Files
Webklar-Kundenbereich/server/routes/funktionen.js
T
ClaudeandClaude Opus 5.5 809da80c9b Shop G1: Verdrahtung #webshop, Webshop-Kachel, Bestellseite mit key und optionaler Zusammenfassung
Dashboard-Abschnitt view-webshop, shop.css und Platzhalter shop.js, app.js (Hash, Refresher, Label, Elternansicht), Kachel in funktionen.js nach shop.verfuegbar, GET /api/funktionen liefert shop.verfuegbar, funktion-bestellen.js nimmt key/name/zusammenfassung (Standard: Terminplaner unverändert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:57:02 +00:00

434 lines
21 KiB
JavaScript

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,
getFeatureDoc,
hakenTexte,
listCustomerFeatures,
markOrdered,
runActivation,
unterlagenAdressen,
} from '../services/siteFeatures.js'
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
import { aboMemo } from '../services/aboStatus.js'
import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, 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 })
}
// 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,
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
}
}
// 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) {
if (!termineFuerKunde(email)) return { verfuegbar: false }
const register = featureConfig('termine')
let bestellbar = null
let grund = null
try {
const abo = await aboMemo().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
}
// 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,
// 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),
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
// kommen aus projects[].features.shop und GET /api/shop/status
shop: { verfuegbar: shopFuerKunde(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.
* 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.'
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
/**
* Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem
* Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route
* antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log.
*/
async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) {
const belehrung = (await haken.belehrung?.()) ?? null
const texte = nachweisTexte(feature, { belehrung })
try {
await speichereZustimmung({
customerId,
projectId,
featureKey: feature.key,
stripeSessionId: sessionId,
fassungen: zustimmungsfassungen(feature),
texte,
ip: req.ip,
userAgent: req.get('user-agent'),
konto: kontoLabel(actingPerson(req.session)),
at: klickAt,
})
} catch (err) {
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
}
}
// 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.
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, { forNewOrder: true })
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)
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'
)
}
// 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)
// 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) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, 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 },
})
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 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 || {}
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true })
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