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
+}