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 { assertOwnProject, backfillInvoiceLinks, featureView, getFeatureDoc, hakenTexte, listCustomerFeatureDocs, listCustomerFeatures, markOrdered, runActivation, unterlagenAdressen, } from '../services/siteFeatures.js' import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { zeitenFuerKunde } from '../services/zeiten/schalter.js' import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' import { starteBehalten } from '../services/zeiten/behalten.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.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. // err.body (Checkout-Vorpruefungen: code, consent, offen, grund) geht mit in die Antwort, error bleibt der Text. 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({ ...err.body, error: err.message }) console.error('[funktionen]', err.status || '', err.type || '', err.message) return res.status(500).json({ error: fallback }) } 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 } const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv'] /** * Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, * das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv | * null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der * Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. Ein Lesefehler im Abo ergibt * bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen * oder IDs enthalten). */ async function termineAngebot(email, customerId, abos = aboMemo()) { if (!termineFuerKunde(email)) return { verfuegbar: false } const register = featureConfig('termine') let bestellbar = null let grund = null try { const abo = await abos.bestellbar(customerId) bestellbar = abo?.ok === true grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund } catch (err) { console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) } const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt') return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis } } /** * Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner) * { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus * dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die * Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus. */ async function rechtlichesJeFunktion() { const legal = {} for (const key of featureKeys()) { const unterlagen = unterlagenAdressen(featureConfig(key)) if (!unterlagen) { legal[key] = null continue } let belehrung = null try { belehrung = (await featureHaken(key).belehrung?.()) ?? null } catch (err) { console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`) } legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung } } return legal } /** * Felder der Zeiten je Projekt in GET /api/funktionen: zeitenStatus (Kurzstatus der Website, null wenn nichts live ist oder nichts * gespeichert wurde) und zeitenLeer (keine Plaene gespeichert). Nicht verfuegbar: null und true, ohne etwas zu lesen. Ein Lesefehler kostet die * Liste nichts (null und false: ohne Wissen wird kein Hinweis "Zeiten eintragen" erzwungen); im Log nur Art und Status. */ async function zeitenListenfelder(docs, projectId, verfuegbar, jetzt) { if (!verfuegbar) return { zeitenStatus: null, zeitenLeer: true } const doc = docs.find((d) => d.projectId === projectId && d.featureKey === 'zeiten') try { return await zeitenProjektAnsicht(doc, jetzt) } catch (err) { console.error(`[funktionen] Zeiten für die Projektliste nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) return { zeitenStatus: null, zeitenLeer: false } } } /** * Oeffnungszeiten-Teil der Antwort von GET /api/funktionen (Spec 3.1). Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, * das Abo wird dann nicht gelesen. Verfuegbar: inklusive (true: laeuft und ist aktiv, die Funktion ist im Wartungs-Abo enthalten und * einschaltbar; false: sonst, dann sagt grund warum: kein_abo | gekuendigt | nicht_aktiv; null: Abo nicht lesbar), grund (null bei true und * null), preis (Register), einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der * Leistungsbeschreibung) und belehrung: die Widerrufsbelehrung samt Muster-Formular, die die Bestellseite des bezahlten Weges (30 €, auch * "Behalten") vor dem Knopf zeigt (Haken belehrung der Funktion, derselbe Text wie im Zustimmungsnachweis, Ruling R32; null, wenn der Haken * sie nicht liefern kann: die Seite bestellt dann nicht). Die Zustimmung des bezahlten Weges steht in features.zeiten (Register-Ansicht). * Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text. */ async function zeitenAngebot(email, customerId, abos) { if (!zeitenFuerKunde(email)) return { verfuegbar: false } const register = featureConfig('zeiten') let inklusive = null let grund = null try { const abo = await abos.bestellbar(customerId) inklusive = abo?.ok === true grund = inklusive || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund } catch (err) { console.error(`[funktionen] Abo für Öffnungszeiten live nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) } let belehrung = null try { belehrung = (await featureHaken('zeiten').belehrung?.()) ?? null } catch (err) { console.error(`[funktionen] Widerrufsbelehrung (zeiten) nicht lesbar: ${err?.name ?? 'Fehler'}`) } const { version, url } = register.leistungsbeschreibung return { verfuegbar: true, inklusive, grund, preis: register.priceLabel, einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } }, belehrung, } } // 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) // Die Funktionsdokumente einmal lesen: die Ansicht je Projekt und der Zeiten-Status brauchen sie beide const docs = await listCustomerFeatureDocs(customerId) const projects = await listCustomerFeatures(customerId, { docs }) const zeitenAn = zeitenFuerKunde(req.session.email) const jetzt = einschaltenDeps.jetzt() // 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' } }) Object.assign(p, await zeitenListenfelder(docs, p.projectId, zeitenAn, jetzt)) }), ) // Der Abo-Stand wird fuer Terminplaner und Oeffnungszeiten nur einmal gelesen const abos = aboMemo() res.json({ projects, // Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner // dem Kunden noch nicht freigegeben ist); setupEstimate und consentText bleiben als News-Alias fuer aeltere Clients features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])), // Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt ustHinweis: config.ustHinweis, // Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null) legal: await rechtlichesJeFunktion(), setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch // mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers termine: await termineAngebot(req.session.email, customerId, abos), // Oeffnungszeiten live: Schalter ZEITEN_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann mit // dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten" zeiten: await zeitenAngebot(req.session.email, customerId, abos), }) } 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. * Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage). */ async function openOrderSession(doc, feature) { const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { 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() } const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.' /** Pruefstufe "Vertragsunterlagen abrufbar" (Haken unterlagenBereit der Funktion): sonst 503 unterlagen_fehlen, vor jeder Stripe-Sitzung und jedem Dokument. */ async function pruefeUnterlagen(haken) { if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) { throw bestellFehler( 503, 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.', 'unterlagen_fehlen' ) } } // Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news. // Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung, // kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, // 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo, 7b inklusive (Abo laeuft: einschalten statt bezahlen). router.post('/:key/checkout', requireSession, async (req, res) => { // Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis, // Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z) const klickAt = new Date().toISOString() try { // 1 Kundenansicht if (req.session.viewAs) { return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' }) } // 2 Funktionsschluessel (analyse hat einen eigenen Weg und steht nicht im Register) const key = req.params.key const feature = featureConfig(key) if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' }) const body = req.body || {} // 3 Zustimmung: alle Pflicht-Haken und die aktuelle Fassung; bei "Zahlung fortsetzen" entfaellt sie (Stufe 4 prueft dafuer ordered) const fortsetzen = istFortsetzen(body) if (!fortsetzen) pruefeZustimmung(feature, body) // 4 Projekt const { projectId } = body if (typeof projectId !== 'string' || !projectId) { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) await assertOwnProject(customerId, projectId) if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') } // 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste) const sperre = featureSperre(key, req.session.email) if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') // 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung) const haken = featureHaken(key) await pruefeUnterlagen(haken) // 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7 if (haken.bestellpruefung) { const offen = await haken.bestellpruefung({ customerId, projectId, feature }) if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern') if (offen.length > 0) { throw bestellFehler(409, 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', 'bestellung_offen', { offen }) } } // 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv await pruefeAbo(feature, customerId) // 7b Funktionen mit aboInklusive (Oeffnungszeiten live): im laufenden Wartungs-Abo gibt es nichts zu bezahlen (409 inklusive) await pruefeInklusive(feature, customerId) // Zustand, Sitzung, Nachweis und Eintrag 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 () => { // a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register) const existing = await getFeatureDoc(customerId, projectId, key) if (existing?.state === 'setup' || existing?.state === 'active') { throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 }) } if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 }) const nachweis = (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt }) // b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null if (open) { // d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung) // gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu if (!(await zustimmungFuerSitzung(open.id))) { if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') await nachweis(open.id) } return open } // keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') // c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt const customer = await getDocument(config.collections.customers, customerId) const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt }) // d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft // nach 24 h ab, ein Storno ist nicht noetig) await nachweis(created.id) // e erst nach dem Nachweis wird die Bestellung eingetragen await markOrdered({ customerId, projectId, key, sessionId: created.id, consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') }, }) return created }) // f die URL geht erst jetzt an den Browser 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 EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' // Gratisweg: Oeffnungszeiten live im Wartungs-Abo einschalten (Spec 3.2). Vorpruefungen wie im Checkout, jede Stufe ohne Seiteneffekt: // 1 Kundenansicht, 2 Zustimmung (Gratis-Fassung: ein Haken, Knopf "Einschalten"), 3 Projekt (eigen, nicht archiviert), // 4 Schalter/Pilot, 5 Unterlagen. Das Abo wird erst unter der Sperre frisch geprueft (einschaltenZeiten), dort entstehen auch Nachweis, // Zustand, Neubau, Ticket und K27; ein zweiter Klick holt Fehlendes nach. router.post('/zeiten/einschalten', requireSession, async (req, res) => { const now = new Date() try { // 1 Kundenansicht: Mitarbeiter schalten nicht fuer den Kunden ein if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' }) const feature = featureConfig('zeiten') const body = req.body || {} // 2 Zustimmung: Haken und aktuelle Fassung der Gratis-Zustimmung (consentFrei) // nur { zustimmung }: die Altform { consent: true } (News, ohne Fassungspruefung) gilt bei dieser neuen Route nicht, sonst bestaetigte ein // alter Tab nach einer Textaenderung den neuen Wortlaut, ohne ihn gesehen zu haben pruefeZustimmung(freiweg(feature), { zustimmung: body.zustimmung }) // 3 Projekt const { projectId } = body if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) const customerId = getSessionCustomerId(req) await assertOwnProject(customerId, projectId, { forNewOrder: true }) // 4 Schalter (ZEITEN_ENABLED und Pilotliste) const sperre = featureSperre('zeiten', req.session.email) if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') // 5 Vertragsunterlagen abrufbar await pruefeUnterlagen(featureHaken('zeiten')) const { doc } = await einschaltenZeiten({ customerId, projectId, by: 'kunde', now, // Nachweis der Gratis-Zustimmung ohne Stripe-Sitzung und ohne Belehrung (es gibt kein Widerrufsverlangen) nachweis: () => speichereNachweisAusAnfrage(req, { feature: freiweg(feature), customerId, projectId, klickAt: now.toISOString() }), }) res.json({ feature: await featureView(doc, 'zeiten'), einbau: doc.einbau || null }) } catch (err) { fail(res, err, EINSCHALTEN_ERROR) } }) const BEHALTEN_ERROR = 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' // Behalten (Spec 3.3): nach dem Ende des Wartungs-Abos Öffnungszeiten live fuer einmalig 30 € behalten, ohne neuen Einbau. Vorpruefungen wie im // Checkout, jede Stufe ohne Seiteneffekt: 1 Kundenansicht, 2 Zustimmung (die der Bestellung, beide Haken), 3 Projekt (eigen; eine laufende Funktion // zu behalten ist keine Neubestellung, ein archiviertes Projekt bleibt erlaubt), 4 Schalter/Pilot, 5 Unterlagen. Ob behalten werden darf, entscheidet // das Dokument unter der Sperre (starteBehalten: aboEndeAt, sonst 409); den Zustand aendert die Route nicht, erst die bezahlte Sitzung (Webhook). // Die Route steht nicht auf der Erlaubnisliste der Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden); die Pruefung hier ist die zweite Sicherung. router.post('/zeiten/behalten', requireSession, async (req, res) => { // Zeit des Klicks: Eingang der Anfrage, Anker des Nachweises und der Metadaten (consentAt) und damit der Widerrufsfrist const now = new Date() try { // 1 Kundenansicht if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' }) const feature = featureConfig('zeiten') const body = req.body || {} // 2 Zustimmung: alle Pflicht-Haken der Bestellung und die aktuelle Fassung pruefeZustimmung(feature, body) // 3 Projekt const { projectId } = body if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) const customerId = getSessionCustomerId(req) await assertOwnProject(customerId, projectId) // 4 Schalter (ZEITEN_ENABLED und Pilotliste) const sperre = featureSperre('zeiten', req.session.email) if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') // 5 Vertragsunterlagen abrufbar const haken = featureHaken('zeiten') await pruefeUnterlagen(haken) const customer = await getDocument(config.collections.customers, customerId) const { url } = await starteBehalten({ customer, projectId, now, nachweis: (sessionId) => speichereNachweisAusAnfrage(req, { feature, haken, customerId, projectId, sessionId, klickAt: now.toISOString() }), }) res.json({ url }) } catch (err) { fail(res, err, BEHALTEN_ERROR) } }) 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