import { Router } from 'express' import { legalPaths } from '../services/analyseLegal.js' import { config } from '../config.js' import { getDocument } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { withLocks } from '../services/keyedLock.js' import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js' import { DISABLED_MESSAGE, assertOwnProject, backfillInvoiceLinks, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation, } from '../services/siteFeatures.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { ELIGIBILITY_MESSAGES, activateAnalyse, analyseDeps, analyseHosts, isDevPreview, analyseEligibility, controllerView, deactivateAnalyse, } from '../services/analyse.js' const router = Router() const DAY_MS = 24 * 60 * 60 * 1000 // Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder // unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung. function fail(res, err, fallback) { const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type if (own) return res.status(err.status).json({ error: err.message }) console.error('[funktionen]', err.status || '', err.type || '', err.message) return res.status(500).json({ error: fallback }) } // Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde const actingPerson = (session) => session.adminName || session.adminEmail ? { name: session.adminName, email: session.adminEmail } : { name: session.name, email: session.email } const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet' const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' async function analyseView(customerId, projectId) { if (!config.analyse.enabled) return { comingSoon: true } const [doc, eligibility, controller] = await Promise.all([ getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId), controllerView(customerId), ]) let devMode = false if (doc?.state === 'active') { const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null)) devMode = isDevPreview(project) } return { state: doc?.state || 'off', devMode, accentColor: doc?.accentColor || '#111111', eligible: eligibility.ok, reason: eligibility.reason || null, reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller, } } // Schalter aus: alle Analyse-Routen 409 function requireAnalyseEnabled(_req, res, next) { if (!config.analyse.enabled) return res.status(409).json({ error: ANALYSE_OFF }) next() } function analyseProjectId(value) { if (typeof value !== 'string' || !value) throw Object.assign(new Error('Bitte wähle ein Projekt.'), { status: 400 }) return value } // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet router.get('/', requireSession, async (req, res) => { try { const customerId = getSessionCustomerId(req) // Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message)) await backfillInvoiceLinks(customerId) const projects = await listCustomerFeatures(customerId) // Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten await Promise.all( projects.map(async (p) => { p.analyse = await analyseView(customerId, p.projectId).catch((err) => { console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message) return { state: 'unknown' } }) }), ) res.json({ projects, setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel termine: { verfuegbar: termineFuerKunde(req.session.email) }, }) } catch (err) { fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.') } }) /** * Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal * (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft * oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409. */ async function openOrderSession(doc) { const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) if (!doc.stripeSessionId) return null let session try { session = await retrieveCheckoutSession(doc.stripeSessionId) } catch (err) { console.warn('[funktionen] Sitzung nicht abrufbar:', err.message) if (Date.now() - new Date(doc.orderedAt || 0).getTime() < DAY_MS) throw conflict() return null } if (session.status === 'open' && session.url) return { id: session.id, url: session.url } if (session.status === 'expired') return null throw conflict() } router.post('/news/checkout', requireSession, async (req, res) => { try { if (req.session.viewAs) { return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' }) } const { projectId, consent } = req.body || {} if (consent !== true) { return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' }) } if (typeof projectId !== 'string' || !projectId) { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) await assertOwnProject(customerId, projectId) // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung // richtet sich nach ihrer gespeicherten Stripe-Sitzung const existing = await getFeatureDoc(customerId, projectId, 'news') if (existing?.state === 'setup' || existing?.state === 'active') { throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) } if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 }) if (existing?.state === 'ordered') { const open = await openOrderSession(existing) if (open) return open } const customer = await getDocument(config.collections.customers, customerId) const now = new Date().toISOString() const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) await markOrdered({ customerId, projectId, key: 'news', sessionId: created.id, consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') }, }) return created }) res.json({ url: session.url }) } catch (err) { fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.') } }) const analyseGuard = [requireSession, requireAnalyseEnabled] router.post('/analyse/activate', analyseGuard, async (req, res) => { try { if (req.session.viewAs) { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { projectId, accentColor, consent } = req.body || {} const { doc } = await activateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(projectId), accentColor, consent, by: 'customer', actor: actingPerson(req.session), }) res.json({ ok: true, state: doc.state }) } catch (err) { fail(res, err, ANALYSE_ERROR) } }) router.post('/analyse/deactivate', analyseGuard, async (req, res) => { try { if (req.session.viewAs) { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { doc } = await deactivateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(req.body?.projectId), by: 'customer', actor: actingPerson(req.session), }) res.json({ ok: true, state: doc?.state || 'off' }) } catch (err) { fail(res, err, ANALYSE_ERROR) } }) // Eigenes Projekt und Analyse-Dokument mit siteKey (aktiv oder aus: Daten liegen noch bis zu 60 Tage) async function ownAnalyseDoc(req, projectId) { const customerId = getSessionCustomerId(req) const project = await assertOwnProject(customerId, analyseProjectId(projectId)) const doc = await getFeatureDoc(customerId, projectId, 'analyse') return { project, doc } } router.get('/analyse/stats', analyseGuard, async (req, res) => { try { const range = String(req.query.range || '30') if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum' }) const { doc } = await ownAnalyseDoc(req, req.query.projectId) if (!doc?.siteKey || !['active', 'off'].includes(doc.state)) { return res.status(409).json({ error: 'Für dieses Projekt gibt es keine Auswertung.' }) } let stats try { stats = await analyseDeps.client.getStats(doc.siteKey, Number(range)) } catch (err) { console.error('[funktionen] Auswertung:', err.status || '', err.message) return res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' }) } res.json(stats) } catch (err) { fail(res, err, 'Die Auswertung konnte gerade nicht geladen werden.') } }) router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => { try { const { projectId, path, vp, embed } = req.body || {} if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) { return res.status(400).json({ error: 'Ungültiger Pfad' }) } if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' }) const { project, doc } = await ownAnalyseDoc(req, projectId) if (!doc?.siteKey || doc.state !== 'active') { return res.status(409).json({ error: 'Die Website-Analyse ist für dieses Projekt nicht aktiv.' }) } const domains = analyseHosts(project) if (!domains.length) return res.status(409).json({ error: 'Keine gültige Domain' }) const host = project.liveDomain ? domains[domains.length - 1] : domains[0] const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp }) // embed: Ansicht im Geraeterahmen des Portals (h.js ohne Leiste, Links gesperrt) res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}${embed === true ? '&__wk_embed=1' : ''}` }) } catch (err) { fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.') } }) export default router