Files
Webklar-Kundenbereich/server/services/featureOrder.js
T
2026-10-04 12:30:44 +00:00

135 lines
6.5 KiB
JavaScript

import { config } from '../config.js'
import { aboMemo } from './aboStatus.js'
import { oeffentlicheAnsicht } from './featureRegistry.js'
import { istObjekt } from './termine/ablauf.js'
import { holeOderNull, ladeEinstellung } from './termine/kontext.js'
// Bausteine der Bestell-Vorpruefungen (POST /api/funktionen/:key/checkout, Task B6): Zustimmung, Abo-Hinweistexte,
// Entwurf des Terminplaners. Die Route in routes/funktionen.js legt die Reihenfolge fest; jede Stufe hier ist
// ohne Seiteneffekt (liest nur, schreibt nie, legt keine Stripe-Sitzung an).
/** Einhaengepunkt fuer Tests: der Abo-Speicher je Anfrage (Tests zaehlen die Aufrufe, um die Reihenfolge zu pruefen). */
export const orderDeps = { aboMemo }
/**
* Fehler mit Status, Kundentext und Code. code steht am Fehler und im Antwortkoerper (err.body.code); weitere
* Felder fuer den Browser (consent, offen, grund) kommen in body. routes/funktionen.js fail() gibt body weiter.
*/
export function bestellFehler(status, message, code, extra = {}) {
const err = new Error(message)
err.status = status
if (code) {
err.code = code
err.body = { code, ...extra }
} else if (Object.keys(extra).length > 0) {
err.body = { ...extra }
}
return err
}
const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.'
const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.'
const MAX_HAKEN = 50
const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt')
const hatWert = (wert) => wert !== undefined && wert !== null
/**
* "Zahlung fortsetzen" aus der Statuskarte: { projectId, fortsetzen: true } ohne Zustimmung. Enthaelt der Koerper auch
* zustimmung (oder die News-Altform consent: true), gilt die Zustimmung und sie wird geprueft.
*/
export function istFortsetzen(eingabe) {
return eingabe?.fortsetzen === true && !hatWert(eingabe.zustimmung) && eingabe.consent !== true
}
/**
* Pruefstufe 3: Sind alle Pflicht-Haken der Funktion gesetzt und passt die Fassung zur Serverfassung?
* eingabe ist der Anfragekoerper: { zustimmung: { version, haken: [id, …] } } oder, nur bei Funktionen mit genau einem
* Haken-Item (News), die Altform { consent: true }. Gibt die angenommenen Haken-IDs in Registerreihenfolge zurueck.
* Wirft 400 zustimmung_fehlt (fehlt, unvollstaendig, unbekannte ID) und 409 fassung_veraltet (err.body.consent ist die
* aktuelle Fassung mit allen Texten, wie die Bestellseite sie zeigt). Die Fassung geht vor den Haken: wer einen alten
* Wortlaut gesehen hat, soll erst den neuen lesen.
*/
export function pruefeZustimmung(feature, eingabe) {
const ids = feature.consent.items.filter((item) => item.haken).map((item) => item.id)
if (!hatWert(eingabe?.zustimmung)) {
// Altform: ein einziger Haken (News) laesst sich mit consent: true bestaetigen
if (eingabe?.consent === true && ids.length === 1) return { akzeptiert: [...ids] }
throw fehlt()
}
if (!istObjekt(eingabe.zustimmung)) throw fehlt()
const { version, haken } = eingabe.zustimmung
if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt()
if (version !== feature.consent.version) {
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent })
}
if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt()
const gesetzt = new Set(haken)
if (!ids.every((id) => gesetzt.has(id))) throw fehlt()
return { akzeptiert: [...ids] }
}
// Abo-Hinweistexte (Du-Form, Akkusativ der Funktion aus mails.formen.akk, z. B. "den Terminplaner"). Einzige Quelle:
// Bestellseite, Vorschau (Gate-Punkt (h)), Entwurfsrechte und Abo-Abgleich importieren sie von hier.
const ABO_TEXTE = {
kein_abo: (akk) => `Für ${akk} brauchst du ein laufendes Wartungs-Abo.`,
gekuendigt: (akk) => `Dein Wartungs-Abo ist gekündigt. Für ${akk} brauchst du ein laufendes Abo.`,
nicht_aktiv: (akk) =>
`Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du ${akk} bestellen.`,
unbekannt: () => 'Das Abo konnte gerade nicht geprüft werden.',
}
/**
* Hinweistext zu einem Abo-Grund.
* @param {object} feature Registereintrag (config.features.<key>)
* @param {'kein_abo'|'gekuendigt'|'nicht_aktiv'|'unbekannt'} grund jeder andere Wert gilt wie 'unbekannt'
*/
export function aboMeldung(feature, grund) {
const text = Object.hasOwn(ABO_TEXTE, grund) ? ABO_TEXTE[grund] : ABO_TEXTE.unbekannt
return text(feature?.mails?.formen?.akk || 'diese Funktion')
}
// Fragebogen aus termineConfig.fragebogen (JSON-Text): nur ein Objekt zaehlt, fehlend oder kaputt ist null
function liesFragebogen(text) {
if (typeof text !== 'string' || text === '') return null
try {
const wert = JSON.parse(text)
return istObjekt(wert) ? wert : null
} catch {
return null
}
}
/**
* Entwurf des Terminplaners (Einstellung und Fragebogen aus termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
* Fehlende oder unlesbare Teile sind null; Lesefehler der Datenbank werden weitergereicht.
* @param {string} featureId
* @returns {Promise<{ einstellung: object|null, fragebogen: object|null }>}
*/
export async function ladeEntwurf(featureId) {
const [gelesen, doc] = await Promise.all([ladeEinstellung(featureId), holeOderNull(config.collections.termineConfig, featureId)])
return { einstellung: gelesen?.einstellung ?? null, fragebogen: liesFragebogen(doc?.fragebogen) }
}
/**
* Pruefstufe 7: Wartungs-Abo, wenn die Funktion es verlangt (requiresWartungsAbo). Wirft 409 mit code und body.grund
* (kein_abo | gekuendigt | nicht_aktiv, ohne Praefix) und dem Text aus aboMeldung. Ein Lesefehler wirft den Fehler der
* Datenbank weiter (die Route antwortet 500 mit allgemeinem Text): im Zweifel nicht bestellbar.
*/
export async function pruefeAbo(feature, customerId) {
if (feature.requiresWartungsAbo !== true) return
let ergebnis
try {
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
} catch (err) {
// egal mit welchem Status die Datenbank antwortet: 500, die Route zeigt nur ihren allgemeinen Text
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
}
if (ergebnis?.ok === true) return
const grund = ergebnis?.grund
if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
}