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 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ node_modules/
|
||||
.DS_Store
|
||||
preview-data/
|
||||
*.log
|
||||
|
||||
server/graphify-out/
|
||||
graphify-out/
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function renderProjectCard(project, features) {
|
||||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||||
<dl>
|
||||
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
||||
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a href="${escapeAttr(project.previewUrl)}" target="_blank" rel="noopener">${escapeHtml(project.previewUrl)}</a>` : '–'}</dd>
|
||||
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : '–'}</dd>
|
||||
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
||||
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
||||
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '–')}</dd>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
44
server/middleware/appwriteStaff.js
Normal file
44
server/middleware/appwriteStaff.js
Normal file
@@ -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<6E>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' })
|
||||
}
|
||||
}
|
||||
53
server/routes/admin/customers.js
Normal file
53
server/routes/admin/customers.js
Normal file
@@ -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
|
||||
51
server/routes/admin/employees.js
Normal file
51
server/routes/admin/employees.js
Normal file
@@ -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
|
||||
37
server/routes/admin/gitea.js
Normal file
37
server/routes/admin/gitea.js
Normal file
@@ -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
|
||||
19
server/routes/admin/invoiceClients.js
Normal file
19
server/routes/admin/invoiceClients.js
Normal file
@@ -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
|
||||
91
server/routes/admin/previewOpen.js
Normal file
91
server/routes/admin/previewOpen.js
Normal file
@@ -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
|
||||
32
server/routes/admin/websiteProjects.js
Normal file
32
server/routes/admin/websiteProjects.js
Normal file
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
258
server/services/customerAdmin.js
Normal file
258
server/services/customerAdmin.js
Normal file
@@ -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 <20> 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 }
|
||||
}
|
||||
160
server/services/employeeAdmin.js
Normal file
160
server/services/employeeAdmin.js
Normal file
@@ -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 }
|
||||
}
|
||||
123
server/services/gitWorksheet.js
Normal file
123
server/services/gitWorksheet.js
Normal file
@@ -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 }
|
||||
}
|
||||
323
server/services/giteaAdmin.js
Normal file
323
server/services/giteaAdmin.js
Normal file
@@ -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
|
||||
}
|
||||
81
server/services/invoiceNinja.js
Normal file
81
server/services/invoiceNinja.js
Normal file
@@ -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')
|
||||
)
|
||||
}
|
||||
89
server/services/previewDeploy.js
Normal file
89
server/services/previewDeploy.js
Normal file
@@ -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 }
|
||||
}
|
||||
44
server/services/previewSession.js
Normal file
44
server/services/previewSession.js
Normal file
@@ -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',
|
||||
})
|
||||
}
|
||||
30
server/services/staffPreviewTokens.js
Normal file
30
server/services/staffPreviewTokens.js
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user