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>
This commit is contained in:
1 parent
008ad4fb1e
commit
31aa80affd
3 files changed
+481
-31
No files matched your search
@@ -4,6 +4,7 @@ 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'
|
||||
|
||||
@@ -11,11 +12,12 @@ 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 }
|
||||
* 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
|
||||
@@ -25,6 +27,11 @@ import { pruefeNow, pruefeText } from './util.js'
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -69,15 +76,34 @@ function liesDokument(doc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gespeicherte Einstellung einer Funktion.
|
||||
* 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 }|null>} null, wenn es noch keine (lesbare) gibt
|
||||
* @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 : null
|
||||
return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null
|
||||
}
|
||||
|
||||
async function baueKontext(feature) {
|
||||
@@ -86,7 +112,13 @@ async function baueKontext(feature) {
|
||||
holeOderNull(config.collections.termineConfig, feature.$id),
|
||||
])
|
||||
const gelesen = doc ? liesDokument(doc) : null
|
||||
return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 }
|
||||
return {
|
||||
feature,
|
||||
project,
|
||||
einstellung: gelesen?.einstellung ?? null,
|
||||
version: gelesen?.version ?? 0,
|
||||
fragebogen: doc ? fragebogenAus(doc) : leerFragebogen(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,12 +164,37 @@ export async function kontextPerFeature(featureId) {
|
||||
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
|
||||
@@ -149,12 +206,19 @@ const veraltet = () =>
|
||||
* 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 }} optionen
|
||||
* @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version
|
||||
* @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() } = {}) {
|
||||
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')
|
||||
@@ -170,6 +234,13 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
|
||||
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,
|
||||
@@ -177,6 +248,7 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
|
||||
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)
|
||||
@@ -186,7 +258,46 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
|
||||
generation += 1
|
||||
cache.clear()
|
||||
}
|
||||
return { einstellung: JSON.parse(json), version: aktuell + 1 }
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user