713 lines
37 KiB
JavaScript
713 lines
37 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,
|
|
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 { shopFuerKunde } from '../services/shop/schalter.js'
|
|
import { unterkunftFuerKunde } from '../services/unterkunft/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 { aboProdukt } from '../services/aboFunktionen.js'
|
|
import { analyseUnterlagen } from '../services/aboUnterlagen.js'
|
|
import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog.js'
|
|
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
|
|
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
|
|
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
|
import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
|
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
|
|
|
import {
|
|
activateAnalyse,
|
|
eligibilityMessage,
|
|
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, { aboModell = false } = {}) {
|
|
if (!config.analyse.enabled) return { comingSoon: true }
|
|
const [doc, eligibility, controller] = await Promise.all([
|
|
getFeatureDoc(customerId, projectId, 'analyse'),
|
|
analyseEligibility(customerId, projectId, { aboModell }),
|
|
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 ? eligibilityMessage(eligibility.reason, { aboModell }) : null,
|
|
// Neue Ansicht: Fassung und Adressen aus derselben Quelle wie der Nachweis (die Oberflaeche zeigt und sendet, was der Server prueft)
|
|
avvVersion: aboModell ? analyseUnterlagen().avvVersion : config.analyse.avvVersion,
|
|
legal: aboModell ? analyseUnterlagen().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, 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()
|
|
const neu = neueAnsicht(req)
|
|
// 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, { aboModell: neu }).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()
|
|
const antwort = {
|
|
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),
|
|
// 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) },
|
|
// Buchungskalender: Schalter UNTERKUNFT_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
|
|
unterkunft: { verfuegbar: unterkunftFuerKunde(req.session.email) },
|
|
// 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),
|
|
}
|
|
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt.
|
|
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
|
|
if (neu) {
|
|
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
|
|
kundenDokument(customerId),
|
|
aboProdukt(customerId, { now: jetzt }),
|
|
katalogFuerKunde({ email: req.session.email }),
|
|
anfrageStatusJeProjekt({ docs, projects }),
|
|
])
|
|
for (const p of projects) {
|
|
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
|
|
p.features.unterkunft.anfrageStatus = anfrageStatus.get(p.projectId)?.unterkunft ?? ''
|
|
}
|
|
Object.assign(antwort, { modell: 'abo', kundenart: kundenartWirksam(kunde), abo, katalog })
|
|
}
|
|
res.json(antwort)
|
|
} 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, { 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)
|
|
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 },
|
|
})
|
|
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.
|
|
// Weiche (Kundenansicht neu): wer die neue Ansicht sieht, geht an POST /:key/einschalten (unten); alle anderen behalten diesen Weg unveraendert
|
|
const alteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next())
|
|
|
|
router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, 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 EINSCHALTEN_NEU_ERROR = 'Die Funktion konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
|
const ZUSTIMMUNG_EINSCHALTEN = 'Bitte bestätige die Erklärung, um einzuschalten.'
|
|
|
|
/** Kundendokument einmal lesen; ein unbekannter Kunde ergibt {} (Kundenart privat). */
|
|
async function kundenDokument(customerId) {
|
|
try {
|
|
return await getDocument(config.collections.customers, customerId)
|
|
} catch (err) {
|
|
if (err.status === 404) return {}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Einschalten im Abo-Modell (V5, nur neue Ansicht; Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Vorpruefungen, jede ohne Seiteneffekt:
|
|
// 0 neue Ansicht (sonst 404), 1 Kundenansicht (403), 2 Funktionsschluessel, 3 Eingabe (Projekt, Zustimmung), 4 Projekt,
|
|
// 5 409 in dieser Reihenfolge: nicht_verfuegbar (dann 503 unterlagen_fehlen), abo_fehlt, nur_betrieb, bestellung_offen, beginn_noetig,
|
|
// bereits_eingeschaltet | bestellung_laeuft | abgeschaltet, fassung_veraltet; 6 einschalten() (Sperre, Abo frisch, Nachweis, Zustand, Ticket).
|
|
// Die Kundenart kommt nur aus dem Kundendokument, nie aus dem Koerper.
|
|
router.post('/:key/einschalten', requireSession, async (req, res) => {
|
|
const jetzt = einschaltenDeps.jetzt()
|
|
try {
|
|
if (!neueAnsicht(req)) return res.status(404).json({ error: 'Nicht gefunden' })
|
|
if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' })
|
|
const key = req.params.key
|
|
const feature = featureConfig(key)
|
|
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
|
const body = req.body || {}
|
|
const { projectId, zustimmung } = body
|
|
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
|
const zustimmungOk =
|
|
zustimmung !== null && typeof zustimmung === 'object' && !Array.isArray(zustimmung) && typeof zustimmung.version === 'string' &&
|
|
Array.isArray(zustimmung.haken) && zustimmung.haken.every((id) => id === 'beginn')
|
|
if (!zustimmungOk) throw bestellFehler(400, ZUSTIMMUNG_EINSCHALTEN, 'zustimmung_fehlt')
|
|
const customerId = getSessionCustomerId(req)
|
|
await assertOwnProject(customerId, projectId, { forNewOrder: true })
|
|
|
|
// nicht_verfuegbar (Schalter der Funktion), danach die Vertragsunterlagen
|
|
const sperre = featureSperre(key, req.session.email)
|
|
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
|
const haken = featureHaken(key)
|
|
await pruefeUnterlagen(haken)
|
|
// abo_fehlt: das Abo frisch lesen, Lesefehler sind 503
|
|
await pruefeAboDeckung(customerId, key, jetzt)
|
|
// nur_betrieb
|
|
const kunde = await kundenDokument(customerId)
|
|
const privat = kundenartWirksam(kunde) === 'privat'
|
|
if (privat && katalogFelder(key).nurBetrieb) throw bestellFehler(409, NUR_BETRIEB_TEXT, 'nur_betrieb')
|
|
// bestellung_offen: offene Punkte der Funktion (Fragebogen, Freigabe der Anfrage)
|
|
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, 'Es fehlen noch Angaben, bevor du einschalten kannst.', 'bestellung_offen', { offen })
|
|
}
|
|
// beginn_noetig: Privatkunden innerhalb der Widerrufsfrist brauchen das Haekchen fuer den sofortigen Beginn
|
|
let fristBis = null
|
|
if (privat) {
|
|
try {
|
|
fristBis = (await aboProdukt(customerId, { now: jetzt })).widerrufsfristBis
|
|
} catch (err) {
|
|
console.error(`[funktionen] Widerrufsfrist für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
|
throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar)
|
|
}
|
|
}
|
|
const beginnNoetig = privat && fristBis !== null && Date.parse(fristBis) >= jetzt.getTime()
|
|
const gesetzt = privat && zustimmung.haken.includes('beginn')
|
|
if (beginnNoetig && !gesetzt) {
|
|
const ab = new Date(Date.parse(fristBis) + 1).toISOString()
|
|
throw bestellFehler(409, `Für den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem ${datumDe(ab)} einschalten.`, 'beginn_noetig', { ab })
|
|
}
|
|
// bereits_eingeschaltet | bestellung_laeuft | abgeschaltet (setup ohne Ticket durch Abo oder Projekt heilt der Dienst)
|
|
pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key))
|
|
// fassung_veraltet: der Kunde hat einen alten Wortlaut gesehen
|
|
if (zustimmung.version !== consentAbo(key).version) {
|
|
throw bestellFehler(409, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.', 'fassung_veraltet', {
|
|
consent: consentAboAktiv(key, { beginn: beginnNoetig }),
|
|
})
|
|
}
|
|
|
|
const { doc, ticketWoid } = await einschalten({
|
|
customerId,
|
|
projectId,
|
|
key,
|
|
by: 'kunde',
|
|
abrechnung: 'abo',
|
|
now: jetzt,
|
|
// Nachweis ohne Stripe-Sitzung und ohne Belehrung; ein mitgeschicktes beginn zaehlt nur fuer Privatkunden
|
|
nachweis: () =>
|
|
speichereNachweisAusAnfrage(req, {
|
|
feature: aboweg(feature, { beginn: gesetzt }),
|
|
customerId,
|
|
projectId,
|
|
klickAt: jetzt.toISOString(),
|
|
kundenart: kundenartWirksam(kunde),
|
|
}),
|
|
})
|
|
res.json({ feature: await featureView(doc, key), ticketWoid })
|
|
} catch (err) {
|
|
fail(res, err, EINSCHALTEN_NEU_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 || {}
|
|
// Neue Ansicht (Abo-Modell): die Zielgruppen-Aussage (keine Website fuer Kinder unter 16) ist serverseitig erzwungen, nicht nur ein
|
|
// Haekchen im Browser; vor jedem Schreiben. Die alte Ansicht bleibt unveraendert.
|
|
const neu = neueAnsicht(req)
|
|
if (neu && req.body?.audienceConfirmed !== true) {
|
|
throw bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')
|
|
}
|
|
const customerId = getSessionCustomerId(req)
|
|
await assertOwnProject(customerId, analyseProjectId(projectId), { forNewOrder: true })
|
|
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung
|
|
const abo = neu
|
|
? {
|
|
aboModell: true,
|
|
nachweis: async () =>
|
|
speichereNachweisAusAnfrage(req, {
|
|
feature: abowegAnalyse(),
|
|
customerId,
|
|
projectId,
|
|
klickAt: einschaltenDeps.jetzt().toISOString(),
|
|
kundenart: kundenartWirksam(await kundenDokument(customerId)),
|
|
}),
|
|
}
|
|
: {}
|
|
const { doc } = await activateAnalyse({
|
|
customerId,
|
|
projectId: analyseProjectId(projectId),
|
|
accentColor,
|
|
consent,
|
|
by: 'customer',
|
|
actor: actingPerson(req.session),
|
|
...abo,
|
|
})
|
|
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
|