import { runRepoAssignJob } from './services/repoAssignJob.js' import express from 'express' import path from 'node:path' import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' import { syncPreviewTlsOnStartup } from './services/previewDeploy.js' import { runReconcile } from './services/previewReconcile.js' import { checkBankConsent, shouldRunStartupSync, syncBank } from './services/finance/bankSync.js' import { getConnection } from './services/finance/bankStore.js' import { requireSession, sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' import featuresRoutes from './routes/features.js' import customerDataRoutes from './routes/customerData.js' import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' import funktionenRoutes from './routes/funktionen.js' import termineRoutes from './routes/termine.js' import shopRoutes from './routes/shop.js' import shopWebhookRoutes from './routes/shopWebhook.js' import { shopSeiteRouter } from './routes/shopSeite.js' import unterkunftRoutes from './routes/unterkunft.js' import { unterkunftSeiteRouter } from './routes/unterkunftSeite.js' import zeitenRoutes from './routes/zeiten.js' import publicAuftragRoutes from './routes/auftrag.js' import kundeAuftraegeRoutes from './routes/auftraegeKunde.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgeViewLog } from './services/customerViewLog.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js' import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js' import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' import adminPreviewRoutes from './routes/admin/preview.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' import adminServicesRoutes from './routes/admin/services.js' import adminCustomersRoutes from './routes/admin/customers.js' import adminEmployeesRoutes from './routes/admin/employees.js' import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' import publicShopRoutes from './routes/publicShop.js' import publicUnterkunftRoutes from './routes/publicUnterkunft.js' import publicZeitenRoutes from './routes/publicZeiten.js' import adminAnalyseRoutes from './routes/admin/analyse.js' import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' import adminProjektkontenRoutes from './routes/admin/projektkonten.js' import adminAuftraegeRoutes from './routes/admin/auftraege.js' import teamRoutes from './routes/team/index.js' import legacyAdminRedirect from './routes/legacyAdminRedirect.js' import { corsMiddleware } from './middleware/cors.js' import { kundenansichtGuard } from './middleware/kundenansichtGuard.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') assertServerConfig() const app = express() app.set('trust proxy', 1) app.use(sessionMiddleware()) // Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen // Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften app.use(kundenansichtGuard) // Oeffentliche Schnittstelle des Terminplaners (Gaeste ohne Session): bringt Parser (16 KB) und Fehlerbehandlung mit und // haengt deshalb VOR dem globalen Parser (2 MB), der ihre Rumpfe sonst zuerst lesen wuerde app.use('/api/public/termine/v1', publicTermineRoutes) // Oeffentliche Schnittstelle des Webshops (Kaeufer ohne Session): wie die des Terminplaners mit eigenem Parser (16 KB), daher ebenfalls VOR dem globalen Parser app.use('/api/public/shop/v1', publicShopRoutes) // Oeffentliche Schnittstelle des Buchungskalenders (Gaeste ohne Session): ebenfalls mit eigenem Parser VOR dem globalen app.use('/api/public/unterkunft/v1', publicUnterkunftRoutes) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. app.use('/api/abo/takeover-credits', requireSession, express.json({ limit: '15mb' })) // rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) app.use(corsMiddleware(config.cors.allowedOrigins)) app.use('/api/auth', authRoutes) app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) app.use('/api/public/news', publicNewsRoutes) app.use('/api/public/zeiten/v1', publicZeitenRoutes) app.use('/api/news', newsRoutes) app.use('/api', customerDataRoutes) app.use('/api/chat', chatRoutes) app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) app.use('/api/funktionen', funktionenRoutes) app.use('/api/termine', termineRoutes) // Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers app.use('/api/shop/stripe-webhook', shopWebhookRoutes) app.use('/api/shop', shopRoutes) app.use('/api/unterkunft', unterkunftRoutes) app.use('/api/zeiten', zeitenRoutes) // Auftragsbestaetigung: oeffentliche Bestaetigung per Token (ohne Sitzung) und die Auftraege des angemeldeten Kunden app.use('/api/auftrag', publicAuftragRoutes) app.use('/api/auftraege', kundeAuftraegeRoutes) app.use('/api/team', teamRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminPreviewRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) app.use('/api/admin/services', adminServicesRoutes) app.use('/api/admin/customers', adminCustomersRoutes) app.use('/api/admin/employees', adminEmployeesRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/analyse', adminAnalyseRoutes) // Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes) app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes) app.use('/api/admin/finanzen', adminFinanzenRoutes) app.use('/webhook', giteaWebhookRoutes) // Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token) app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes) app.get('/api/health', async (_req, res) => { const dbAccess = await verifyDatabaseAccess() res.json({ ok: dbAccess.ok, service: 'webklar-kundenbereich', databaseId: WOMS_DATABASE_ID, endpoint: config.appwrite.endpoint, appwriteDbAccess: dbAccess, }) }) // Einbau-Skript fuer Kundenwebsites: darf von fremden Seiten geladen werden, 5 Minuten Cache app.use('/embed/', (_req, res, next) => { res.set('Cache-Control', 'public, max-age=300') res.set('Access-Control-Allow-Origin', '*') next() }) // Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static) app.use('/rechtliches', legalRouter()) // Webshop-Seiten fuer Kaeufer: /shop/:siteKey und /shop/:siteKey/widerruf (vor express.static) app.use(shopSeiteRouter()) // Buchungskalender-Seiten fuer Gaeste: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id (vor express.static) app.use(unterkunftSeiteRouter()) // Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem app.use('/admin.html', legacyAdminRedirect.admin) app.use('/kalkulation.html', legacyAdminRedirect.kalkulation) app.use(express.static(publicDir)) app.get('/dashboard.html', (req, res, next) => { const raw = req.signedCookies?.[config.cookieName] if (!raw) { return res.redirect('/login.html') } next() }) app.get('/', (_req, res) => { res.redirect('/login.html') }) app.use((err, _req, res, _next) => { console.error('[server] Unbehandelter Fehler:', err) if (!res.headersSent) { res.status(500).json({ error: err.message || 'Interner Serverfehler' }) } }) const server = app.listen(config.port, '0.0.0.0', () => { console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`) syncPreviewTlsOnStartup() verifyDatabaseAccess().then((result) => { if (result.ok) return console.error( '[startup] APPWRITE_API_KEY: Kein Lesezugriff auf woms-database. In Appwrite Console neuen API-Key mit Scopes databases.read + databases.write anlegen und in .env eintragen.' ) if (result.reason) console.error(`[startup] Appwrite: ${result.reason}`) }) // Nachweise abgelehnter Uebernahmegutschriften nach Ablauf der Aufbewahrungsfrist loeschen (taeglich) const purge = () => purgeExpiredUploads() .then((r) => r.purged && console.log(`[takeover] Nachweise von ${r.purged} abgelehnten Anträgen gelöscht`)) .catch((err) => console.warn('[takeover] Aufräumen fehlgeschlagen:', err.message)) setTimeout(purge, 60 * 1000).unref() setInterval(purge, 24 * 60 * 60 * 1000).unref() // Protokoll der Kundenansicht: Eintraege aelter als 12 Monate loeschen (taeglich) const purgeViewLogJob = () => purgeViewLog() .then((r) => r.deleted && console.log(`[kundenansicht] ${r.deleted} Protokolleinträge gelöscht`)) .catch((err) => console.warn('[kundenansicht] Aufräumen fehlgeschlagen:', err.message)) setTimeout(purgeViewLogJob, 150 * 1000).unref() setInterval(purgeViewLogJob, 24 * 60 * 60 * 1000).unref() // Chat-Anhaenge, die nie abgeschickt wurden, nach config.chat.pendingTtlHours loeschen (stuendlich) const purgeChat = () => purgePendingChatUploads() .then((r) => r.purged && console.log(`[chat] ${r.purged} nicht gesendete Anhänge gelöscht`)) .catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message)) setTimeout(purgeChat, 90 * 1000).unref() setInterval(purgeChat, 60 * 60 * 1000).unref() // News-Fotos, die nie an einen Beitrag gebunden wurden, nach 24 Stunden loeschen (stuendlich) const purgeNewsImages = () => purgeUnclaimedNewsImages() .then((n) => n && console.log(`[news] ${n} nicht verwendete Fotos gelöscht`)) .catch((err) => console.warn('[news] Aufräumen fehlgeschlagen:', err.message)) setTimeout(purgeNewsImages, 120 * 1000).unref() setInterval(purgeNewsImages, 60 * 60 * 1000).unref() // Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten) const activateFeatures = () => runActivation() .then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`)) .catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message)) setInterval(activateFeatures, 5 * 60 * 1000).unref() // Website-Analyse: aktive Analysen ohne laufendes Wartungs-Abo beenden (taeglich, 2 Minuten nach Start beginnend). // Laeuft unabhaengig vom Schalter ANALYSE_ENABLED: er liest nur Analyse-Dokumente und ist ohne solche wirkungslos. const analyseAboCheck = () => runAboCheck({ now: new Date() }) .then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`)) .catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message)) // danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets .then(() => runAnalyseReconcile({ now: new Date() })) .then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`)) .catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message)) setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() // Terminplaner (Modus off: jeder Lauf kehrt sofort zurueck, ohne Abfrage; ein noch laufender Job wird nicht ueberlappt): // jede Minute abgelaufene Haltungen verfallen lassen und die Mail-Warteschlange senden, alle 5 Minuten Erinnerungen, erledigte // Termine und vergangene Anfragen, taeglich (3 Minuten nach dem Start beginnend) Loeschfristen und Aufraeumen. Die Jobs melden // einzelne Fehler selbst; hier faengt der Rahmen alles Unerwartete ab. Im Log stehen keine Gastdaten. const termineMinutenJob = () => minutenJob(new Date()).catch((err) => console.warn('[termine] Minutenjob fehlgeschlagen:', err.message)) setInterval(termineMinutenJob, 60 * 1000).unref() const termineFuenfMinutenJob = () => fuenfMinutenJob(new Date()).catch((err) => console.warn('[termine] Fünf-Minuten-Job fehlgeschlagen:', err.message)) setInterval(termineFuenfMinutenJob, 5 * 60 * 1000).unref() // Nach dem Tagesjob steht eine Zeile mit den Zahlen der Loeschungen im Log (Spur unwiderruflicher Loeschungen, ohne Personendaten) const termineTagesJob = () => taeglicherJob(new Date()) .then((ergebnis) => { const zeile = tagesbericht(ergebnis) if (zeile) console.log(zeile) }) .catch((err) => console.warn('[termine] Tagesjob fehlgeschlagen:', err.message)) setTimeout(termineTagesJob, 180 * 1000).unref() setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref() // Webshop: jede Minute offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (75 s nach dem Start beginnend), stuendlich nie // beanspruchte Artikelbilder aufraeumen (130 s), taeglich Kaeuferdaten anonymisieren, unbezahlte Bestellungen und lange abgeschaltete // Shops loeschen (200 s). Bei SHOP_ENABLED=off kehren Minuten- und Stundenjob sofort zurueck; der Tagesjob laeuft dann nur, wenn es // noch Shop-Dokumente gibt. Die Jobs melden Einzelfehler selbst (ohne Kaeuferdaten); hier faengt der Rahmen alles Unerwartete ab. const shopMinuten = () => shopMinutenJob(new Date()).catch((err) => console.warn('[shop] Minutenjob fehlgeschlagen:', err.message)) setTimeout(() => { shopMinuten() setInterval(shopMinuten, 60 * 1000).unref() }, 75 * 1000).unref() const shopStunden = () => shopStuendlicherJob(new Date()).catch((err) => console.warn('[shop] Stundenjob fehlgeschlagen:', err.message)) setTimeout(() => { shopStunden() setInterval(shopStunden, 60 * 60 * 1000).unref() }, 130 * 1000).unref() const shopTages = () => shopTaeglicherJob(new Date()) .then((r) => r && !r.aus && !r.uebersprungen && console.log(`[shop] Tagesjob: ${JSON.stringify(r)}`)) .catch((err) => console.warn('[shop] Tagesjob fehlgeschlagen:', err.message)) setTimeout(() => { shopTages() setInterval(shopTages, 24 * 60 * 60 * 1000).unref() }, 200 * 1000).unref() // Buchungskalender: jede Minute Fristen ablaufen lassen (80 s nach dem Start beginnend), alle 15 Minuten iCal-Importe (140 s), // taeglich Aufbewahrung (210 s). Bei UNTERKUNFT_ENABLED=off kehrt jeder Lauf sofort zurueck. Die Jobs melden Einzelfehler selbst // (ohne Gastdaten); hier faengt der Rahmen alles Unerwartete ab. const unterkunftMinuten = () => unterkunftMinutenJob(new Date()).catch((err) => console.warn('[unterkunft] Minutenjob fehlgeschlagen:', err.message)) setTimeout(() => { unterkunftMinuten() setInterval(unterkunftMinuten, 60 * 1000).unref() }, 80 * 1000).unref() const unterkunftViertelstunden = () => unterkunftViertelstundenJob(new Date()).catch((err) => console.warn('[unterkunft] Viertelstundenjob fehlgeschlagen:', err.message)) setTimeout(() => { unterkunftViertelstunden() setInterval(unterkunftViertelstunden, 15 * 60 * 1000).unref() }, 140 * 1000).unref() const unterkunftTages = () => unterkunftTaeglicherJob(new Date()) .then((r) => r && !r.aus && !r.uebersprungen && console.log(`[unterkunft] Tagesjob: ${JSON.stringify(r)}`)) .catch((err) => console.warn('[unterkunft] Tagesjob fehlgeschlagen:', err.message)) setTimeout(() => { unterkunftTages() setInterval(unterkunftTages, 24 * 60 * 60 * 1000).unref() }, 210 * 1000).unref() // Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED, // ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei // Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und // werfen nie; hier faengt der Rahmen trotzdem alles Unerwartete ab. Im Log stehen keine Kundendaten. const zeitenAbo = () => zeitenAboJob(new Date()) .then((r) => { if (r.aboEnde || r.abgeschaltet || r.wiederEin || r.geloescht || r.mails || r.fehler) { console.log(`[zeiten] Abo-Abgleich: ${r.aboEnde ?? 0} Abo-Ende erkannt, ${r.abgeschaltet ?? 0} abgeschaltet, ${r.wiederEin ?? 0} wieder eingeschaltet, ${r.geloescht ?? 0} gelöscht, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`) } }) .catch((err) => console.warn('[zeiten] Abo-Abgleich fehlgeschlagen:', err.message)) setTimeout(zeitenAbo, 240 * 1000).unref() setInterval(zeitenAbo, 24 * 60 * 60 * 1000).unref() const zeitenErinnerung = () => zeitenErinnerungsJob(new Date()) .then((r) => { if (r.merker || r.mails || r.fehler) console.log(`[zeiten] Feiertags-Erinnerung: ${r.merker ?? 0} Tag(e) vorgemerkt, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`) }) .catch((err) => console.warn('[zeiten] Feiertags-Erinnerung fehlgeschlagen:', err.message)) setTimeout(zeitenErinnerung, 300 * 1000).unref() setInterval(zeitenErinnerung, 60 * 60 * 1000).unref() // Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten) const repoAssign = () => runRepoAssignJob({ now: new Date() }).catch((err) => console.warn('[repoAssign] Lauf fehlgeschlagen:', err.message) ) setTimeout(repoAssign, 60 * 1000).unref() setInterval(repoAssign, 10 * 60 * 1000).unref() // Finanzen (nur wenn Enable Banking eingerichtet ist): Bankabruf 2 Minuten nach dem Start (nur wenn der letzte // nicht frisch war) und alle 6 Stunden, Warnung vor dem Ablauf des Bankzugangs 3 Minuten nach dem Start und // taeglich. syncBank wirft nie, meldet einen Fehlschlag aber im Ergebnis; die Meldung enthaelt keine Sitzungs- // oder Schluesselangaben. if (config.enableBanking.enabled) { const bankSync = () => syncBank() .then((r) => !r.ok && console.warn('[finanzen] Bankabruf fehlgeschlagen:', r.error)) .catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message)) // Beim Start nur abrufen, wenn der letzte Abruf nicht frisch war: Deployments starten den Server oft neu und // wuerden sonst das Tageslimit der Bank aufbrauchen const startupBankSync = () => getConnection() .then((connection) => { if (shouldRunStartupSync(connection, new Date())) return bankSync() console.log('[finanzen] Bankabruf beim Start übersprungen (letzter Abruf ist frisch)') }) .catch((err) => console.warn('[finanzen] Bankabruf fehlgeschlagen:', err.message)) setTimeout(startupBankSync, 2 * 60 * 1000).unref() setInterval(bankSync, 6 * 60 * 60 * 1000).unref() const bankConsent = () => checkBankConsent().catch((err) => console.warn('[finanzen] Prüfung des Bankzugangs fehlgeschlagen:', err.message)) setTimeout(bankConsent, 3 * 60 * 1000).unref() setInterval(bankConsent, 24 * 60 * 60 * 1000).unref() } // Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet) if (config.previewRunner.enabled) { setTimeout(runReconcile, 30 * 1000).unref() setInterval(runReconcile, 2 * 60 * 1000).unref() } }) server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error( `[server] Port ${config.port} ist bereits belegt. Windows: netstat -ano | findstr :${config.port} → taskkill /PID /F. Oder in .env einen anderen PORT setzen (z. B. PORT=3002).` ) process.exit(1) } throw err })