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>
176 lines
8.6 KiB
JavaScript
176 lines
8.6 KiB
JavaScript
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')
|
||
}
|