POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen (503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo (409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter. featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung, Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
319 lines
14 KiB
JavaScript
319 lines
14 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 {
|
|
DISABLED_MESSAGE,
|
|
assertOwnProject,
|
|
backfillInvoiceLinks,
|
|
getFeatureDoc,
|
|
listCustomerFeatures,
|
|
markOrdered,
|
|
runActivation,
|
|
} from '../services/siteFeatures.js'
|
|
import { termineFuerKunde } from '../services/termine/schalter.js'
|
|
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
|
|
import { 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,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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) => {
|
|
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, 'Bitte stimme der Erklärung zu, um zu bestellen.', '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)
|
|
|
|
// Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register.
|
|
// Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch Dokument.
|
|
if (key !== 'news') {
|
|
throw bestellFehler(503, 'Die Bestellung ist für diese Funktion noch nicht freigeschaltet.', 'bestellung_nicht_freigeschaltet')
|
|
}
|
|
// 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
|