Files
Webklar-Kundenbereich/server/services/termine/kontext.js
T
ClaudeandClaude Sonnet 5.5 31aa80affd Terminplaner TP2: Fragebogen im Kontext und Speicher
kontext.js: ctx.fragebogen (normalisiert; fehlt er oder ist er kaputt, der leere,
kaputt mit einer Logzeile und ohne Wurf), ladeEinstellung liefert zusaetzlich
fragebogen, neuer kontextFuerPortal(feature) fuer das Portal in jedem Zustand.
speichereFragebogen schreibt unter termine-cfg:<id> nur fragebogen, updatedAt und
updatedBy (ohne version; fehlt das Dokument: 409 keine_einstellung). Neue Option
fragebogen bei speichereEinstellung schreibt Einstellung und Fragebogen in
derselben Sperre. kontextPerSiteKey bleibt unveraendert (Entwurf nie oeffentlich),
Tests dazu fuer off, ordered, setup, disabled und nach activate. Der Test
ladeEntwurf in funktionenCheckout.test.js schaltet die neue Logzeile stumm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:14:22 +00:00

329 lines
17 KiB
JavaScript

import { config } from '../../config.js'
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
import { analyseHosts } from '../analyse.js'
import { withLocks } from '../keyedLock.js'
import { findActiveBySiteKey } from '../siteFeatures.js'
import { fehlendePflichtangaben } from './einstellungen.js'
import { leerFragebogen, normalisiereFragebogen } from './fragebogen.js'
import { termineAus } from './schalter.js'
import { pruefeNow, pruefeText } from './util.js'
/**
* Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und
* Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
*
* ctx = { feature, project, einstellung, version, fragebogen }
* feature portalFeatures-Dokument (featureKey 'termine')
* project websiteProjects-Dokument oder null (Projekt fehlt)
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
* fragebogen Bestell-Fragebogen (TP2), immer normalisiert; fehlt er oder ist er unlesbar (Logzeile), der leere (leerFragebogen)
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
*
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
* Einstellung, in der die Pflichtangaben fuer Gaeste stehen (fehlendePflichtangaben, Ruling R41: ein Entwurf aus einer
* Vorlage ist noch nicht buchbar); Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
* gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
* ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0).
* - kontextFuerPortal (Portal, Entwurf und Bestellung): das uebergebene portalFeatures-Dokument in jedem Zustand (off, ordered,
* setup, active, disabled) ohne Modus- und Zustandspruefung (die macht der Aufrufer), ohne Speicher.
* Der Fragebogen steht im selben Dokument wie die Einstellung (termineConfig.fragebogen, JSON-Text) und gehoert nur dem
* Entwurf: speichereFragebogen aendert ihn ohne die Version der Einstellung anzufassen (der Fragebogen-Editor und der
* Einstellungs-Editor stoeren sich so nicht), beide Schreibwege laufen unter derselben Sperre termine-cfg:<featureId>.
* Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen.
*/
const FUNKTION = 'termine'
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const CACHE_MS = 30 * 1000
const CACHE_MAX = 10000
const cache = new Map()
/** Wird mit jedem Speichern erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
let generation = 0
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
/** Dokument lesen; fehlt es (404 "Dokument fehlt"), ist das Ergebnis null. Fehlt Collection oder Datenbank, wird der Fehler weitergereicht. */
export async function holeOderNull(collectionId, id) {
try {
return await getDocument(collectionId, id)
} catch (err) {
if (dokumentFehlt(err)) return null
throw err
}
}
/**
* { einstellung, version } aus einem termineConfig-Dokument. Unlesbares JSON (mit Log) ergibt einstellung null, die Version
* des Dokuments bleibt: wer die kaputte Einstellung ueberschreiben will, speichert mit genau dieser Version.
*/
function liesDokument(doc) {
const version = Number.isInteger(doc.version) ? doc.version : 0
try {
const einstellung = JSON.parse(doc.einstellung)
if (!istObjekt(einstellung)) throw new Error('kein Objekt')
return { einstellung, version }
} catch (err) {
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
return { einstellung: null, version }
}
}
/**
* Der Fragebogen aus einem termineConfig-Dokument, normalisiert. Fehlt das Attribut (Altbestand, noch nie gespeichert), ist es
* der leere Fragebogen ohne Meldung; kaputtes JSON oder ein Inhalt, den die Normalisierung ablehnt, ergibt ebenfalls den leeren
* mit genau einer Logzeile (Dokument-ID und Grund) und wirft nie: der Fragebogen darf Einstellung und Kontext nicht blockieren.
*/
function fragebogenAus(doc) {
const roh = doc.fragebogen
if (roh === undefined || roh === null || roh === '') return leerFragebogen()
try {
const erg = normalisiereFragebogen(JSON.parse(roh))
if (erg.fehler) throw new Error(`ungueltige Angaben (${Object.keys(erg.fehler).join(', ')})`)
return erg.fragebogen
} catch (err) {
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
return leerFragebogen()
}
}
/**
* Die gespeicherte Einstellung einer Funktion samt Fragebogen.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<{ einstellung: object, version: number, fragebogen: object }|null>} null, wenn es noch keine (lesbare)
* Einstellung gibt; fragebogen ist immer normalisiert (leer, wenn nichts oder nichts Lesbares gespeichert ist)
*/
export async function ladeEinstellung(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
const gelesen = doc ? liesDokument(doc) : null
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
}
async function baueKontext(feature) {
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
holeOderNull(config.collections.termineConfig, feature.$id),
])
const gelesen = doc ? liesDokument(doc) : null
return {
feature,
project,
einstellung: gelesen?.einstellung ?? null,
version: gelesen?.version ?? 0,
fragebogen: doc ? fragebogenAus(doc) : leerFragebogen(),
}
}
/**
* Kontext fuer die oeffentliche Schnittstelle: aktive Funktion 'termine' mit diesem siteKey und gespeicherter Einstellung.
* @param {string} siteKey
* @param {Date} [now] Zeitpunkt fuer den 30-Sekunden-Speicher
* @returns {Promise<object|null>} null bei Modus off, ungueltigem oder unbekanntem siteKey, anderem Zustand als active
* und fehlender Einstellung
*/
export async function kontextPerSiteKey(siteKey, now = new Date()) {
if (termineAus()) return null
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
const nowMs = pruefeNow(now, 'kontextPerSiteKey')
const gemerkt = cache.get(siteKey)
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
const stand = generation
const feature = await findActiveBySiteKey(siteKey, FUNKTION)
let ctx = feature ? await baueKontext(feature) : null
if (ctx && (!ctx.einstellung || fehlendePflichtangaben(ctx.einstellung).length > 0)) ctx = null
if (stand === generation) merke(siteKey, ctx, nowMs)
return ctx
}
function merke(siteKey, ctx, nowMs) {
if (cache.size >= CACHE_MAX) {
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
}
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
}
/**
* Kontext fuer Portal und Gaesteseiten (Token): Funktion 'termine' im Zustand active oder disabled, ohne Speicher.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<object|null>} null bei Modus off, unbekannter ID, anderer Funktion oder anderem Zustand
*/
export async function kontextPerFeature(featureId) {
if (termineAus()) return null
if (typeof featureId !== 'string' || featureId === '') return null
const feature = await holeOderNull(config.collections.portalFeatures, featureId)
if (!feature || feature.featureKey !== FUNKTION || !['active', 'disabled'].includes(feature.state)) return null
return baueKontext(feature)
}
/**
* Kontext fuer das Portal (Entwurf, Bestellung, Einbau): das uebergebene portalFeatures-Dokument in jedem Zustand, ohne Speicher.
* Es gibt keine Modus- und keine Zustandspruefung und keinen Abgleich mit der Datenbank: das Dokument ist das, das der Aufrufer
* schon (frisch) gelesen und geprueft hat. Oeffentlich ist dieser Kontext nie, dafuer gibt es kontextPerSiteKey.
* @param {{ $id: string, projectId?: string }} feature portalFeatures-Dokument
* @returns {Promise<object>} ctx; ohne termineConfig-Dokument einstellung null, version 0, leerer Fragebogen
*/
export async function kontextFuerPortal(feature) {
pruefeText(feature?.$id, 'feature.$id')
return baueKontext(feature)
}
const veraltet = () =>
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
status: 409,
code: 'EINSTELLUNG_VERALTET',
})
const keineEinstellung = () =>
Object.assign(new Error('Es gibt noch keinen Entwurf für den Terminplaner. Bitte wähle zuerst eine Branche.'), {
status: 409,
code: 'keine_einstellung',
})
/** Eingabe normalisieren und als JSON-Text fuer termineConfig.fragebogen bereitstellen; ungueltig => 400 mit err.fehler. */
function pruefeFragebogen(eingabe) {
const erg = normalisiereFragebogen(eingabe)
if (erg.fehler) throw Object.assign(new Error('Der Fragebogen ist ungültig.'), { status: 400, fehler: erg.fehler })
return { fragebogen: erg.fragebogen, json: JSON.stringify(erg.fragebogen) }
}
/**
* Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
* { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre
* termine-cfg:<featureId>. Danach ist der Speicher von kontextPerSiteKey leer.
* Statt der Einstellung darf eine Funktion `async (bisher) => einstellung` uebergeben werden (Ruling R40): sie laeuft
* INNERHALB der Sperre nach der Versionspruefung, bekommt die gespeicherte Einstellung (Objekt) oder null und liefert die
* zu speichernde; sie darf werfen (der Fehler geht unveraendert durch, es wird nichts geschrieben). So laufen Pruefungen,
* die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie
* das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand
* aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch.
* Mit der Option `fragebogen` (TP2: Neuanlage mit Branche, "Vorlage ersetzen" mit zurueckgesetzten Bestaetigungen) schreibt
* derselbe Vorgang unter derselben Sperre auch den Fragebogen: entweder die Eingabe (Objekt, wird normalisiert) oder eine
* Funktion `(bisher) => Eingabe` (bisher = gespeicherter Fragebogen, leer wenn keiner da ist), die wie die Einstellungs-Funktion
* nach der Versionspruefung laeuft, sodass ein Einbau-Wunsch, der zwischendurch gespeichert wurde, nicht verloren geht. Ist sie
* ungueltig, gibt es { status: 400, fehler } und es wird nichts geschrieben (weder Einstellung noch Version noch Dokument).
* Ohne die Option bleibt der gespeicherte Fragebogen unberuehrt.
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
* @param {object|((bisher: object|null) => object|Promise<object>)} einstellungOderFn
* @param {{ version: number|null, by?: string, now?: Date, fragebogen?: object|((bisher: object) => object|Promise<object>) }} optionen
* @returns {Promise<{ einstellung: object, version: number, fragebogen?: object }>} wie gespeichert, mit der neuen Version;
* fragebogen (normalisiert) nur, wenn die Option gesetzt war
*/
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date(), fragebogen } = {}) {
pruefeText(feature?.$id, 'feature.$id')
const alsFunktion = typeof einstellungOderFn === 'function'
if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt')
const nowMs = pruefeNow(now, 'speichereEinstellung')
return withLocks([`termine-cfg:${feature.$id}`], async () => {
const col = config.collections.termineConfig
const vorhanden = await holeOderNull(col, feature.$id)
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
if (version !== null && version !== aktuell) throw veraltet()
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? liesDokument(vorhanden).einstellung : null) : einstellungOderFn
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
const json = JSON.stringify(einstellung)
// der Fragebogen wird vor dem ersten Schreibzugriff geprueft: ein ungueltiger laesst auch die Einstellung ungespeichert
let neuerFragebogen
if (fragebogen !== undefined) {
const eingabe = typeof fragebogen === 'function' ? await fragebogen(vorhanden ? fragebogenAus(vorhanden) : leerFragebogen()) : fragebogen
neuerFragebogen = pruefeFragebogen(eingabe)
}
const felder = {
customerId: feature.customerId,
projectId: feature.projectId,
einstellung: json,
version: aktuell + 1,
updatedAt: new Date(nowMs).toISOString(),
updatedBy: String(by ?? '').slice(0, 100),
...(neuerFragebogen ? { fragebogen: neuerFragebogen.json } : {}),
}
try {
if (vorhanden) await updateDocument(col, feature.$id, felder)
else await createDocument(col, felder, feature.$id)
} finally {
// auch bei einem Fehler: unklar, was in der Datenbank steht
generation += 1
cache.clear()
}
return { einstellung: JSON.parse(json), version: aktuell + 1, ...(neuerFragebogen ? { fragebogen: neuerFragebogen.fragebogen } : {}) }
})
}
/**
* Speichert den Fragebogen einer Funktion (die Eingaben des Kunden im Bestell-Fragebogen). Gleiche Sperre wie
* speichereEinstellung (termine-cfg:<featureId>); `aendere(bisher)` laeuft darin, bekommt den gespeicherten Fragebogen
* (leer, wenn keiner gespeichert oder lesbar ist) und liefert die zu speichernde Eingabe, die normalisiert wird. Sie darf werfen
* (der Fehler geht unveraendert durch, es wird nichts geschrieben). Eine ungueltige Eingabe wirft { status: 400, fehler }.
* Geschrieben werden nur fragebogen, updatedAt und updatedBy: `version` und `einstellung` bleiben unberuehrt, ein offener
* Einstellungs-Editor mit der gelesenen Version kann also weiter speichern. Gibt es noch kein termineConfig-Dokument, wird keins
* angelegt, sondern { status: 409, code: 'keine_einstellung' } geworfen. Danach ist der Speicher von kontextPerSiteKey leer.
* @param {{ $id: string }} feature portalFeatures-Dokument
* @param {(bisher: object) => object|Promise<object>} aendere
* @param {{ by?: string, now?: Date }} optionen
* @returns {Promise<{ fragebogen: object }>} der gespeicherte, normalisierte Fragebogen
*/
export async function speichereFragebogen(feature, aendere, { by = '', now = new Date() } = {}) {
pruefeText(feature?.$id, 'feature.$id')
if (typeof aendere !== 'function') throw new TypeError('aendere fehlt')
const nowMs = pruefeNow(now, 'speichereFragebogen')
return withLocks([`termine-cfg:${feature.$id}`], async () => {
const col = config.collections.termineConfig
const vorhanden = await holeOderNull(col, feature.$id)
if (!vorhanden) throw keineEinstellung()
const { fragebogen, json } = pruefeFragebogen(await aendere(fragebogenAus(vorhanden)))
try {
await updateDocument(col, feature.$id, {
fragebogen: json,
updatedAt: new Date(nowMs).toISOString(),
updatedBy: String(by ?? '').slice(0, 100),
})
} finally {
// auch bei einem Fehler: unklar, was in der Datenbank steht
generation += 1
cache.clear()
}
return { fragebogen }
})
}
/**
* Adresse der Website des Betriebs (Link auf Gaesteseiten und im Mailfuss), immer die echte Seite statt der Vorschau:
* betrieb.websiteUrl; sonst https:// + Live-Domain des Projekts (wenn project.liveDomain einen gueltigen Host ergibt);
* sonst https:// + <subdomain>.project.webklar.com; sonst die Portal-Adresse (config.publicBaseUrl).
*/
export function websiteUrl(ctx) {
const eigene = ctx?.einstellung?.betrieb?.websiteUrl
if (typeof eigene === 'string' && eigene !== '') return eigene
const project = ctx?.project
if (project) {
// analyseHosts: [<subdomain>.project.webklar.com, Live-Domain], ungueltige und doppelte Hosts fehlen
const hosts = analyseHosts(project)
const vorschau = project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
const live = project.liveDomain ? hosts.find((host) => host !== vorschau) : undefined
const host = live ?? hosts.find((h) => h === vorschau)
if (host) return `https://${host}`
}
return config.publicBaseUrl
}
/** Leert den Speicher von kontextPerSiteKey (Tests). */
export function resetKontextForTests() {
generation += 1
cache.clear()
}