Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich
PATCH /api/termine/einstellungen schreibt den Entwurf nach eigenen Regeln
(withTermine({ schreiben, entwurf }) in routes/termine.js): off und active
ja, ordered und setup nur in der Kundenansicht (sonst 409 gesperrt), disabled
409 abgeschaltet wie bisher. In off zusaetzlich laeuft(customerId): ohne
Wartungs-Abo 409 kein_abo (Text aus aboMeldung), bei Lesefehler 503
abo_pruefung; eine Kuendigung oder offene Zahlung sperrt nur die Bestellung,
nicht das Speichern des Entwurfs. Das Funktionsdokument legt erst der Handler
an (ensureFeatureDoc), nach der Eingabepruefung. PATCH normalisiert mit
entwurf: state !== 'active' und antwortet mit fehlt. Tests: Zustandsmatrix,
Mandanten, Pflichtangaben, Abo, und die Gegenprobe an der oeffentlichen
Schnittstelle (Entwurf und Einbauwuensche nie oeffentlich).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42a3befc5f
commit
1cd74226cb
3 files changed
+495
-29
No files matched your search
+81
-21
@@ -1,12 +1,13 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { aboMemo } from '../services/aboStatus.js'
|
||||
import { aboMemo, laeuft } from '../services/aboStatus.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { analysePrivacyData } from '../services/analysePrivacy.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { aboMeldung } from '../services/featureOrder.js'
|
||||
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
|
||||
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { assertOwnProject, ensureFeatureDoc, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { verschiebeZeiten } from '../services/termine/ablauf.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
@@ -28,7 +29,9 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
|
||||
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder
|
||||
* `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409.
|
||||
* 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 }).
|
||||
* 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`.
|
||||
* 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).
|
||||
@@ -65,6 +68,7 @@ const MELDUNG = {
|
||||
abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.',
|
||||
abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.',
|
||||
abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.',
|
||||
gesperrt: 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.',
|
||||
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.',
|
||||
@@ -96,12 +100,16 @@ const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.
|
||||
/** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */
|
||||
const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) })
|
||||
|
||||
/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 500 ohne Rohtext. */
|
||||
/**
|
||||
* Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, ebenso das 503 der
|
||||
* Abo-Prüfung des Entwurfs (`abo_pruefung`: "später noch einmal"); alles andere 500 ohne Rohtext.
|
||||
*/
|
||||
function fail(res, err) {
|
||||
const status = Number.isInteger(err?.status) ? err.status : 500
|
||||
const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {}
|
||||
const eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET'
|
||||
if (!eigen || status < 400 || status >= 500) {
|
||||
const durchgelassen = (status >= 400 && status < 500) || (status === 503 && err?.code === 'abo_pruefung')
|
||||
if (!eigen || !durchgelassen) {
|
||||
console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
return res.status(500).json({ error: 'Interner Serverfehler' })
|
||||
}
|
||||
@@ -157,15 +165,52 @@ const projectIdOf = (req) => {
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Abo konnte nicht gelesen werden: nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten).
|
||||
* Der Aufrufer entscheidet, was dann gilt (Vorschau: "nicht geprüft", Entwurf schreiben: 503).
|
||||
*/
|
||||
const logAboFehler = (err) => console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
|
||||
/**
|
||||
* Schreibregeln des Entwurfs (PATCH /einstellungen), nach dem Zustand des Funktionsdokuments (`feature`, null = kein Dokument = off):
|
||||
* active ja (die Einstellung läuft; der Abo-Abgleich beendet sie, nicht das Speichern)
|
||||
* off ja, aber nur mit laufendem Wartungs-Abo (laeuft, nicht bestellbar: eine Kündigung oder offene Zahlung sperrt nur die
|
||||
* Bestellung, Gate (h), nicht das Speichern des Entwurfs). Ohne Abo 409 `kein_abo`, bei Lesefehler 503 `abo_pruefung`.
|
||||
* ordered, setup nur in der Kundenansicht (Mitarbeiter); der Kunde selbst bekommt 409 `gesperrt`: Änderungen laufen über den Chat
|
||||
* disabled 409 `abgeschaltet` (Text von E11), auch in der Kundenansicht
|
||||
* Ein unbekannter Zustand gilt als nicht aktiv (409). Liest nichts außer dem Abo und schreibt nichts.
|
||||
*/
|
||||
async function pruefeEntwurfSchreiben(req, customerId, feature) {
|
||||
const state = feature ? feature.state : 'off'
|
||||
if (state === 'active') return
|
||||
if (state === 'disabled') throw stop(409, abgeschaltetMeldung(feature), 'abgeschaltet')
|
||||
if (state === 'ordered' || state === 'setup') {
|
||||
if (isKundenansicht(req.session)) return
|
||||
throw stop(409, MELDUNG.gesperrt, 'gesperrt')
|
||||
}
|
||||
if (state !== 'off') throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
|
||||
|
||||
const register = featureConfig('termine')
|
||||
let laufend
|
||||
try {
|
||||
laufend = await laeuft(customerId)
|
||||
} catch (err) {
|
||||
logAboFehler(err)
|
||||
throw stop(503, aboMeldung(register, 'unbekannt'), 'abo_pruefung')
|
||||
}
|
||||
if (!laufend) throw stop(409, aboMeldung(register, 'kein_abo'), 'kein_abo')
|
||||
}
|
||||
|
||||
/**
|
||||
* Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active.
|
||||
* Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409).
|
||||
*
|
||||
* entwurf (TP2, nur GET /einstellungen): Lesen in jedem Zustand des Funktionsdokuments (off, ordered, setup, active,
|
||||
* disabled) über kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller
|
||||
* `off` ohne Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und nie etwas angelegt. Projekt (403)
|
||||
* und Schalter (409) gelten wie sonst. Die Schreibregeln des Entwurfs folgen mit C4b: bis dahin behält jede Route mit
|
||||
* `schreiben` die Schranke active, auch wenn `entwurf` gesetzt ist.
|
||||
* entwurf (TP2): Zugriff auf den Entwurf in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, disabled) über
|
||||
* kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller `off` ohne
|
||||
* Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und hier nie etwas angelegt (das macht der Handler
|
||||
* mit ensureFeatureDoc, erst nach der Eingabeprüfung). Projekt (403) und Schalter (409) gelten wie sonst. Ohne `schreiben`
|
||||
* (GET) liest jeder Zustand; mit `schreiben` gelten die Regeln von pruefeEntwurfSchreiben. Öffentlich wird der Entwurf nie:
|
||||
* die öffentliche Schnittstelle liest nur Funktionen im Zustand active (kontextPerSiteKey).
|
||||
*/
|
||||
const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async (req, _res, next) => {
|
||||
const projectId = projectIdOf(req)
|
||||
@@ -180,7 +225,8 @@ const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async
|
||||
req.projectId = projectId
|
||||
if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
|
||||
const feature = await getFeatureDoc(customerId, projectId, 'termine')
|
||||
if (entwurf && !schreiben) {
|
||||
if (entwurf) {
|
||||
if (schreiben) await pruefeEntwurfSchreiben(req, customerId, feature)
|
||||
req.ctx = feature ? await kontextFuerPortal(feature) : virtuellerKontext(customerId, projectId, req.project)
|
||||
return next()
|
||||
}
|
||||
@@ -249,8 +295,7 @@ async function aboFuerVorschau(customerId) {
|
||||
try {
|
||||
return await aboMemo().bestellbar(customerId)
|
||||
} catch (err) {
|
||||
// nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten)
|
||||
console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
logAboFehler(err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -340,24 +385,39 @@ router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: t
|
||||
res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
|
||||
}))
|
||||
|
||||
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
/**
|
||||
* Einstellungen speichern (Version wie beim Lesen, optimistisch gesperrt). Der Entwurf (alles außer active) darf unvollständig
|
||||
* gespeichert werden: es entfallen nur die Pflichtangaben für Gäste (Telefon, Anschrift), alles andere prüft er wie active; die
|
||||
* Antwort nennt sie in `fehlt`. In active sind sie Pflicht (400). Ohne Funktionsdokument legt erst dieser Handler es an, nach der
|
||||
* Eingabeprüfung (ein abgelehnter Entwurf hinterlässt nichts).
|
||||
*/
|
||||
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
|
||||
const { version, einstellung: eingabe } = req.body ?? {}
|
||||
if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
|
||||
const now = uhr()
|
||||
const feature = req.ctx.feature
|
||||
|
||||
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
|
||||
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
|
||||
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
|
||||
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now })
|
||||
const entwurf = req.ctx.feature.state !== 'active'
|
||||
const pruefe = (bisher, kuenftigeTermine) => {
|
||||
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, entwurf, now })
|
||||
if (ergebnis.fehler) {
|
||||
throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler })
|
||||
}
|
||||
return ergebnis.einstellung
|
||||
}
|
||||
|
||||
let feature = req.ctx.feature
|
||||
if (!feature.$id) {
|
||||
pruefe(null, null)
|
||||
feature = await ensureFeatureDoc(getSessionCustomerId(req), req.projectId, 'termine')
|
||||
}
|
||||
|
||||
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
|
||||
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
|
||||
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
|
||||
return pruefe(bisher, kuenftigeTermine)
|
||||
}, { version, by: von(req), now })
|
||||
|
||||
await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert')
|
||||
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version })
|
||||
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
|
||||
}))
|
||||
|
||||
router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
|
||||
|
||||
Reference in new issue
Block a user