From cf4b3ce4d17f5ff82d84938b6b8a17b8b7a808b5 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Sat, 11 Jul 2026 16:24:53 +0000 Subject: [PATCH] chore: aktuellen Live-Stand sichern (Admin-API, Services, Auth-Erweiterungen) Sichert den bisher uncommitteten Produktionsstand des Kundenbereich-Servers, damit kuenftige Deploys (git reset --hard) nichts mehr verwerfen. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + package-lock.json | 10 + package.json | 1 + public/app.js | 79 +++++- server/config.js | 22 ++ server/index.js | 25 ++ server/middleware/appwriteStaff.js | 44 ++++ server/routes/admin/customers.js | 53 ++++ server/routes/admin/employees.js | 51 ++++ server/routes/admin/gitea.js | 37 +++ server/routes/admin/invoiceClients.js | 19 ++ server/routes/admin/previewOpen.js | 91 +++++++ server/routes/admin/websiteProjects.js | 32 +++ server/routes/auth.js | 165 ++++++++++++- server/routes/webhook/gitea.js | 168 +++++-------- server/services/appwriteAdmin.js | 98 +++++++- server/services/customerAdmin.js | 258 ++++++++++++++++++++ server/services/employeeAdmin.js | 160 ++++++++++++ server/services/gitWorksheet.js | 123 ++++++++++ server/services/giteaAdmin.js | 323 +++++++++++++++++++++++++ server/services/invoiceNinja.js | 81 +++++++ server/services/previewDeploy.js | 89 +++++++ server/services/previewSession.js | 44 ++++ server/services/staffPreviewTokens.js | 30 +++ 24 files changed, 1883 insertions(+), 123 deletions(-) create mode 100644 server/middleware/appwriteStaff.js create mode 100644 server/routes/admin/customers.js create mode 100644 server/routes/admin/employees.js create mode 100644 server/routes/admin/gitea.js create mode 100644 server/routes/admin/invoiceClients.js create mode 100644 server/routes/admin/previewOpen.js create mode 100644 server/routes/admin/websiteProjects.js create mode 100644 server/services/customerAdmin.js create mode 100644 server/services/employeeAdmin.js create mode 100644 server/services/gitWorksheet.js create mode 100644 server/services/giteaAdmin.js create mode 100644 server/services/invoiceNinja.js create mode 100644 server/services/previewDeploy.js create mode 100644 server/services/previewSession.js create mode 100644 server/services/staffPreviewTokens.js diff --git a/.gitignore b/.gitignore index a77ccdf..42d77f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ .DS_Store preview-data/ *.log + +server/graphify-out/ +graphify-out/ diff --git a/package-lock.json b/package-lock.json index 2b766c6..185bb37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.4.7", "express": "^4.21.2", + "jose": "^6.0.11", "node-appwrite": "^14.1.0" }, "engines": { @@ -472,6 +473,15 @@ "node": ">= 0.10" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 1013668..3b43429 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.4.7", "express": "^4.21.2", + "jose": "^6.0.11", "node-appwrite": "^14.1.0" } } diff --git a/public/app.js b/public/app.js index 9618aa0..769857c 100644 --- a/public/app.js +++ b/public/app.js @@ -44,7 +44,7 @@ function renderProjectCard(project, features) {

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

Subdomain
${escapeHtml(project.subdomain || '–')}
-
Vorschau
${project.previewUrl ? `${escapeHtml(project.previewUrl)}` : '–'}
+
Vorschau
${project.previewUrl ? `Projekt-Webseite öffnen` : '–'}
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}
Status
${escapeHtml(project.status || '–')}
Bereitstellung
${escapeHtml(project.provisioningStatus || '–')}
@@ -66,11 +66,84 @@ function escapeAttr(str) { return escapeHtml(str).replace(/'/g, ''') } +function getQueryParam(name) { + return new URLSearchParams(window.location.search).get(name) || '' +} + +function safeRedirectUrl(url) { + try { + const u = new URL(url) + if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') { + return u.toString() + } + } catch { + /* ignore invalid URL */ + } + return null +} + +function isProjectPreviewReady(project) { + const status = (project?.provisioningStatus || project?.status || '').toLowerCase() + return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl) +} + +function previewSubdomainFromUrl(url) { + try { + const host = new URL(url).hostname.toLowerCase() + const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/) + return match ? match[1] : '' + } catch { + return '' + } +} + +async function canAccessPreviewUrl(url) { + const subdomain = previewSubdomainFromUrl(url) + if (!subdomain) return false + try { + const access = await fetch( + `/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`, + { credentials: 'same-origin' } + ).then((r) => r.json()) + return Boolean(access.allowed) + } catch { + return false + } +} + +async function resolvePostLoginTarget() { + const next = safeRedirectUrl(getQueryParam('next')) + if (next && (await canAccessPreviewUrl(next))) { + return next + } + + try { + const { projects } = await api('/api/projects') + const readyProjects = (projects || []).filter(isProjectPreviewReady) + if (readyProjects.length === 1) { + return readyProjects[0].previewUrl + } + } catch { + /* dashboard fallback */ + } + + return '/dashboard.html' +} + +async function postLoginRedirect() { + window.location.href = await resolvePostLoginTarget() +} + async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') + const errorFromQuery = getQueryParam('error') + if (errorFromQuery) { + showError(errorEl, errorFromQuery) + } + async function applyCooldown(seconds) { if (seconds <= 0) return showError( @@ -106,7 +179,7 @@ async function initLoginPage() { try { const me = await api('/api/auth/me') if (me.authenticated && me.customer) { - window.location.href = '/dashboard.html' + await postLoginRedirect() return } } catch { @@ -129,7 +202,7 @@ async function initLoginPage() { password: document.getElementById('password').value, }), }) - window.location.href = '/dashboard.html' + await postLoginRedirect() return } catch (err) { showError(errorEl, err.message) diff --git a/server/config.js b/server/config.js index fd87b7f..651cd79 100644 --- a/server/config.js +++ b/server/config.js @@ -43,17 +43,39 @@ export const config = { customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess', websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects', portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures', + workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders', + worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets', + employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees', }, gitea: { webhookToken: process.env.GITEA_WEBHOOK_TOKEN || '', baseUrl: process.env.GITEA_BASE_URL || 'https://git.webklar.com', apiToken: process.env.GITEA_API_TOKEN || '', + templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso', + templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template', + defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso', + }, + + cors: { + allowedOrigins: (process.env.ADMIN_CORS_ORIGINS || 'https://ticket.webklar.com,http://localhost:5173') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), }, preview: { baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com', deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '', + sessionSecret: + process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '', + cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com', + sessionMaxAge: Number(process.env.PREVIEW_SESSION_MAX_AGE || 60 * 60 * 24 * 7), + }, + + invoiceNinja: { + url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''), + apiToken: process.env.INVOICE_NINJA_API_TOKEN || '', }, } diff --git a/server/index.js b/server/index.js index ebd49db..16462bd 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,12 @@ import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' import featuresRoutes from './routes/features.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' +import adminGiteaRoutes from './routes/admin/gitea.js' +import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' +import adminCustomersRoutes from './routes/admin/customers.js' +import adminEmployeesRoutes from './routes/admin/employees.js' +import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' +import adminPreviewOpenRoutes from './routes/admin/previewOpen.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -19,9 +25,28 @@ app.set('trust proxy', 1) app.use(sessionMiddleware()) app.use(express.json({ limit: '2mb' })) +app.use((req, res, next) => { + const origin = req.get('Origin') + if (origin && config.cors.allowedOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + if (req.method === 'OPTIONS') { + return res.sendStatus(204) + } + } + return next() +}) + app.use('/api/auth', authRoutes) app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) +app.use('/api/admin/gitea', adminGiteaRoutes) +app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) +app.use('/api/admin/customers', adminCustomersRoutes) +app.use('/api/admin/employees', adminEmployeesRoutes) +app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) +app.use('/api/admin/preview-open', adminPreviewOpenRoutes) app.use('/webhook', giteaWebhookRoutes) app.get('/api/health', async (_req, res) => { diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js new file mode 100644 index 0000000..ba7df49 --- /dev/null +++ b/server/middleware/appwriteStaff.js @@ -0,0 +1,44 @@ +import { config } from '../config.js' + +export async function requireAppwriteStaff(req, res, next) { + const authHeader = req.get('Authorization') || '' + const match = authHeader.match(/^Bearer\s+(.+)$/i) + if (!match) { + return res.status(401).json({ error: 'Authorization Bearer JWT erforderlich' }) + } + + const jwt = match[1] + + try { + const response = await fetch(`${config.appwrite.endpoint}/account`, { + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': config.appwrite.projectId, + 'X-Appwrite-JWT': jwt, + }, + }) + + const text = await response.text() + let data = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = { message: text } + } + } + + if (!response.ok) { + return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + } + + if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { + return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + } + + req.appwriteUser = data + return next() + } catch (err) { + return res.status(500).json({ error: err.message || 'JWT-Validierung fehlgeschlagen' }) + } +} diff --git a/server/routes/admin/customers.js b/server/routes/admin/customers.js new file mode 100644 index 0000000..5539e3c --- /dev/null +++ b/server/routes/admin/customers.js @@ -0,0 +1,53 @@ +import { Router } from 'express' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { + createCustomerWithPortalAccess, + deleteCustomerWithPortalAccess, + listCustomersForAdmin, + updateCustomerWithPortalAccess, +} from '../../services/customerAdmin.js' + +const router = Router() + +router.use(requireAppwriteStaff) + +router.get('/', async (_req, res) => { + try { + const customers = await listCustomersForAdmin() + return res.json({ customers }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Kunden konnten nicht geladen werden' }) + } +}) + +router.post('/', async (req, res) => { + try { + const result = await createCustomerWithPortalAccess(req.body || {}) + return res.status(201).json(result) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Kunde konnte nicht angelegt werden' }) + } +}) + +router.patch('/:customerId', async (req, res) => { + try { + const result = await updateCustomerWithPortalAccess(req.params.customerId, req.body || {}) + return res.json(result) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' }) + } +}) + +router.delete('/:customerId', async (req, res) => { + try { + return res.json(await deleteCustomerWithPortalAccess(req.params.customerId)) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Kunde konnte nicht gelöscht werden' }) + } +}) + +export default router diff --git a/server/routes/admin/employees.js b/server/routes/admin/employees.js new file mode 100644 index 0000000..85160df --- /dev/null +++ b/server/routes/admin/employees.js @@ -0,0 +1,51 @@ +import { Router } from 'express' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { + createEmployeeWithLogin, + deleteEmployeeWithLogin, + listEmployeesForAdmin, + updateEmployeeWithLogin, +} from '../../services/employeeAdmin.js' + +const router = Router() +router.use(requireAppwriteStaff) + +router.get('/', async (_req, res) => { + try { + return res.json({ employees: await listEmployeesForAdmin() }) + } catch (error) { + return res.status(error.status || 500).json({ error: error.message }) + } +}) + +router.post('/', async (req, res) => { + try { + return res.status(201).json(await createEmployeeWithLogin(req.body || {})) + } catch (error) { + return res.status(error.status || 500).json({ error: error.message }) + } +}) + +router.patch('/:employeeId', async (req, res) => { + try { + return res.json(await updateEmployeeWithLogin( + req.params.employeeId, + req.body || {}, + req.appwriteUser.$id + )) + } catch (error) { + return res.status(error.status || 500).json({ error: error.message }) + } +}) + +router.delete('/:employeeId', async (req, res) => { + try { + return res.json( + await deleteEmployeeWithLogin(req.params.employeeId, req.appwriteUser.$id) + ) + } catch (error) { + return res.status(error.status || 500).json({ error: error.message }) + } +}) + +export default router diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js new file mode 100644 index 0000000..20d40ac --- /dev/null +++ b/server/routes/admin/gitea.js @@ -0,0 +1,37 @@ +import { Router } from 'express' +import { syncReposToAppwrite, fetchRepoReadme } from '../../services/giteaAdmin.js' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' + +const router = Router() + +router.use(requireAppwriteStaff) + +router.post('/sync-repos', async (_req, res) => { + try { + const result = await syncReposToAppwrite() + return res.json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Gitea-Sync fehlgeschlagen' }) + } +}) + +router.get('/repo-readme', async (req, res) => { + const repoFullName = String(req.query.repoFullName || '').trim() + if (!repoFullName) { + return res.status(400).json({ error: 'repoFullName ist erforderlich' }) + } + + try { + const readme = await fetchRepoReadme(repoFullName) + if (!readme) { + return res.json({ ok: true, found: false, repoFullName }) + } + return res.json({ ok: true, found: true, repoFullName, ...readme }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'README konnte nicht geladen werden' }) + } +}) + +export default router diff --git a/server/routes/admin/invoiceClients.js b/server/routes/admin/invoiceClients.js new file mode 100644 index 0000000..8fee152 --- /dev/null +++ b/server/routes/admin/invoiceClients.js @@ -0,0 +1,19 @@ +import { Router } from 'express' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { listInvoiceNinjaClients } from '../../services/invoiceNinja.js' + +const router = Router() + +router.use(requireAppwriteStaff) + +router.get('/', async (_req, res) => { + try { + const clients = await listInvoiceNinjaClients() + return res.json({ clients }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Kunden konnten nicht geladen werden' }) + } +}) + +export default router diff --git a/server/routes/admin/previewOpen.js b/server/routes/admin/previewOpen.js new file mode 100644 index 0000000..47ada10 --- /dev/null +++ b/server/routes/admin/previewOpen.js @@ -0,0 +1,91 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { listDocuments, Query } from '../../services/appwriteAdmin.js' +import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js' + +const router = Router() + +function isStaffAdmin(user) { + return (user?.labels || []).includes('admin') +} + +function isPreviewProjectReady(project) { + if (!project) return false + if (project.hasPreview === false) return false + const status = (project.provisioningStatus || project.status || '').toLowerCase() + return ['ready', 'deployed'].includes(status) +} + +function safePreviewUrl(url) { + try { + const parsed = new URL(String(url || '')) + if ( + parsed.hostname.endsWith('.project.webklar.com') || + parsed.hostname === 'project.webklar.com' + ) { + return parsed.toString() + } + } catch { + /* ignore */ + } + return null +} + +function previewUrlForSubdomain(subdomain) { + const safe = String(subdomain || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + if (!safe) return null + return `https://${safe}.${config.preview.baseHost}` +} + +router.use(requireAppwriteStaff) + +router.post('/', async (req, res) => { + const user = req.appwriteUser + if (!isStaffAdmin(user)) { + return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + } + + const subdomain = String(req.body?.subdomain || '') + .trim() + .toLowerCase() + const previewUrl = safePreviewUrl(req.body?.previewUrl) || previewUrlForSubdomain(subdomain) + + if (!subdomain || !previewUrl) { + return res.status(400).json({ error: 'subdomain oder gueltige previewUrl erforderlich' }) + } + + try { + const projects = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(1), + ]) + const project = projects[0] + if (!project) { + return res.status(404).json({ error: 'Projekt nicht gefunden' }) + } + if (!isPreviewProjectReady(project)) { + return res.status(409).json({ error: 'Projekt-Vorschau ist noch nicht bereit' }) + } + + const token = createStaffPreviewToken({ + userId: user.$id, + email: user.email || '', + name: user.name || user.email || 'Admin', + previewUrl, + customerId: project.customerId || '', + }) + + const portalBase = `https://${config.preview.baseHost}` + return res.json({ + openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}`, + }) + } catch (err) { + return res.status(err.status || 500).json({ error: err.message || 'Preview-Login fehlgeschlagen' }) + } +}) + +export default router diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js new file mode 100644 index 0000000..19da1f4 --- /dev/null +++ b/server/routes/admin/websiteProjects.js @@ -0,0 +1,32 @@ +import { Router } from 'express' +import { createProjectFromTemplate } from '../../services/giteaAdmin.js' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' + +const router = Router() + +router.use(requireAppwriteStaff) + +router.post('/create-from-template', async (req, res) => { + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + + if (!repoName || !subdomain) { + return res.status(400).json({ error: 'repoName und subdomain sind erforderlich' }) + } + + try { + const result = await createProjectFromTemplate({ + repoName, + displayName, + subdomain, + customerId, + ticketId, + owner, + }) + return res.status(201).json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Projekt konnte nicht angelegt werden' }) + } +}) + +export default router diff --git a/server/routes/auth.js b/server/routes/auth.js index beef1c6..d154c82 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -3,14 +3,25 @@ import { config } from '../config.js' import { getCustomerByAppwriteUserId, getCustomerByEmail, + getDocument, getPortalAccessByCustomerId, + listDocuments, updateDocument, + Query, } from '../services/appwriteAdmin.js' import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js' import { clearPortalSession, setPortalSession, } from '../middleware/session.js' +import { + PREVIEW_COOKIE_NAME, + clearPreviewSession, + createPreviewSessionToken, + previewSessionCookieOptions, + verifyPreviewSessionToken, +} from '../services/previewSession.js' +import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js' const router = Router() @@ -64,6 +75,62 @@ async function validatePortalAccess(appwriteUserId, email) { return { customer, portalAccess } } +async function syncCustomerAppwriteUserId(customer, appwriteUserId) { + if (!customer?.$id || !appwriteUserId) return customer + if (customer.appwriteUserId === appwriteUserId) return customer + + const now = new Date().toISOString() + return updateDocument(config.collections.customers, customer.$id, { + appwriteUserId, + portalAccessEnabled: true, + updatedAt: now, + }) +} + +async function resolveSessionCustomer(session) { + if (!session?.customerId) return null + + let customer = await getDocument(config.collections.customers, session.customerId).catch(() => null) + if (customer) return customer + + if (session.appwriteUserId) { + customer = await getCustomerByAppwriteUserId(session.appwriteUserId) + if (customer) return customer + } + + if (session.email) { + customer = await getCustomerByEmail(session.email) + } + + return customer +} + +function isPreviewProjectReady(project) { + if (!project) return false + if (project.hasPreview === false) return false + const status = (project.provisioningStatus || project.status || '').toLowerCase() + return ['ready', 'deployed'].includes(status) +} + +function isAdminSession(session) { + return session?.role === 'admin' || (session?.labels || []).includes('admin') +} + +function safePreviewRedirectUrl(url) { + try { + const parsed = new URL(String(url || '')) + if ( + parsed.hostname.endsWith('.project.webklar.com') || + parsed.hostname === 'project.webklar.com' + ) { + return parsed.toString() + } + } catch { + /* ignore */ + } + return null +} + router.get('/login-status', (_req, res) => { const retryAfterSeconds = getLoginCooldownRemainingSec() res.json({ @@ -80,7 +147,8 @@ router.post('/login', async (req, res) => { try { const user = await loginWithAppwrite(email.trim(), password) - const { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim()) + let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim()) + customer = await syncCustomerAppwriteUserId(customer, user.$id) setPortalSession(res, { customerId: customer.$id, @@ -89,6 +157,17 @@ router.post('/login', async (req, res) => { email: customer.email || user.email || email, }) + const previewToken = await createPreviewSessionToken({ + userId: user.$id, + customerId: customer.$id, + email: customer.email || user.email || email, + name: customer.name || user.name || '', + role: 'customer', + labels: [], + authSource: 'kundenbereich', + }) + res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions()) + try { await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, { lastLoginAt: new Date().toISOString(), @@ -120,9 +199,78 @@ router.post('/login', async (req, res) => { router.post('/logout', (_req, res) => { clearPortalSession(res) + clearPreviewSession(res) res.json({ success: true }) }) +router.get('/preview-access', async (req, res) => { + const token = req.cookies?.[PREVIEW_COOKIE_NAME] + if (!token) { + return res.status(401).json({ allowed: false }) + } + + const session = await verifyPreviewSessionToken(token) + const admin = isAdminSession(session) + if (!session || (!session.customerId && !admin)) { + return res.status(401).json({ allowed: false }) + } + + const subdomain = String(req.query.subdomain || '') + .trim() + .toLowerCase() + if (!subdomain) { + return res.status(400).json({ allowed: false, error: 'subdomain required' }) + } + + try { + const queries = [Query.equal('subdomain', subdomain), Query.limit(1)] + if (!admin) { + queries.splice(1, 0, Query.equal('customerId', session.customerId)) + } + + const projects = await listDocuments(config.collections.websiteProjects, queries) + const project = projects[0] + if (!project) { + return res.json({ allowed: false, reason: 'no_project' }) + } + if (!isPreviewProjectReady(project)) { + return res.json({ allowed: false, reason: 'not_ready', projectId: project.$id }) + } + return res.json({ allowed: true, projectId: project.$id }) + } catch (err) { + return res.status(500).json({ allowed: false, error: err.message || 'Fehler' }) + } +}) + +router.get('/staff-preview', async (req, res) => { + const tokenValue = String(req.query.token || '').trim() + if (!tokenValue) { + return res.status(400).send('Token fehlt') + } + + const tokenData = consumeStaffPreviewToken(tokenValue) + if (!tokenData) { + return res.status(410).send('Der Preview-Link ist abgelaufen. Bitte erneut aus dem Ticketsystem oeffnen.') + } + + const previewUrl = safePreviewRedirectUrl(tokenData.previewUrl) + if (!previewUrl) { + return res.status(400).send('Ungueltige Preview-URL') + } + + const previewToken = await createPreviewSessionToken({ + userId: tokenData.userId, + customerId: tokenData.customerId || '', + email: tokenData.email || '', + name: tokenData.name || 'Admin', + role: 'admin', + labels: ['admin'], + authSource: 'ticket-admin', + }) + res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions()) + return res.redirect(previewUrl) +}) + router.get('/me', async (req, res) => { const raw = req.signedCookies?.[config.cookieName] if (!raw) { @@ -135,12 +283,25 @@ router.get('/me', async (req, res) => { return res.json({ authenticated: false }) } - const customer = await getCustomerByAppwriteUserId(session.appwriteUserId) + let customer = await resolveSessionCustomer(session) if (!customer) { clearPortalSession(res) + clearPreviewSession(res) return res.json({ authenticated: false }) } + try { + await validatePortalAccess(session.appwriteUserId, session.email || customer.email) + } catch { + clearPortalSession(res) + clearPreviewSession(res) + return res.json({ authenticated: false }) + } + + if (customer.appwriteUserId !== session.appwriteUserId) { + customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId) + } + return res.json({ authenticated: true, customer: sanitizeCustomer(customer), diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 053deda..0470eb1 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -1,12 +1,14 @@ import { Router } from 'express' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -import fs from 'node:fs/promises' -import path from 'node:path' import { config } from '../../config.js' import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js' +import { + fetchPreviewConfig, + defaultPreviewConfig, + runDeploy, + buildPreviewUrl, +} from '../../services/previewDeploy.js' +import { createGitPushWorksheet } from '../../services/gitWorksheet.js' -const execFileAsync = promisify(execFile) const router = Router() function verifyWebhookToken(req) { @@ -17,84 +19,6 @@ function verifyWebhookToken(req) { return queryToken === token || headerToken === token } -async function fetchPreviewConfig(repoFullName, branch) { - const [owner, repo] = repoFullName.split('/') - if (!owner || !repo || !config.gitea.apiToken) { - return null - } - - const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}` - const response = await fetch(url, { - headers: { Authorization: `token ${config.gitea.apiToken}` }, - }) - - if (!response.ok) return null - - const data = await response.json() - const content = Buffer.from(data.content || '', 'base64').toString('utf8') - return JSON.parse(content) -} - -function defaultPreviewConfig(repoFullName) { - const repoName = repoFullName.split('/').pop() || 'preview' - return { - enabled: true, - type: 'static', - branch: 'main', - displayName: repoName, - subdomain: repoName, - } -} - -async function cloneRepo(cloneUrl, targetDir, branch) { - await fs.rm(targetDir, { recursive: true, force: true }) - await fs.mkdir(targetDir, { recursive: true }) - await execFileAsync('git', [ - 'clone', - '--depth', - '1', - '--branch', - branch, - cloneUrl, - targetDir, - ]) -} - -async function deployStatic(sourceDir, targetDir, indexSubdir = '') { - const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir - await fs.rm(targetDir, { recursive: true, force: true }) - await fs.mkdir(targetDir, { recursive: true }) - await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir]) -} - -async function deployNodeBuild(sourceDir, targetDir) { - await execFileAsync('npm', ['ci'], { cwd: sourceDir }) - await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir }) - const distDir = path.join(sourceDir, 'dist') - await deployStatic(distDir, targetDir) -} - -async function runDeploy(repoFullName, branch, previewConfig) { - if (!config.preview.deployRoot) { - return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' } - } - - const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() - const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` - const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_')) - const targetDir = path.join(config.preview.deployRoot, subdomain) - - await cloneRepo(cloneUrl, workDir, branch) - - if (previewConfig.type === 'node_build') { - await deployNodeBuild(workDir, targetDir) - } else { - await deployStatic(workDir, targetDir, previewConfig.index || '') - } - - return { deployed: true, subdomain, targetDir } -} - router.post('/gitea', async (req, res) => { if (!verifyWebhookToken(req)) { return res.status(401).json({ error: 'Unauthorized' }) @@ -104,6 +28,9 @@ router.post('/gitea', async (req, res) => { const ref = payload.ref || '' const repo = payload.repository || {} const repoFullName = repo.full_name || '' + const commits = payload.commits || [] + const pusher = payload.pusher || {} + const commitSha = payload.after || commits[commits.length - 1]?.id || '' if (!repoFullName) { return res.status(400).json({ error: 'repository.full_name fehlt' }) @@ -112,42 +39,58 @@ router.post('/gitea', async (req, res) => { const branch = ref.replace('refs/heads/', '') || 'main' try { - let previewConfig = await fetchPreviewConfig(repoFullName, branch) - if (!previewConfig) { - previewConfig = defaultPreviewConfig(repoFullName) - } - - if (!previewConfig.enabled) { - return res.json({ ok: true, skipped: true, reason: 'preview disabled' }) - } - - const configBranch = previewConfig.branch || 'main' - if (branch !== configBranch) { - return res.json({ ok: true, skipped: true, reason: `branch ${branch} != ${configBranch}` }) - } - + const previewConfigFile = await fetchPreviewConfig(repoFullName, branch) + const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName) + const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() - const previewUrl = `https://${subdomain}.${config.preview.baseHost}` + const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' - let deployResult = { deployed: false } - try { - deployResult = await runDeploy(repoFullName, branch, previewConfig) - } catch (deployErr) { - console.error('[webhook] deploy failed:', deployErr.message) - deployResult = { deployed: false, error: deployErr.message } + let deployResult = { deployed: false, skipped: true } + const configBranch = previewConfig.branch || 'main' + + if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { + try { + deployResult = await runDeploy(repoFullName, branch, previewConfig) + } catch (deployErr) { + console.error('[webhook] deploy failed:', deployErr.message) + deployResult = { deployed: false, error: deployErr.message } + } } - const project = await upsertWebsiteProjectByRepo(repoFullName, { - projectName: previewConfig.displayName || subdomain, - templateName: previewConfig.templateName || 'webklar-preview-template', + const projectPayload = { + projectName: previewConfig.displayName || repo.name || subdomain, + templateName: previewConfig.templateName || config.gitea.templateRepo, giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || subdomain, repoFullName, - subdomain, - previewUrl, - status: deployResult.deployed ? 'deployed' : 'pending', - provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed', - }) + } + + if (subdomain) projectPayload.subdomain = subdomain + if (previewUrl) projectPayload.previewUrl = previewUrl + + if (deployResult.deployed) { + projectPayload.status = 'deployed' + projectPayload.provisioningStatus = 'ready' + } else if (hasPreview) { + projectPayload.status = 'pending' + projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' + } else { + projectPayload.status = 'synced' + } + + const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload) + + let gitWorksheet = null + if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') { + gitWorksheet = await createGitPushWorksheet({ + ticketId: project.ticketId, + repoFullName, + branch, + commits, + pusher, + commitSha, + }) + } return res.json({ ok: true, @@ -156,6 +99,7 @@ router.post('/gitea', async (req, res) => { previewUrl, deploy: deployResult, projectId: project.$id, + gitWorksheet, }) } catch (err) { console.error('[webhook]', err) diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e892989..a777002 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -16,9 +16,9 @@ function adminHeaders() { } } -function formatRequestBody(body, method) { +function formatRequestBody(body, method, { raw = false } = {}) { if (!body || method === 'GET' || method === 'DELETE') return body - if (body.data !== undefined) return body + if (raw || body.data !== undefined) return body const { documentId, ...fields } = body const payload = { data: fields } if (documentId) payload.documentId = documentId @@ -37,7 +37,8 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) { url.searchParams.append('queries[]', q) } - const requestBody = formatRequestBody(body, method) + const useRawBody = path.startsWith('/users') + const requestBody = formatRequestBody(body, method, { raw: useRawBody }) const response = await fetch(url.toString(), { method, @@ -90,6 +91,50 @@ export async function getUserById(userId) { return adminFetch(`/users/${userId}`) } +export async function createAppwriteUser({ email, password, name }) { + return adminFetch('/users', { + method: 'POST', + body: { + userId: ID.unique(), + email: email.trim(), + password, + name: name?.trim() || email.trim(), + }, + }) +} + +export async function updateAppwriteUserEmail(userId, email) { + return adminFetch(`/users/${userId}/email`, { + method: 'PATCH', + body: { email: email.trim() }, + }) +} + +export async function updateAppwriteUserPassword(userId, password) { + return adminFetch(`/users/${userId}/password`, { + method: 'PATCH', + body: { password }, + }) +} + +export async function updateAppwriteUserName(userId, name) { + return adminFetch(`/users/${userId}/name`, { + method: 'PATCH', + body: { name: name.trim() }, + }) +} + +export async function updateAppwriteUserLabels(userId, labels) { + return adminFetch(`/users/${userId}/labels`, { + method: 'PUT', + body: { labels }, + }) +} + +export async function deleteAppwriteUser(userId) { + return adminFetch(`/users/${userId}`, { method: 'DELETE' }) +} + export async function deleteUserSession(userId, sessionId) { return adminFetch(`/users/${userId}/sessions/${sessionId}`, { method: 'DELETE' }) } @@ -131,14 +176,47 @@ export async function updateDocument(collectionId, documentId, data) { }) } +export async function getDocument(collectionId, documentId) { + return adminFetch(`${collectionPath(collectionId)}/${documentId}`) +} + +export async function createDocument(collectionId, data, documentId = ID.unique()) { + return adminFetch(collectionPath(collectionId), { + method: 'POST', + body: { ...data, documentId }, + }) +} + +export async function deleteDocument(collectionId, documentId) { + return adminFetch(`${collectionPath(collectionId)}/${documentId}`, { + method: 'DELETE', + }) +} + +const PRESERVE_IF_EMPTY = new Set(['customerId', 'ticketId']) + +function buildProjectPayload(existing, data) { + const now = new Date().toISOString() + const payload = { updatedAt: now } + + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue + if (PRESERVE_IF_EMPTY.has(key) && (value === '' || value === null)) { + if (existing?.[key]) continue + } + payload[key] = value + } + + return payload +} + export async function upsertWebsiteProjectByRepo(repoFullName, data) { const existing = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', repoFullName), Query.limit(1), ]) - const now = new Date().toISOString() - const payload = { ...data, updatedAt: now } + const payload = buildProjectPayload(existing[0], data) if (existing[0]) { return updateDocument( @@ -148,9 +226,17 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { ) } + const now = new Date().toISOString() return adminFetch(collectionPath(config.collections.websiteProjects), { method: 'POST', - body: { ...payload, createdAt: now, documentId: ID.unique() }, + body: { + ...payload, + customerId: payload.customerId ?? '', + ticketId: payload.ticketId ?? '', + projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, + createdAt: now, + documentId: ID.unique(), + }, }) } diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js new file mode 100644 index 0000000..694adc2 --- /dev/null +++ b/server/services/customerAdmin.js @@ -0,0 +1,258 @@ +import { config } from '../config.js' +import { + createAppwriteUser, + createDocument, + deleteDocument, + getCustomerByEmail, + getDocument, + getPortalAccessByCustomerId, + listDocuments, + updateAppwriteUserEmail, + updateAppwriteUserPassword, + deleteAppwriteUser, + updateDocument, + Query, +} from './appwriteAdmin.js' + +const PORTAL_PASSWORD_PREFIX = '__portalPassword__:' + +function readPortalPassword(customer) { + if (customer?.portalPassword) return customer.portalPassword + const notes = customer?.notes || '' + const match = notes.match(/^__portalPassword__:([^\n]+)/) + return match ? match[1] : '' +} + +function notesWithPortalPassword(existingNotes, password) { + const stripped = (existingNotes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim() + if (!password) return stripped || '' + const line = `${PORTAL_PASSWORD_PREFIX}${password}` + return stripped ? `${line}\n${stripped}` : line +} + +function sanitizeCustomer(customer, { portalPassword } = {}) { + const storedPassword = portalPassword || readPortalPassword(customer) + return { + $id: customer.$id, + code: customer.code || '', + name: customer.name || '', + location: customer.location || '', + email: customer.email || '', + phone: customer.phone || '', + companyName: customer.companyName || '', + portalAccessEnabled: Boolean(customer.portalAccessEnabled), + appwriteUserId: customer.appwriteUserId || '', + customerStatus: customer.customerStatus || '', + portalPassword: storedPassword || '', + } +} + +async function ensurePortalAccess(customerId, appwriteUserId) { + const now = new Date().toISOString() + let portalAccess = await getPortalAccessByCustomerId(customerId) + if (portalAccess) { + portalAccess = await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, { + enabled: true, + appwriteUserId, + passwordSet: true, + }) + } else { + portalAccess = await createDocument(config.collections.customerPortalAccess, { + customerId, + enabled: true, + appwriteUserId, + passwordSet: true, + }) + } + return portalAccess +} + +export async function createCustomerWithPortalAccess(payload) { + const { + code = '', + name, + location = '', + email, + phone = '', + password, + } = payload || {} + + if (!name?.trim()) { + const error = new Error('Name ist erforderlich') + error.status = 400 + throw error + } + if (!email?.trim()) { + const error = new Error('E-Mail ist erforderlich') + error.status = 400 + throw error + } + if (!password || password.length < 8) { + const error = new Error('Passwort muss mindestens 8 Zeichen haben') + error.status = 400 + throw error + } + + const existing = await getCustomerByEmail(email.trim()) + if (existing) { + const error = new Error('Ein Kunde mit dieser E-Mail existiert bereits') + error.status = 409 + throw error + } + + const appwriteUser = await createAppwriteUser({ + email: email.trim(), + password, + name: name.trim(), + }) + + const now = new Date().toISOString() + let customer + try { + customer = await createDocument(config.collections.customers, { + code: code.trim(), + name: name.trim(), + location: location.trim(), + email: email.trim(), + phone: phone.trim(), + notes: notesWithPortalPassword('', password), + portalAccessEnabled: true, + appwriteUserId: appwriteUser.$id, + customerStatus: 'active', + createdAt: now, + updatedAt: now, + }) + await ensurePortalAccess(customer.$id, appwriteUser.$id) + } catch (err) { + try { + await deleteAppwriteUser(appwriteUser.$id) + } catch { + /* ignore rollback failure */ + } + throw err + } + + return { + customer: sanitizeCustomer(customer, { portalPassword: password }), + } +} + +export async function updateCustomerWithPortalAccess(customerId, payload) { + const customer = await getDocument(config.collections.customers, customerId).catch(() => null) + if (!customer) { + const error = new Error('Kunde nicht gefunden') + error.status = 404 + throw error + } + + const { + code, + name, + location, + email, + phone, + password, + } = payload || {} + + const nextEmail = (email ?? customer.email ?? '').trim() + if (!nextEmail) { + const error = new Error('E-Mail ist erforderlich') + error.status = 400 + throw error + } + if (password && password.length < 8) { + const error = new Error('Passwort muss mindestens 8 Zeichen haben') + error.status = 400 + throw error + } + + const normalizedPassword = + typeof password === 'string' && password.trim().length > 0 ? password : undefined + + if (nextEmail.toLowerCase() !== (customer.email || '').trim().toLowerCase()) { + const existing = await getCustomerByEmail(nextEmail) + if (existing && existing.$id !== customerId) { + const error = new Error('Ein anderer Kunde nutzt diese E-Mail bereits') + error.status = 409 + throw error + } + } + + let appwriteUserId = customer.appwriteUserId || '' + let portalPassword + + if (!appwriteUserId && nextEmail && normalizedPassword) { + const appwriteUser = await createAppwriteUser({ + email: nextEmail, + password: normalizedPassword, + name: (name ?? customer.name ?? nextEmail).trim(), + }) + appwriteUserId = appwriteUser.$id + portalPassword = normalizedPassword + } else if (appwriteUserId) { + if (nextEmail && nextEmail !== customer.email) { + await updateAppwriteUserEmail(appwriteUserId, nextEmail) + } + if (normalizedPassword) { + await updateAppwriteUserPassword(appwriteUserId, normalizedPassword) + portalPassword = normalizedPassword + } + } else if (normalizedPassword) { + const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern') + error.status = 400 + throw error + } + + const now = new Date().toISOString() + const nextPortalPassword = + normalizedPassword || readPortalPassword(customer) || undefined + + const updated = await updateDocument(config.collections.customers, customerId, { + ...(code !== undefined ? { code: String(code).trim() } : {}), + ...(name !== undefined ? { name: String(name).trim() } : {}), + ...(location !== undefined ? { location: String(location).trim() } : {}), + email: nextEmail, + ...(phone !== undefined ? { phone: String(phone).trim() } : {}), + ...(nextPortalPassword + ? { notes: notesWithPortalPassword(customer.notes, nextPortalPassword) } + : {}), + ...(appwriteUserId ? { appwriteUserId, portalAccessEnabled: true, customerStatus: 'active' } : {}), + updatedAt: now, + }) + + if (appwriteUserId) { + await ensurePortalAccess(customerId, appwriteUserId) + } + + return { + customer: sanitizeCustomer(updated, portalPassword ? { portalPassword } : {}), + } +} + +export async function listCustomersForAdmin() { + const customers = await listDocuments(config.collections.customers, [Query.orderAsc('name')]) + return customers.map((customer) => sanitizeCustomer(customer)) +} + +export async function deleteCustomerWithPortalAccess(customerId) { + const customer = await getDocument(config.collections.customers, customerId) + .catch(() => null) + if (!customer) { + const error = new Error('Kunde nicht gefunden') + error.status = 404 + throw error + } + + const portalAccess = await getPortalAccessByCustomerId(customerId) + if (portalAccess) { + await deleteDocument( + config.collections.customerPortalAccess, + portalAccess.$id + ) + } + await deleteDocument(config.collections.customers, customerId) + if (customer.appwriteUserId) { + await deleteAppwriteUser(customer.appwriteUserId).catch(() => {}) + } + return { success: true } +} diff --git a/server/services/employeeAdmin.js b/server/services/employeeAdmin.js new file mode 100644 index 0000000..869611f --- /dev/null +++ b/server/services/employeeAdmin.js @@ -0,0 +1,160 @@ +import { config } from '../config.js' +import { + createAppwriteUser, + createDocument, + deleteAppwriteUser, + deleteDocument, + getDocument, + getUserById, + listDocuments, + updateAppwriteUserEmail, + updateAppwriteUserLabels, + updateAppwriteUserName, + updateAppwriteUserPassword, + updateDocument, + Query, +} from './appwriteAdmin.js' + +function httpError(status, message) { + const error = new Error(message) + error.status = status + return error +} + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase() +} + +function normalizeShortcode(value) { + return String(value || '').trim().toUpperCase().slice(0, 10) +} + +function sanitizeEmployee(employee, user) { + return { + $id: employee.$id, + userId: employee.userId, + displayName: employee.displayName || user?.name || '', + email: employee.email || user?.email || '', + shortcode: employee.shortcode || '', + isAdmin: Boolean(user?.labels?.includes('admin')), + } +} + +async function employeeWithUser(employee) { + const user = await getUserById(employee.userId).catch(() => null) + return sanitizeEmployee(employee, user) +} + +async function setAdminLabel(userId, isAdmin) { + const user = await getUserById(userId) + const labels = new Set(user.labels || []) + if (isAdmin) labels.add('admin') + else labels.delete('admin') + await updateAppwriteUserLabels(userId, [...labels]) +} + +export async function listEmployeesForAdmin() { + const employees = await listDocuments(config.collections.employees, [ + Query.orderAsc('displayName'), + Query.limit(500), + ]) + return Promise.all(employees.map(employeeWithUser)) +} + +export async function createEmployeeWithLogin(payload) { + const displayName = String(payload?.displayName || '').trim() + const email = normalizeEmail(payload?.email) + const password = String(payload?.password || '') + const shortcode = normalizeShortcode(payload?.shortcode) + const isAdmin = Boolean(payload?.isAdmin) + + if (!displayName) throw httpError(400, 'Name ist erforderlich') + if (!email) throw httpError(400, 'E-Mail ist erforderlich') + if (password.length < 8) { + throw httpError(400, 'Passwort muss mindestens 8 Zeichen haben') + } + + const user = await createAppwriteUser({ email, password, name: displayName }) + let employee + try { + await setAdminLabel(user.$id, isAdmin) + employee = await createDocument(config.collections.employees, { + userId: user.$id, + displayName, + email, + shortcode, + }) + } catch (error) { + await deleteAppwriteUser(user.$id).catch(() => {}) + throw error + } + + return { employee: await employeeWithUser(employee) } +} + +export async function updateEmployeeWithLogin(employeeId, payload, actorUserId) { + const employee = await getDocument(config.collections.employees, employeeId) + .catch(() => null) + if (!employee) throw httpError(404, 'Mitarbeiter nicht gefunden') + + const displayName = payload?.displayName === undefined + ? employee.displayName + : String(payload.displayName || '').trim() + const email = payload?.email === undefined + ? normalizeEmail(employee.email) + : normalizeEmail(payload.email) + const shortcode = payload?.shortcode === undefined + ? employee.shortcode || '' + : normalizeShortcode(payload.shortcode) + const password = typeof payload?.password === 'string' ? payload.password : '' + + if ( + employee.userId === actorUserId && + payload?.isAdmin === false + ) { + throw httpError(400, 'Die eigene Admin-Berechtigung kann nicht entfernt werden') + } + + if (!displayName) throw httpError(400, 'Name ist erforderlich') + if (!email) throw httpError(400, 'E-Mail ist erforderlich') + if (password && password.length < 8) { + throw httpError(400, 'Passwort muss mindestens 8 Zeichen haben') + } + + const user = await getUserById(employee.userId) + if (email !== normalizeEmail(user.email)) { + await updateAppwriteUserEmail(employee.userId, email) + } + if (displayName !== (user.name || '')) { + await updateAppwriteUserName(employee.userId, displayName) + } + if (password) { + await updateAppwriteUserPassword(employee.userId, password) + } + if (payload?.isAdmin !== undefined) { + await setAdminLabel(employee.userId, Boolean(payload.isAdmin)) + } + + const updated = await updateDocument(config.collections.employees, employeeId, { + displayName, + email, + shortcode, + }) + return { employee: await employeeWithUser(updated) } +} + +export async function deleteEmployeeWithLogin(employeeId, actorUserId) { + const employee = await getDocument(config.collections.employees, employeeId) + .catch(() => null) + if (!employee) throw httpError(404, 'Mitarbeiter nicht gefunden') + if (employee.userId === actorUserId) { + throw httpError(400, 'Der eigene angemeldete Account kann nicht gelöscht werden') + } + + await deleteDocument(config.collections.employees, employeeId) + await deleteAppwriteUser(employee.userId).catch((error) => { + error.message = `Mitarbeiter entfernt, Login konnte nicht gelöscht werden: ${error.message}` + throw error + }) + return { success: true } +} diff --git a/server/services/gitWorksheet.js b/server/services/gitWorksheet.js new file mode 100644 index 0000000..6261e75 --- /dev/null +++ b/server/services/gitWorksheet.js @@ -0,0 +1,123 @@ +import { config } from '../config.js' +import { + listDocuments, + getDocument, + createDocument, + Query, + ID, +} from './appwriteAdmin.js' + +const WSID_START = 100000 + +function formatToday() { + const d = new Date() + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}${m}${day}` +} + +function formatTimeNow() { + const d = new Date() + return `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}` +} + +async function generateNextWsid() { + const docs = await listDocuments(config.collections.worksheets, [ + Query.orderDesc('wsid'), + Query.limit(1), + ]) + const highest = docs[0]?.wsid ? parseInt(docs[0].wsid, 10) : WSID_START - 1 + return String(Math.max(WSID_START, highest + 1)) +} + +async function gitWorksheetExists(woid, commitSha) { + if (!commitSha) return false + const docs = await listDocuments(config.collections.worksheets, [ + Query.equal('woid', woid), + Query.limit(100), + ]) + const marker = `Commit: ${commitSha.slice(0, 7)}` + return docs.some((doc) => doc.details?.includes(marker) || doc.details?.includes(commitSha)) +} + +function formatCommitLines(commits, repoFullName) { + if (!Array.isArray(commits) || commits.length === 0) { + return '- (keine Commit-Details im Webhook)' + } + + return commits + .map((commit) => { + const sha = (commit.id || commit.sha || '').slice(0, 7) + const message = (commit.message || '').split('\n')[0] + const author = commit.author?.name || commit.committer?.name || 'Unbekannt' + const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}` + return `- [${sha}](${url}): ${message} (${author})` + }) + .join('\n') +} + +export async function createGitPushWorksheet({ + ticketId, + repoFullName, + branch, + commits = [], + pusher, + commitSha, +}) { + if (!ticketId) return null + + let workorder + try { + workorder = await getDocument(config.collections.workorders, ticketId) + } catch { + console.warn('[gitWorksheet] Workorder nicht gefunden:', ticketId) + return null + } + + const woid = workorder.woid + if (!woid) return null + + if (await gitWorksheetExists(woid, commitSha)) { + return { skipped: true, reason: 'duplicate_commit' } + } + + const wsid = await generateNextWsid() + const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea' + const latestCommit = commits[commits.length - 1] || commits[0] + const details = [ + `Git Push auf \`${repoFullName}\` (Branch: ${branch})`, + `Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`, + '', + formatCommitLines(commits, repoFullName), + ].join('\n') + + const today = formatToday() + const worksheet = await createDocument( + config.collections.worksheets, + { + wsid, + woid: String(woid), + workorderId: ticketId, + employeeId: 'gitea', + employeeName: pusherName, + employeeShort: 'GIT', + serviceType: 'GIT', + oldStatus: workorder.status || '', + newStatus: workorder.status || '', + oldResponseLevel: workorder.response || '', + newResponseLevel: workorder.response || '', + totalTime: 0, + startDate: today, + startTime: formatTimeNow(), + endDate: today, + endTime: formatTimeNow(), + details, + isComment: true, + createdAt: new Date().toISOString(), + }, + ID.unique() + ) + + return { created: true, wsid, worksheetId: worksheet.$id } +} diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js new file mode 100644 index 0000000..0d5f865 --- /dev/null +++ b/server/services/giteaAdmin.js @@ -0,0 +1,323 @@ +import { config } from '../config.js' +import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js' +import { + runDeploy, + buildPreviewUrl, +} from './previewDeploy.js' + +function giteaHeaders() { + if (!config.gitea.apiToken) { + throw new Error('GITEA_API_TOKEN fehlt in .env') + } + return { + Authorization: `token ${config.gitea.apiToken}`, + 'Content-Type': 'application/json', + } +} + +async function giteaFetch(path, options = {}) { + const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, { + ...options, + headers: { ...giteaHeaders(), ...options.headers }, + }) + + const text = await response.text() + let data = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = { message: text } + } + } + + if (!response.ok) { + let message = data?.message || `Gitea ${response.status}` + if (response.status === 409 && /same name already exists/i.test(message)) { + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + } + const error = new Error(message) + error.status = response.status + throw error + } + + return data +} + +function slugify(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) +} + +export async function listAllAccessibleRepos() { + const repos = [] + const limit = 50 + let page = 1 + + while (true) { + const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`) + const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : [] + if (data.length === 0) break + repos.push(...data) + if (data.length < limit) break + page += 1 + } + + return repos +} + +async function resolveSubdomainForSync(repo, existingDoc) { + if (existingDoc?.subdomain) return existingDoc.subdomain + + const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop()) + if (!candidate) return '' + + const conflict = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', candidate), + Query.limit(1), + ]) + + if (!conflict[0] || conflict[0].repoFullName === repo.full_name) { + return candidate + } + + const ownerSlug = slugify(repo.full_name?.replace('/', '-')) + if (!ownerSlug) return '' + + const ownerConflict = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', ownerSlug), + Query.limit(1), + ]) + + if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) { + return ownerSlug + } + + return '' +} + +export async function syncReposToAppwrite() { + const repos = await listAllAccessibleRepos() + let created = 0 + let updated = 0 + + for (const repo of repos) { + const repoFullName = repo.full_name + if (!repoFullName) continue + + const existing = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + + const branch = repo.default_branch || 'main' + const subdomain = await resolveSubdomainForSync(repo, existing[0]) + const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' + + const payload = { + projectName: repo.description || repo.name || repoFullName.split('/').pop(), + giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, + giteaRepoName: repo.name || repoFullName.split('/').pop(), + repoFullName, + } + + if (subdomain) payload.subdomain = subdomain + if (previewUrl) payload.previewUrl = previewUrl + + if (!existing[0]) { + payload.status = 'synced' + created += 1 + } else { + updated += 1 + } + + await upsertWebsiteProjectByRepo(repoFullName, payload) + } + + return { synced: repos.length, created, updated } +} + +export async function ensureSubdomainAvailable(subdomain) { + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(1), + ]) + if (docs[0]) { + const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`) + error.status = 409 + throw error + } +} + +export async function generateRepoFromTemplate({ owner, repoName }) { + const templateOwner = config.gitea.templateOwner + const templateRepo = config.gitea.templateRepo + const targetOwner = owner || config.gitea.defaultOwner + + if (!targetOwner) { + throw new Error('GITEA_DEFAULT_OWNER fehlt in .env') + } + + return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, { + method: 'POST', + body: JSON.stringify({ + owner: targetOwner, + name: repoName, + private: false, + description: `Website-Projekt ${repoName}`, + git_content: true, + }), + }) +} + +async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`) + const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`) + const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : [] + + if (branchNames.includes(preferredBranch)) return preferredBranch + if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) { + return repoInfo.default_branch + } + if (branchNames.length > 0) return branchNames[0] + } catch { + /* retry */ + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`) +} + +async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') { + const [owner, repo] = repoFullName.split('/') + const content = JSON.stringify(previewConfig, null, 2) + '\n' + const encoded = Buffer.from(content).toString('base64') + + let sha = null + try { + const existing = await giteaFetch( + `/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}` + ) + sha = existing.sha + } catch { + /* file may not exist yet */ + } + + await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, { + method: sha ? 'PUT' : 'POST', + body: JSON.stringify({ + message: 'Update preview config from ticket system', + content: encoded, + branch, + ...(sha ? { sha } : {}), + }), + }) +} + +export async function createProjectFromTemplate({ + repoName, + displayName, + subdomain, + customerId = '', + ticketId = '', + owner, +}) { + const targetOwner = owner || config.gitea.defaultOwner + const safeRepoName = slugify(repoName) || slugify(subdomain) + const safeSubdomain = slugify(subdomain) || safeRepoName + + if (!safeRepoName || !safeSubdomain) { + throw new Error('repoName und subdomain sind erforderlich') + } + + await ensureSubdomainAvailable(safeSubdomain) + + const repo = await generateRepoFromTemplate({ + owner: targetOwner, + repoName: safeRepoName, + }) + + const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` + const [repoOwner, repoSlug] = repoFullName.split('/') + const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main') + const previewConfig = { + enabled: true, + type: 'static', + branch, + displayName: displayName || safeRepoName, + subdomain: safeSubdomain, + templateName: config.gitea.templateRepo, + } + + await updatePreviewConfigFile(repoFullName, previewConfig, branch) + + let deployResult = { deployed: false } + try { + deployResult = await runDeploy(repoFullName, branch, previewConfig) + } catch (deployErr) { + console.error('[giteaAdmin] deploy failed:', deployErr.message) + deployResult = { deployed: false, error: deployErr.message } + } + + const previewUrl = buildPreviewUrl(safeSubdomain) + const project = await upsertWebsiteProjectByRepo(repoFullName, { + customerId, + ticketId, + projectName: previewConfig.displayName, + templateName: config.gitea.templateRepo, + giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, + giteaRepoName: repo.name || safeRepoName, + repoFullName, + subdomain: safeSubdomain, + previewUrl, + status: deployResult.deployed ? 'deployed' : 'pending', + provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed', + }) + + return { + projectId: project.$id, + repoFullName, + previewUrl, + subdomain: safeSubdomain, + deploy: deployResult, + } +} + +const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README'] + +export async function fetchRepoReadme(repoFullName) { + const [owner, repo] = repoFullName.split('/') + if (!owner || !repo) { + const error = new Error('repoFullName muss owner/repo sein') + error.status = 400 + throw error + } + + for (const filename of README_CANDIDATES) { + try { + const data = await giteaFetch( + `/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}` + ) + const encoding = data.encoding || 'base64' + const raw = encoding === 'base64' + ? Buffer.from(data.content || '', 'base64').toString('utf8') + : String(data.content || '') + return { + filename: data.name || filename, + content: raw, + htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`, + } + } catch (err) { + if (err.status === 404) continue + throw err + } + } + + return null +} diff --git a/server/services/invoiceNinja.js b/server/services/invoiceNinja.js new file mode 100644 index 0000000..3222295 --- /dev/null +++ b/server/services/invoiceNinja.js @@ -0,0 +1,81 @@ +import { config } from '../config.js' + +function getPrimaryContact(client) { + const contacts = client?.contacts || [] + return contacts.find((c) => c.is_primary) || contacts[0] || {} +} + +export function sanitizeInvoiceClient(client) { + const contact = getPrimaryContact(client) + const baseUrl = config.invoiceNinja.url + + return { + id: client.id, + number: client.number || '', + displayName: client.display_name || client.name || '', + companyName: client.name || '', + website: client.website || '', + phone: contact.phone || client.phone || '', + email: contact.email || '', + contactFirstName: contact.first_name || '', + contactLastName: contact.last_name || '', + address1: client.address1 || '', + address2: client.address2 || '', + city: client.city || '', + state: client.state || '', + postalCode: client.postal_code || '', + countryId: client.country_id || '', + vatNumber: client.vat_number || '', + idNumber: client.id_number || '', + balance: Number(client.balance ?? 0), + paidToDate: Number(client.paid_to_date ?? 0), + publicNotes: client.public_notes || '', + privateNotes: client.private_notes || '', + editUrl: `${baseUrl}/clients/${client.id}/edit`, + } +} + +export async function listInvoiceNinjaClients() { + const token = config.invoiceNinja.apiToken + if (!token) { + const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert') + error.status = 503 + throw error + } + + const clients = [] + let page = 1 + let totalPages = 1 + + while (page <= totalPages) { + const url = new URL(`${config.invoiceNinja.url}/api/v1/clients`) + url.searchParams.set('per_page', '100') + url.searchParams.set('page', String(page)) + url.searchParams.set('include', 'contacts') + + const response = await fetch(url, { + headers: { + 'X-API-TOKEN': token, + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + }) + + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const error = new Error(data.message || 'Invoice Ninja API Fehler') + error.status = response.status === 401 ? 502 : response.status + throw error + } + + const batch = (data.data || []).filter((c) => !c.is_deleted) + clients.push(...batch.map(sanitizeInvoiceClient)) + + totalPages = Number(data.meta?.pagination?.total_pages || 1) + page += 1 + } + + return clients.sort((a, b) => + (a.displayName || a.companyName).localeCompare(b.displayName || b.companyName, 'de') + ) +} diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js new file mode 100644 index 0000000..17111b9 --- /dev/null +++ b/server/services/previewDeploy.js @@ -0,0 +1,89 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import fs from 'node:fs/promises' +import path from 'node:path' +import { config } from '../config.js' + +const execFileAsync = promisify(execFile) + +export async function fetchPreviewConfig(repoFullName, branch) { + const [owner, repo] = repoFullName.split('/') + if (!owner || !repo || !config.gitea.apiToken) { + return null + } + + const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}` + const response = await fetch(url, { + headers: { Authorization: `token ${config.gitea.apiToken}` }, + }) + + if (!response.ok) return null + + const data = await response.json() + const content = Buffer.from(data.content || '', 'base64').toString('utf8') + return JSON.parse(content) +} + +export function defaultPreviewConfig(repoFullName) { + const repoName = repoFullName.split('/').pop() || 'preview' + return { + enabled: false, + type: 'static', + branch: 'main', + displayName: repoName, + subdomain: repoName, + } +} + +async function cloneRepo(cloneUrl, targetDir, branch) { + await fs.rm(targetDir, { recursive: true, force: true }) + await fs.mkdir(targetDir, { recursive: true }) + await execFileAsync('git', [ + 'clone', + '--depth', + '1', + '--branch', + branch, + cloneUrl, + targetDir, + ]) +} + +async function deployStatic(sourceDir, targetDir, indexSubdir = '') { + const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir + await fs.rm(targetDir, { recursive: true, force: true }) + await fs.mkdir(targetDir, { recursive: true }) + await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir]) +} + +async function deployNodeBuild(sourceDir, targetDir) { + await execFileAsync('npm', ['ci'], { cwd: sourceDir }) + await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir }) + const distDir = path.join(sourceDir, 'dist') + await deployStatic(distDir, targetDir) +} + +export function buildPreviewUrl(subdomain) { + return `https://${subdomain}.${config.preview.baseHost}` +} + +export async function runDeploy(repoFullName, branch, previewConfig) { + if (!config.preview.deployRoot) { + return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' } + } + + const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() + const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` + const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_')) + const targetDir = path.join(config.preview.deployRoot, subdomain) + + await cloneRepo(cloneUrl, workDir, branch) + + if (previewConfig.type === 'node_build') { + await deployNodeBuild(workDir, targetDir) + } else { + await deployStatic(workDir, targetDir, previewConfig.index || '') + } + + return { deployed: true, subdomain, targetDir } +} diff --git a/server/services/previewSession.js b/server/services/previewSession.js new file mode 100644 index 0000000..af1c9b5 --- /dev/null +++ b/server/services/previewSession.js @@ -0,0 +1,44 @@ +import { SignJWT, jwtVerify } from 'jose' +import { config } from '../config.js' + +export const PREVIEW_COOKIE_NAME = 'preview_session' + +const secret = new TextEncoder().encode(config.preview.sessionSecret) + +export async function createPreviewSessionToken(payload) { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(`${config.preview.sessionMaxAge}s`) + .sign(secret) +} + +export async function verifyPreviewSessionToken(token) { + try { + const { payload } = await jwtVerify(token, secret) + return payload + } catch { + return null + } +} + +export function previewSessionCookieOptions() { + return { + domain: config.preview.cookieDomain, + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: config.preview.sessionMaxAge * 1000, + } +} + +export function clearPreviewSession(res) { + res.clearCookie(PREVIEW_COOKIE_NAME, { + domain: config.preview.cookieDomain, + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + }) +} diff --git a/server/services/staffPreviewTokens.js b/server/services/staffPreviewTokens.js new file mode 100644 index 0000000..70645ed --- /dev/null +++ b/server/services/staffPreviewTokens.js @@ -0,0 +1,30 @@ +import { randomBytes } from 'node:crypto' + +const TOKEN_TTL_MS = 60 * 1000 +const tokens = new Map() + +function purgeExpired() { + const now = Date.now() + for (const [key, entry] of tokens) { + if (entry.expiresAt <= now) tokens.delete(key) + } +} + +export function createStaffPreviewToken(payload) { + purgeExpired() + const token = randomBytes(32).toString('hex') + tokens.set(token, { + ...payload, + expiresAt: Date.now() + TOKEN_TTL_MS, + }) + return token +} + +export function consumeStaffPreviewToken(token) { + purgeExpired() + const entry = tokens.get(token) + if (!entry) return null + tokens.delete(token) + if (entry.expiresAt <= Date.now()) return null + return entry +}