Files
Webklar-Kundenbereich/server/services/featureOrder.js
T
ClaudeandClaude Opus 5.5 66793d06d5 Öffnungszeiten: Einschalten im Wartungs-Abo
POST /api/funktionen/zeiten/einschalten (Gratisweg, Spec 3.2): Abo frisch
prüfen, Zustimmung (consentFrei) und Nachweis ohne Stripe-Sitzung, Zustand
setup mit unlockedBy abo, dann Neubau, Einbau-Ticket und K24; ein zweiter
Klick holt Fehlendes nach (holeZeitenNach, eigene Sperre). GET /api/funktionen
liefert das Angebot (zeiten), features.zeiten mit unlockedBy, aboEndeAt, einbau,
loeschungAm sowie zeitenStatus und zeitenLeer je Projekt.

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

176 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import { aboMemo, bestellbar } from './aboStatus.js'
import { featureConfig } 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
/**
* Sicht der Zustimmung fuer den Browser (Bestellseite, Antwort bei veralteter Fassung): Fassung, Knopf und alle Texte; ob ein Punkt
* einen Haken braucht, steht je Punkt. Fuer feature.consent und, beim Gratisweg, freiweg(feature).consent.
*/
export const oeffentlicheConsent = (consent) => ({
version: consent.version,
knopf: consent.knopf,
items: consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
})
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) {
// die Fassung des geprueften Eintrags (beim Gratisweg die Gratis-Zustimmung), nicht die des Registers
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheConsent(feature.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 })
}
const GRATIS_ABO_TEXT = {
kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`,
nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.',
}
const GERADE_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'
/**
* Abo-Pruefung des Gratisweges "Einschalten" von Oeffnungszeiten live (Spec 3.2 Schritt 1): immer frisch gelesen (bestellbar direkt,
* nie aboMemo), gedacht fuer den Aufruf unter der Sperre feature-order. Laeuft das Wartungs-Abo und ist es aktiv, geschieht nichts.
* kein_abo und gekuendigt: 409 (code und body.grund wie der Grund) mit dem Hinweis auf den 30-Euro-Weg; nicht_aktiv: 409 "Einschaltbar,
* sobald Dein Abo aktiv ist." Ein Lesefehler ist 503 (fail-closed, nie stillschweigend erlaubt); im Log stehen nur Art und Status.
*/
export async function pruefeAboFuerEinschalten(customerId) {
const feature = featureConfig('zeiten')
let ergebnis
try {
ergebnis = await bestellbar(customerId)
} catch (err) {
console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
throw Object.assign(new Error(GERADE_NICHT_PRUEFBAR), { status: 503 })
}
if (ergebnis?.ok === true) return
const grund = ergebnis?.grund
if (grund === 'nicht_aktiv') throw bestellFehler(409, GRATIS_ABO_TEXT.nicht_aktiv(), grund, { grund })
if (grund === 'kein_abo' || grund === 'gekuendigt') {
throw bestellFehler(409, GRATIS_ABO_TEXT.kein_abo_gekuendigt(feature.label.name, feature.priceLabel), grund, { grund })
}
throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
}