Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen

POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).

PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 20:12:00 +00:00
1 parent 1cd74226cb
commit 29a38a9592
4 files changed
+837 -35

No files matched your search

+126 -19
View File
@@ -14,8 +14,8 @@ import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBe
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } from '../services/termine/fragebogen.js'
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung } from '../services/termine/kontext.js'
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { belegungAus, freieZeiten } from '../services/termine/slots.js'
@@ -31,7 +31,9 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
* Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne
* Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). PATCH /einstellungen
* schreibt den Entwurf nach eigenen Regeln (off und active ja; ordered und setup nur in der Kundenansicht; disabled nie; in off
* nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`.
* nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. POST /einstellungen/vorlage
* legt den Entwurf aus einer Branche an (oder ersetzt ihn, solange nichts bestellt ist) und schreibt dabei den Fragebogen mit;
* PUT /fragebogen speichert nur den Fragebogen (nie die Version der Einstellung) und nie in der Kundenansicht.
* Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in
* services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion,
* termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail).
@@ -72,7 +74,12 @@ const MELDUNG = {
keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
vorlage: 'Bitte wähle eine Vorlage aus der Liste.',
branche: 'Bitte wähle eine Branche aus der Liste.',
gesundheitsbereich: 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.',
betrieb: 'Die Betriebsangaben sind ungültig.',
vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.',
nichtErsetzbar: 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.',
kundenansichtBestaetigen: 'In der Kundenansicht kann nichts bestätigt werden.',
version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.',
art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.',
zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).',
@@ -361,28 +368,128 @@ async function betriebVorschlag(customerId, project) {
return { name, adresse, email, telefon, websiteUrl: liveWebsite(project) }
}
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const key = req.body?.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
// Betriebsfelder, die der Kunde beim Anlegen der Vorlage mitschicken darf (Anschrift und Telefon u. a. aus dem Konto vorbelegt)
const BETRIEB_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl']
const betrieb = await betriebAusKonto(getSessionCustomerId(req))
/**
* Die Betriebsfelder aus dem Rumpf der Vorlage-Route: nur die fünf bekannten, nur nichtleere Texte (ein leeres Feld lässt den
* Wert aus dem Konto stehen). Kein Objekt oder ein Feld, das kein Text ist => 400. Prüft nur die Form; Länge, Adresse und
* Mail-Format prüft die Einstellung selbst (ausVorlage).
*/
function betriebFelder(eingabe) {
if (eingabe === undefined || eingabe === null) return {}
if (!istObjekt(eingabe)) throw stop(400, MELDUNG.betrieb, undefined, 'betrieb')
const felder = {}
for (const feld of BETRIEB_FELDER) {
const wert = eingabe[feld]
if (wert === undefined || wert === null) continue
if (typeof wert !== 'string') throw stop(400, MELDUNG.betrieb, undefined, `betrieb.${feld}`)
if (wert.trim() !== '') felder[feld] = wert.trim()
}
return felder
}
/**
* Entwurf aus einer Vorlage anlegen (TP2: aus einer Branche, auch als Ersatz für den bestehenden Entwurf).
* { projectId, branche } Branche laut BRANCHEN; unbekannt => 400; Gesundheitsbereich => 400 `gesundheitsbereich` (harter
* Ausschluss, nichts wird angelegt, auch kein Funktionsdokument); die Branche steht danach im Fragebogen
* { projectId, vorlage } wie in TP1: Vorlage nach Schlüssel, der Fragebogen bleibt unberührt
* betrieb { name, adresse, telefon, email, websiteUrl }: überschreibt nur mit nichtleeren Werten das Konto
* ersetzen: true + version eine bestehende Einstellung ersetzen: nur im Zustand `off` und nur mit der gelesenen Version (sonst 409
* `EINSTELLUNG_VERALTET`); neue IDs und Kalender-Token, Fragebogen neu (Branche, keine bestätigten
* Schritte, Preise und Bundesland ungeprüft), die Einbauwünsche bleiben. Einstellung und Fragebogen
* werden in einem Vorgang unter der Sperre geschrieben (speichereEinstellung, Option fragebogen).
* Ohne `ersetzen` gilt wie in TP1: gibt es schon Einstellungen, ist es 409 `einstellung_vorhanden`.
*/
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const body = istObjekt(req.body) ? req.body : {}
let key
let brancheKey = null
if (body.branche !== undefined && body.branche !== null) {
const branche = typeof body.branche === 'string' ? BRANCHEN.find((b) => b.key === body.branche) : undefined
if (!branche) throw stop(400, MELDUNG.branche, undefined, 'branche')
if (branche.gesundheit) throw stop(400, MELDUNG.gesundheitsbereich, 'gesundheitsbereich', 'branche')
key = branche.vorlage
brancheKey = branche.key
} else {
key = body.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
}
const eigene = betriebFelder(body.betrieb)
const ersetzen = body.ersetzen === true
if (req.ctx.einstellung && !ersetzen) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
if (ersetzen) {
if (!Number.isInteger(body.version) || body.version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
if (req.ctx.feature.state !== 'off') throw stop(409, MELDUNG.nichtErsetzbar, 'nicht_ersetzbar')
}
const customerId = getSessionCustomerId(req)
const betrieb = { ...(await betriebAusKonto(customerId)), ...eigene }
const now = uhr()
const baue = () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}
// Ohne Funktionsdokument legt erst dieser Handler es an, nach der Eingabeprüfung (eine abgelehnte Vorlage hinterlässt nichts)
let feature = req.ctx.feature
if (!feature.$id) {
baue()
feature = await ensureFeatureDoc(customerId, req.projectId, 'termine')
}
// Branche oder Ersetzen: der Fragebogen beginnt neu, nur die Einbauwünsche bleiben (aus dem Stand unter der Sperre)
const fragebogen = brancheKey !== null || ersetzen
? (bisher) => ({ ...leerFragebogen(), branche: brancheKey ?? key, einbau: bisher.einbau })
: undefined
let gespeichert
try {
// version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(req.ctx.feature, () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}, { version: 0, by: von(req), now })
// ohne ersetzen version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(feature, baue, { version: ersetzen ? body.version : 0, by: von(req), now, fragebogen })
} catch (err) {
throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
throw err?.code === 'EINSTELLUNG_VERALTET' && !ersetzen ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
}
await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`)
res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
await logIfKundenansicht(req, 'termin_einstellungen', ersetzen ? `Vorlage ${key} ersetzt` : `Vorlage ${key}`)
res.status(201).json({
einstellung: fuerKunde(gespeichert.einstellung),
version: gespeichert.version,
fehlt: fehlendePflichtangaben(gespeichert.einstellung),
fragebogen: gespeichert.fragebogen ?? req.ctx.fragebogen,
})
}))
/** Schritte bestätigt nur der Kunde selbst: jede Sitzung der Kundenansicht (viewAs) bekommt 403, vor jeder anderen Prüfung. */
const nichtInKundenansicht = route(async (req, _res, next) => {
if (req.session?.viewAs) throw stop(403, MELDUNG.kundenansichtBestaetigen)
return next()
})
/**
* Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht:
* Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal).
* Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur
* fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern.
* Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag.
*/
router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const geprueft = normalisiereFragebogen(req.body?.fragebogen)
if (geprueft.fehler) throw Object.assign(new Error(Object.values(geprueft.fehler)[0]), { status: 400, fehler: geprueft.fehler })
const { einstellung, feature } = mitEinstellung(req)
let gespeichert
try {
gespeichert = await speichereFragebogen(feature, () => geprueft.fragebogen, { by: von(req), now: uhr() })
} catch (err) {
throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err
}
const abo = feature.state === 'off' ? await aboFuerVorschau(getSessionCustomerId(req)) : null
const offen = feature.state === 'off' ? bestellpruefung({ einstellung, fragebogen: gespeichert.fragebogen, feature: featureConfig('termine'), abo }) : null
res.json({ fragebogen: gespeichert.fragebogen, offen })
}))
/**