From be49d38e0cade973d34db346ad459bf5c300d5ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:32:12 +0000 Subject: [PATCH] Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot, verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist (withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/ablauf.js | 156 ++-- server/services/termine/ablaufBetrieb.js | 444 +++++++++++ server/services/termine/buchungen.js | 11 +- server/services/termine/einstellungen.js | 25 +- server/services/termine/rechtstexte.js | 5 +- test/termineAblaufBetrieb.test.js | 943 +++++++++++++++++++++++ test/termineAblaufGast.test.js | 18 + test/termineEinstellungen.test.js | 40 +- 8 files changed, 1563 insertions(+), 79 deletions(-) create mode 100644 server/services/termine/ablaufBetrieb.js create mode 100644 test/termineAblaufBetrieb.test.js diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index e7eadac..78cf078 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -56,6 +56,10 @@ import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js' * das, bleibt alles wie es war), die alten erst danach freigegeben; die Zellen einer abgesagten Buchung gelten ohnehin * als frei, ein Fehler beim Freigeben macht eine Absage also nicht rueckgaengig (nur ein Log). Fuer die Zellen gilt: * nie selbst eine termine-res-Sperre halten (belegeZellen/gibZellenFrei sind nicht wiedereintrittsfaehig). + * + * Die Aktionen des Betriebs (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren, Termine von Hand, + * Sperren) stehen in ablaufBetrieb.js und nutzen die hier exportierten Bausteine (Pruefungen, Meldungen, mailSicher, + * pruefeSlot, verschiebeKern). */ const MIN_MS = 60 * 1000 @@ -72,7 +76,7 @@ const TELEFON_MAX = 40 const ZUSATZ_WERT_MAX = 200 const ZUSATZ_JSON_MAX = 2000 -const MELDUNG = { +export const MELDUNG = { formular: 'Bitte das Formular ausfüllen.', leistung: 'Bitte eine Leistung auswählen.', ressource: 'Bitte aus den angebotenen Möglichkeiten auswählen.', @@ -100,14 +104,14 @@ const MELDUNG = { keinWiderruf: 'Für diesen Termin ist kein Widerruf möglich.', } -const iso = (wert) => new Date(wert).toISOString() +export const iso = (wert) => new Date(wert).toISOString() const sha256 = (text) => createHash('sha256').update(text).digest('hex') -const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +export const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /** Fehler mit Status und Feldfehlern; message ist die erste Meldung (nie an Gaeste ausgeben, dafuer gibt es fehler). */ -const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) +export const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) -const zeitVergeben = () => fehlerMit(409, { zeit: MELDUNG.zeitVergeben }, { code: 'SLOT_VERGEBEN' }) +export const zeitVergeben = () => fehlerMit(409, { zeit: MELDUNG.zeitVergeben }, { code: 'SLOT_VERGEBEN' }) const unbekannt = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbekannt' }) /** Betriebsangaben fuer eine abgelaufene Reservierung: Name, Telefon und Website (Link "zurueck zur Website"). */ @@ -115,7 +119,7 @@ const betriebKurz = (ctx) => ({ name: ctx.einstellung.betrieb.name, telefon: ctx const abgelaufenMit = (status, ctx) => fehlerMit(status, { _: MELDUNG.abgelaufen }, { code: 'abgelaufen', betrieb: betriebKurz(ctx) }) /** Sammelt Feldfehler (der erste je Feld gilt): { fehler, fehl(feld, meldung) }. */ -function fehlerSammler() { +export function fehlerSammler() { const fehler = {} const fehl = (feld, meldung) => { if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung @@ -132,13 +136,13 @@ function pruefeNameFeld(wert, fehl) { } /** Log ohne personenbezogene Daten: Vorgang, Buchungsnummer, Art und Text des Fehlers (kommt aus unserem Code oder Appwrite). */ -function protokolliere(was, bookingId, err) { +export function protokolliere(was, bookingId, err) { const nummer = bookingId ? buchungsNummer(bookingId) : '-' console.error(`[termine-ablauf] ${was} (${nummer}): ${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`) } /** Mail einreihen; ein Fehler macht die schon erfolgte Zustandsaenderung nicht rueckgaengig. */ -async function mailSicher(was, bookingId, fn) { +export async function mailSicher(was, bookingId, fn) { try { await fn() } catch (err) { @@ -151,8 +155,10 @@ async function mailSicher(was, bookingId, fn) { /** * Prueft die Eingabe des Formulars gegen die Einstellung. => geprueft und bereinigt; sonst 400 mit allen Feldfehlern. * Abgeschaltete Gastfelder (aus) werden ignoriert, Zusatzfelder muessen konfiguriert sein. + * manuell (Termin von Hand, ablaufBetrieb.js): die E-Mail-Adresse ist freiwillig (leer => ''; ist sie angegeben, muss sie + * stimmen), Erinnerung und Zustimmungen entfallen (der Betrieb traegt ein, der Gast klickt nichts an). */ -function pruefeEingabe(e, roh, now) { +export function pruefeEingabe(e, roh, now, { manuell = false } = {}) { if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) const { fehler, fehl } = fehlerSammler() @@ -180,7 +186,8 @@ function pruefeEingabe(e, roh, now) { // Name, E-Mail const name = pruefeNameFeld(roh.name, fehl) - const email = emailAdresse(roh.email) + const ohneAdresse = manuell && (roh.email === undefined || roh.email === null || (typeof roh.email === 'string' && roh.email.trim() === '')) + const email = ohneAdresse ? '' : emailAdresse(roh.email) if (email === null) fehl('email', MELDUNG.email) // Telefon, Nachricht, Zusatzfelder nach gastFelder @@ -205,18 +212,18 @@ function pruefeEingabe(e, roh, now) { } const zusatz = pruefeZusatz(felder.zusatz || [], roh.zusatz, fehl) - // Erinnerung (freiwillig, nur wenn angeboten) + // Erinnerung (freiwillig, nur wenn angeboten; nicht bei Handeintraegen) let erinnerung = false - if (roh.erinnerung !== undefined && roh.erinnerung !== null) { + if (!manuell && roh.erinnerung !== undefined && roh.erinnerung !== null) { if (typeof roh.erinnerung === 'boolean') erinnerung = roh.erinnerung && e.erinnerung?.anbieten === true else fehl('erinnerung', MELDUNG.ungueltig) } // Zustimmungen const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {} - const stornoNoetig = typeof e.storno?.text === 'string' && e.storno.text !== '' + const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== '' if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno) - const vorzeitigNoetig = leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false + const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig) if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) @@ -280,13 +287,16 @@ function baueNachweis(e, g, verbraucher, now) { /** Buchungen der Liste, die an diesem Berliner Tag beginnen (Mass fuer "die wenigsten Buchungen an dem Tag"). */ const beginnenAn = (liste, tag) => liste.filter((b) => Number.isFinite(zeitMs(b.startsAt)) && ausUtc(new Date(zeitMs(b.startsAt))).tag === tag) -/** Freie Zeit neu berechnen (Belegung des Tages frisch aus der Datenbank) und bei "egal" die Ressource waehlen. */ -async function pruefeSlot(ctx, g, now) { +/** + * Freie Zeit neu berechnen (Belegung des Tages frisch aus der Datenbank) und bei "egal" die Ressource waehlen. + * ohneRegeln hebt Vorlauf und Horizont auf (Handeintrag des Betriebs; nie die Vergangenheit, nie belegte Zeiten). + */ +export async function pruefeSlot(ctx, g, now, { ohneRegeln = false } = {}) { const e = ctx.einstellung const von = zuUtc(g.tag, '00:00') const bis = zuUtc(g.tag, '24:00') const amTag = await imZeitraum(ctx.feature.$id, von, bis) - const zeiten = freieZeiten({ einstellung: e, leistungId: g.leistung.id, ressourceId: g.ressource, tag: g.tag, belegung: belegungAus(amTag, now), now }) + const zeiten = freieZeiten({ einstellung: e, leistungId: g.leistung.id, ressourceId: g.ressource, tag: g.tag, belegung: belegungAus(amTag, now), now, ohneRegeln }) const treffer = zeiten.find((z) => z.uhrzeit === g.zeit) if (!treffer) throw zeitVergeben() if (g.ressource !== 'egal') return { start: treffer.start, ressourceId: g.ressource } @@ -411,7 +421,7 @@ async function findePerToken(token, { nurBestaetigung = false, nurVerwaltung = f // ---------------------------------------------------------------- Aktionen des Gastes -const VERWALTBAR = ['bestaetigt', 'angefragt'] +export const VERWALTBAR = ['bestaetigt', 'angefragt'] /** Darf der Gast jetzt absagen oder verschieben? Bis absageBisStd Stunden vor Beginn (genau auf die Minute noch ja). */ export function kannAbsagen(einstellung, buchung, now) { @@ -638,7 +648,7 @@ function pruefeAbsagbar(einstellung, buchung, now) { * Zellen einer Buchung freigeben, nachdem sich ihr Zustand geaendert hat. Ein Fehler wird nur protokolliert: bei einer * Absage gelten die Zellen einer nicht mehr belegenden Buchung ohnehin als frei (zellen.js, der Tagesjob raeumt sie). */ -async function gibFreiSicher(bookingId, optionen) { +export async function gibFreiSicher(bookingId, optionen) { try { await gibZellenFrei(bookingId, optionen) } catch (err) { @@ -694,8 +704,11 @@ function mitGebuchterDauer(einstellung, buchung) { */ const verschiebeRessource = (einstellung, buchung) => (einstellung.regeln?.personWaehlbar === true ? buchung.ressourceId : 'egal') -/** Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }. */ -async function verschiebeZeiten(ctx, buchung, tag, now) { +/** + * Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }. + * ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont). + */ +async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) { const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00')) const zeiten = freieZeiten({ einstellung: mitGebuchterDauer(ctx.einstellung, buchung), @@ -704,6 +717,7 @@ async function verschiebeZeiten(ctx, buchung, tag, now) { tag, belegung: belegungAus(amTag, now, { ohneBuchung: buchung.$id }), now, + ohneRegeln, }) return { zeiten, amTag } } @@ -734,7 +748,7 @@ const zustimmungHinweis = (betrieb) => `Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder ${betrieb.telefon ? `anrufen: ${betrieb.telefon}.` : 'direkt beim Betrieb melden.'}` /** Tag ('YYYY-MM-DD') und Uhrzeit ('HH:MM') des neuen Termins; sonst 400 mit den Feldern tag und zeit. */ -function pruefeVerschiebung(roh) { +export function pruefeVerschiebung(roh) { if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) const { fehler, fehl } = fehlerSammler() const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null @@ -745,6 +759,62 @@ function pruefeVerschiebung(roh) { return { tag, zeit } } +/** + * Kern des Verschiebens (Gast und Betrieb): MUSS unter `termine-buchung:` laufen, `alt` ist die frisch gelesene + * Buchung. Berechnet die neue Zeit, belegt die neuen Zellen (SLOT_VERGEBEN => 409, alles unveraendert), aendert die + * Buchung (Beginn, Ende, Blockende, Ressource, icsSequenz + 1; scheitert das, werden die neuen Zellen wieder entfernt) + * und gibt erst danach die alten Zellen frei. Die Mails schickt der Aufrufer. + * betrieb: Verschieben durch den Betrieb ohne Vorlauf und Horizont (ohneRegeln; nie in die Vergangenheit, nie auf + * belegte Zellen) und ohne die Pruefung der Zustimmung zum vorzeitigen Beginn (Fristen und Zustimmung schuetzen den Gast). + * @returns {Promise<{ alt: object, buchung: object, start: Date }>} + * @throws Error 400 (dieselbe Zeit), 409 ('zustimmung_noetig', 'SLOT_VERGEBEN' Feld zeit) + */ +export async function verschiebeKern(ctx, alt, { tag, zeit }, now, { betrieb = false } = {}) { + const id = alt.$id + const featureId = ctx.feature.$id + const { zeiten, amTag } = await verschiebeZeiten(ctx, alt, tag, now, { ohneRegeln: betrieb }) + const treffer = zeiten.find((z) => z.uhrzeit === zeit) + if (!treffer) throw zeitVergeben() + const ressourceId = treffer.ressourceIds.includes(alt.ressourceId) + ? alt.ressourceId + : waehleRessource(treffer.ressourceIds, beginnenAn(amTag, tag), ctx.einstellung, now) + if (treffer.start.getTime() === zeitMs(alt.startsAt) && ressourceId === alt.ressourceId) throw fehlerMit(400, { zeit: MELDUNG.gleicheZeit }) + if (!betrieb && verschiebenBrauchtZustimmung(alt, treffer.start)) { + throw fehlerMit(409, { _: zustimmungHinweis(ctx.einstellung.betrieb) }, { code: 'zustimmung_noetig' }) + } + + const start = treffer.start + const ende = new Date(start.getTime() + (zeitMs(alt.endsAt) - zeitMs(alt.startsAt))) + const block = new Date(start.getTime() + (zeitMs(alt.blockEndsAt) - zeitMs(alt.startsAt))) + try { + await belegeZellen({ featureId, ressourceId, bookingId: id, start, blockEnde: block, now, ausser: id }) + } catch (err) { + throw err?.code === 'SLOT_VERGEBEN' ? zeitVergeben() : err + } + + let buchung + try { + buchung = await aendere(id, { + startsAt: iso(start), + endsAt: iso(ende), + blockEndsAt: iso(block), + ressourceId, + ressourceName: ctx.einstellung.ressourcen.find((r) => r.id === ressourceId)?.name ?? alt.ressourceName, + icsSequenz: (Number.isInteger(alt.icsSequenz) ? alt.icsSequenz : 0) + 1, + // eine fuer die alte Zeit schon gesendete Erinnerung gilt nicht fuer die neue + ...(alt.erinnerungGesendetAt ? { erinnerungGesendetAt: null } : {}), + updatedAt: iso(now), + }) + } catch (err) { + // die neuen Zellen wieder abgeben: ausser den alten bleibt nichts stehen + await gibFreiSicher(id, { behalte: zellenFuer(alt.startsAt, alt.blockEndsAt), ressourceId: alt.ressourceId }) + throw err + } + // Mit der Ressource des neuen Termins bleiben nur dessen Zellen; die der alten Zeit (und einer anderen Ressource) gehen. + await gibFreiSicher(id, { behalte: zellenFuer(start, block), ressourceId }) + return { alt, buchung, start } +} + /** * Gast verschiebt per Verwaltungslink (gleiche Frist wie beim Absagen) auf eine freie Zeit. Die eigene Buchung belegt * die neue Zeit nicht selbst (belegungAus ohneBuchung, belegeZellen ausser), 10:00 -> 10:30 ueberlappend ist also @@ -766,52 +836,12 @@ export async function verschiebeGast(token, roh, now) { const { tag, zeit } = pruefeVerschiebung(roh) const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs) const id = gefunden.$id - const featureId = ctx.feature.$id const ergebnis = await withLocks([`termine-buchung:${id}`], async () => { const alt = await hole(id) if (!alt) throw unbekannt() pruefeAbsagbar(ctx.einstellung, alt, now) - - const { zeiten, amTag } = await verschiebeZeiten(ctx, alt, tag, now) - const treffer = zeiten.find((z) => z.uhrzeit === zeit) - if (!treffer) throw zeitVergeben() - const ressourceId = treffer.ressourceIds.includes(alt.ressourceId) - ? alt.ressourceId - : waehleRessource(treffer.ressourceIds, beginnenAn(amTag, tag), ctx.einstellung, now) - if (treffer.start.getTime() === zeitMs(alt.startsAt) && ressourceId === alt.ressourceId) throw fehlerMit(400, { zeit: MELDUNG.gleicheZeit }) - if (verschiebenBrauchtZustimmung(alt, treffer.start)) throw fehlerMit(409, { _: zustimmungHinweis(ctx.einstellung.betrieb) }, { code: 'zustimmung_noetig' }) - - const start = treffer.start - const ende = new Date(start.getTime() + (zeitMs(alt.endsAt) - zeitMs(alt.startsAt))) - const block = new Date(start.getTime() + (zeitMs(alt.blockEndsAt) - zeitMs(alt.startsAt))) - try { - await belegeZellen({ featureId, ressourceId, bookingId: id, start, blockEnde: block, now, ausser: id }) - } catch (err) { - throw err?.code === 'SLOT_VERGEBEN' ? zeitVergeben() : err - } - - let buchung - try { - buchung = await aendere(id, { - startsAt: iso(start), - endsAt: iso(ende), - blockEndsAt: iso(block), - ressourceId, - ressourceName: ctx.einstellung.ressourcen.find((r) => r.id === ressourceId)?.name ?? alt.ressourceName, - icsSequenz: (Number.isInteger(alt.icsSequenz) ? alt.icsSequenz : 0) + 1, - // eine fuer die alte Zeit schon gesendete Erinnerung gilt nicht fuer die neue - ...(alt.erinnerungGesendetAt ? { erinnerungGesendetAt: null } : {}), - updatedAt: iso(now), - }) - } catch (err) { - // die neuen Zellen wieder abgeben: ausser den alten bleibt nichts stehen - await gibFreiSicher(id, { behalte: zellenFuer(alt.startsAt, alt.blockEndsAt), ressourceId: alt.ressourceId }) - throw err - } - // Mit der Ressource des neuen Termins bleiben nur dessen Zellen; die der alten Zeit (und einer anderen Ressource) gehen. - await gibFreiSicher(id, { behalte: zellenFuer(start, block), ressourceId }) - return { alt, buchung, start } + return verschiebeKern(ctx, alt, { tag, zeit }, now) }) const { alt, buchung, start } = ergebnis diff --git a/server/services/termine/ablaufBetrieb.js b/server/services/termine/ablaufBetrieb.js new file mode 100644 index 0000000..4fe0759 --- /dev/null +++ b/server/services/termine/ablaufBetrieb.js @@ -0,0 +1,444 @@ +import { randomUUID } from 'node:crypto' +import { withLocks } from '../keyedLock.js' +import { aendere, hole, legeAn, loesche } from './buchungen.js' +import { text as bereinigterText } from './einstellungen.js' +import { anGast } from './mails.js' +import { + MELDUNG, + VERWALTBAR, + fehlerMit, + fehlerSammler, + gibFreiSicher, + iso, + istObjekt, + mailSicher, + protokolliere, + pruefeEingabe, + pruefeSlot, + pruefeVerschiebung, + verschiebeKern, + zeitVergeben, +} from './ablauf.js' +import { blockEnde } from './slots.js' +import { buchungsNummer, hashToken, verwaltungsToken } from './token.js' +import { pruefeNow, zeitMs } from './util.js' +import { datumLang, uhrzeit, zuUtc } from './zeit.js' +import { belegeZellen } from './zellen.js' + +/** + * Betrieb im Portal (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4, "Betrieb im Portal"): Aktionen auf + * Buchungen, Termine von Hand und Sperren. Gleiche Regeln wie ablauf.js: Zustandsaenderungen unter + * `termine-buchung:` nach frischem Lesen, `termine-res:` wird hier nie selbst gehalten, Mails gehen erst NACH der + * Aenderung und ausserhalb der Sperre in die Warteschlange (Fehler nur im Log), `now` ist immer ein Parameter. + * + * Der Betrieb handelt auf eigenen Wunsch, darum: keine K21-Mail an den Betrieb, keine Absagefrist, beim Verschieben kein + * Vorlauf und kein Horizont (ohneRegeln; nie in die Vergangenheit, nie auf belegte Zellen), keine Zustimmung zum + * vorzeitigen Beginn. Mails an den Gast gehen nur, wenn die Buchung eine Adresse hat (Handeintrag ohne Adresse, + * anonymisiert). Wer nie eine Einladung bekam (Status `angefragt`), bekommt bei Absage und Verschiebung keine Kalenderdatei (R21). + * + * Fehler: Error mit `status` und `fehler` = { : Text }, Texte in Du-Form (Portal). Unbekannte Buchung, Buchung einer + * anderen Funktion oder eine Sperre bei `aktion` 404 (code 'unbekannt'); nicht (mehr) moegliche Aktion 409 (code + * 'nicht_moeglich'); eine schon erfolgte Aktion (zweiter Klick) aendert nichts und meldet `schon: true`. + */ + +const MIN_MS = 60 * 1000 +const AKTIONEN = ['annehmen', 'ablehnen', 'absagen', 'verschieben', 'nichterschienen', 'anonymisieren'] +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const BIS_RE = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/ +const VON_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/ +const GRUND_MAX = 100 +const NACHRICHT_MAX = 500 +const TITEL_MAX = 120 +/** Laenge einer Belegung: hoechstens 100 Zellen zu 5 Minuten (ein Bulk-Create). */ +const BELEGUNG_MAX_MIN = 500 +const GELOESCHT = 'Gelöscht' +const SPERRE_TITEL = 'Gesperrt' + +const MELDUNG_BETRIEB = { + unbekannt: 'Diesen Termin gibt es nicht.', + keineSperre: 'Diese Sperre gibt es nicht.', + aktion: 'Bitte eine Aktion auswählen.', + grund: 'Bitte einen Grund auswählen.', + grundLang: `Der Grund darf höchstens ${GRUND_MAX} Zeichen lang sein.`, + nichtOffen: 'Diese Anfrage ist nicht mehr offen.', + nichtAbsagbar: 'Dieser Termin ist nicht mehr offen. Er kann nicht abgesagt oder verschoben werden.', + vergangen: 'Der Termin liegt in der Vergangenheit. Lehne die Anfrage ab.', + vorBeginn: 'Das geht erst, wenn der Termin begonnen hat.', + nichtMarkierbar: 'Dieser Termin kann nicht als „nicht erschienen“ markiert werden.', + nochOffen: 'Dieser Termin ist noch nicht bestätigt.', + zuLang: `Dieser Termin ist länger als ${BELEGUNG_MAX_MIN} Minuten.`, + schritt: 'Bitte eine Uhrzeit in 5-Minuten-Schritten angeben (z. B. 09:00).', + endeVorBeginn: 'Das Ende muss nach dem Beginn liegen.', + sperreLang: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.', + sperreVorbei: 'Die Sperre muss in der Zukunft enden.', + sperreBelegt: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.', + titelLang: `Der Titel darf höchstens ${TITEL_MAX} Zeichen lang sein.`, +} + +const nichtGefunden = (text = MELDUNG_BETRIEB.unbekannt) => fehlerMit(404, { _: text }, { code: 'unbekannt' }) +const nichtMoeglich = (text) => fehlerMit(409, { _: text }, { code: 'nicht_moeglich' }) + +/** Kontext wie in ablauf.js (halte): Betriebsangaben und Funktion muessen da sein. */ +function pruefeKontext(ctx, name) { + if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError(`${name}: ctx fehlt`) +} + +const hatAdresse = (buchung) => typeof buchung?.gastEmail === 'string' && buchung.gastEmail.trim() !== '' + +/** Mail an den Gast, falls die Buchung eine Adresse hat; ein Fehler macht die Aenderung nicht rueckgaengig (mailSicher). */ +async function mailAnGast(art, ctx, buchung, extra, now) { + if (!hatAdresse(buchung)) return + await mailSicher(art, buchung.$id, () => anGast(art, ctx, buchung, extra, now)) +} + +// ---------------------------------------------------------------- Aktionen auf Buchungen + +/** Prueft die Eingabe der Aktion; => { aktion, grund?, nachricht?, tag?, zeit? }, sonst 400 mit Feldfehlern. */ +function pruefeAktion(roh) { + if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) + const art = roh.aktion + if (typeof art !== 'string' || !AKTIONEN.includes(art)) throw fehlerMit(400, { aktion: MELDUNG_BETRIEB.aktion }) + if (art === 'verschieben') return { aktion: art, ...pruefeVerschiebung(roh) } + + const { fehler, fehl } = fehlerSammler() + const eingabe = { aktion: art } + if (art === 'absagen') { + eingabe.grund = bereinigterText(roh.grund, GRUND_MAX + 1) + if (eingabe.grund === '') fehl('grund', MELDUNG_BETRIEB.grund) + else if ([...eingabe.grund].length > GRUND_MAX) fehl('grund', MELDUNG_BETRIEB.grundLang) + } + if (art === 'absagen' || art === 'ablehnen') { + eingabe.nachricht = bereinigterText(roh.nachricht, NACHRICHT_MAX + 1, { mehrzeilig: true }) + if ([...eingabe.nachricht].length > NACHRICHT_MAX) fehl('nachricht', MELDUNG.nachrichtLang) + } + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + return eingabe +} + +/** Anfrage annehmen: `angefragt` => `bestaetigt` mit angenommenAt (Beginn der Widerrufsfrist), G02 mit Einladung. */ +async function annehmen(ctx, frisch, _eingabe, now) { + if (frisch.status === 'bestaetigt' && frisch.angenommenAt) return { antwort: { status: 'bestaetigt', schon: true } } + if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen) + if (!(zeitMs(frisch.startsAt) > now.getTime())) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vergangen }, { code: 'vergangen' }) + const buchung = await aendere(frisch.$id, { status: 'bestaetigt', angenommenAt: iso(now), updatedAt: iso(now) }) + return { antwort: { status: 'bestaetigt' }, mail: () => mailAnGast('G02', ctx, buchung, { anfrageAngenommen: true }, now) } +} + +/** Anfrage ablehnen: `abgelehnt`, Zellen frei, G04 (mit der Nachricht des Betriebs, falls angegeben). */ +async function ablehnen(ctx, frisch, eingabe, now) { + if (frisch.status === 'abgelehnt') return { antwort: { status: 'abgelehnt', schon: true } } + if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen) + const buchung = await aendere(frisch.$id, { status: 'abgelehnt', updatedAt: iso(now) }) + await gibFreiSicher(frisch.$id) + return { antwort: { status: 'abgelehnt' }, mail: () => mailAnGast('G04', ctx, buchung, { nachricht: eingabe.nachricht }, now) } +} + +/** Absage durch den Betrieb: `abgesagt` von `betrieb` mit Grund, Zellen frei, G06 (Kalenderabsage nur nach einer Einladung). */ +async function absagen(ctx, frisch, eingabe, now) { + if (frisch.status === 'abgesagt') return { antwort: { status: 'abgesagt', schon: true } } + if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar) + const buchung = await aendere(frisch.$id, { + status: 'abgesagt', + abgesagtAt: iso(now), + abgesagtVon: 'betrieb', + absageGrund: eingabe.grund, + updatedAt: iso(now), + }) + await gibFreiSicher(frisch.$id) + const extra = { von: 'betrieb', grund: eingabe.grund, nachricht: eingabe.nachricht, ohneIcs: frisch.status === 'angefragt' } + return { antwort: { status: 'abgesagt' }, mail: () => mailAnGast('G06', ctx, buchung, extra, now) } +} + +/** Verschieben durch den Betrieb: derselbe Kern wie beim Gast (verschiebeKern), aber ohne Frist, Vorlauf und Horizont. G07 an den Gast. */ +async function verschieben(ctx, frisch, eingabe, now) { + if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar) + const { alt, buchung, start } = await verschiebeKern(ctx, frisch, eingabe, now, { betrieb: true }) + const extra = { altStart: alt.startsAt, ohneIcs: alt.status === 'angefragt' } + return { + antwort: { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } }, + mail: () => mailAnGast('G07', ctx, buchung, extra, now), + } +} + +/** Gast nicht erschienen: nur nach Beginn des Termins, aus `bestaetigt` (oder schon `erledigt`). */ +async function nichterschienen(_ctx, frisch, _eingabe, now) { + if (frisch.status === 'nichterschienen') return { antwort: { status: 'nichterschienen', schon: true } } + if (!['bestaetigt', 'erledigt'].includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtMarkierbar) + if (now.getTime() < zeitMs(frisch.startsAt)) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vorBeginn }, { code: 'vor_beginn' }) + await aendere(frisch.$id, { status: 'nichterschienen', updatedAt: iso(now) }) + await gibFreiSicher(frisch.$id) + return { antwort: { status: 'nichterschienen' } } +} + +/** + * Gastdaten auf Wunsch loeschen: Name "Gelöscht", E-Mail, Telefon, Nachricht und Zusatzfelder leer, keine Erinnerung, + * Links ungueltig (Hashes leer). Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon + * die Loeschfrist (30 Tage) laeuft. Eine noch nicht bestaetigte Haltung bleibt unberuehrt (409). + */ +async function anonymisieren(_ctx, frisch, _eingabe, now) { + if (frisch.status === 'gehalten') throw nichtMoeglich(MELDUNG_BETRIEB.nochOffen) + const behalteFrist = frisch.status === 'abgesagt' || frisch.status === 'abgelehnt' + const buchung = await aendere(frisch.$id, { + gastName: GELOESCHT, + gastEmail: '', + gastTelefon: '', + gastNachricht: '', + gastZusatz: '', + erinnerung: false, + verifyHash: '', + manageHash: '', + updatedAt: iso(behalteFrist && frisch.updatedAt ? frisch.updatedAt : now), + }) + return { antwort: { status: buchung.status } } +} + +const AKTION = { annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren } + +/** + * Aktion des Betriebs auf eine Buchung der Funktion. + * - annehmen: `angefragt` => `bestaetigt`, angenommenAt; G02 (Block anfrage_angenommen) mit Einladung (REQUEST, Sequenz + * der Buchung) und Verwaltungslink. Nicht fuer eine Anfrage, deren Beginn vorbei ist (409 'vergangen'). + * - ablehnen: `angefragt` => `abgelehnt`, Zellen frei, G04 (optional `nachricht`). + * - absagen: `bestaetigt`/`angefragt` => `abgesagt` von `betrieb`, Pflicht `grund`, optional `nachricht`, Zellen frei, + * G06 (absage_von = Betriebsname; ohne Kalenderabsage fuer eine Anfrage). Keine Absagefrist. + * - verschieben: `{ tag, zeit }`, wie beim Gast, aber ohne Frist, Vorlauf, Horizont und Zustimmungspruefung; G07. + * - nichterschienen: erst ab Beginn (409 'vor_beginn'). + * - anonymisieren: Gastfelder leeren, Status und Zeiten bleiben. + * Kein K21 an den Betrieb: er hat es selbst getan. + * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) + * @param {string} bookingId + * @param {{ aktion: string, grund?: string, nachricht?: string, tag?: string, zeit?: string }} roh + * @param {Date} now + * @returns {Promise<{ status: string, schon?: true, termin?: { datum: string, zeit: string } }>} + * @throws Error 400 (Eingabe; beim Verschieben auch dieselbe Zeit), 404 (unbekannt), 409 ('nicht_moeglich', 'vergangen', + * 'vor_beginn'; beim Verschieben auch 'SLOT_VERGEBEN' Feld zeit) + */ +export async function aktion(ctx, bookingId, roh, now) { + pruefeNow(now, 'aktion') + pruefeKontext(ctx, 'aktion') + const eingabe = pruefeAktion(roh) + if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden() + + const ergebnis = await withLocks([`termine-buchung:${bookingId}`], async () => { + const frisch = await hole(bookingId) + if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle === 'sperre') throw nichtGefunden() + return AKTION[eingabe.aktion](ctx, frisch, eingabe, now) + }) + if (ergebnis.mail) await ergebnis.mail() + return ergebnis.antwort +} + +// ---------------------------------------------------------------- Termine von Hand + +/** Eine gescheiterte Neuanlage (Zellen nicht belegbar) wieder entfernen; scheitert das, wird sie als verfallen vermerkt. */ +async function nimmZurueck(bookingId, now) { + try { + await withLocks([`termine-buchung:${bookingId}`], () => loesche(bookingId)) + } catch (err) { + protokolliere('Buchung nicht entfernt', bookingId, err) + try { + await withLocks([`termine-buchung:${bookingId}`], () => aendere(bookingId, { status: 'verfallen', heldUntil: iso(now), updatedAt: iso(now) })) + } catch (fehler) { + protokolliere('Buchung nicht als verfallen vermerkt', bookingId, fehler) + } + } +} + +/** + * Legt eine bestaetigte Buchung (Handeintrag oder Sperre) und danach ihre Zellen an. Reihenfolge wie beim Halten + * (zellen.js): die Buchung steht VOR den Zellen in der Datenbank; scheitert das Belegen, wird sie wieder entfernt. + * @param {() => Error} belegt erzeugt den Fehler fuer SLOT_VERGEBEN (409); andere Fehler gehen unveraendert durch + */ +async function legeBelegungAn(ctx, id, daten, ressourceId, start, block, now, belegt) { + const buchung = await legeAn(daten, id) + try { + await belegeZellen({ featureId: ctx.feature.$id, ressourceId, bookingId: id, start, blockEnde: block, now }) + } catch (err) { + await nimmZurueck(id, now) + throw err?.code === 'SLOT_VERGEBEN' ? belegt() : err + } + return buchung +} + +/** + * Termin von Hand eintragen (Telefon, Laufkundschaft): `quelle 'manuell'`, sofort `bestaetigt`, `verbraucher false` + * (kein Widerruf, keine Belehrung durch das System: bei telefonischen Buchungen Sache des Betriebs). Eingabe und Pruefung + * wie beim Gast (Leistung, Person oder "egal", Tag, Uhrzeit, Name, Telefon, Nachricht, Zusatzfelder), aber ohne Zustimmungen + * und Erinnerung, die E-Mail-Adresse ist freiwillig. Die Zeit muss frei sein (freie Zeiten der Leistung), nur Vorlauf und + * Horizont gelten nicht (ohneRegeln; nie die Vergangenheit, nie belegte Zellen). G02 (Einladung, Verwaltungslink, ohne + * Widerrufsbelehrung) geht nur mit `bestaetigungSenden: true` und einer Adresse an den Gast. Kein K20: der Betrieb weiss es. + * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) + * @param {object} roh { leistung, ressource, tag, zeit, name, email?, telefon?, nachricht?, zusatz?, bestaetigungSenden? } + * @param {Date} now + * @returns {Promise<{ id: string, nummer: string, status: 'bestaetigt' }>} + * @throws Error 400 (Feldfehler) oder 409 code SLOT_VERGEBEN (Feld zeit) + */ +export async function manuell(ctx, roh, now) { + pruefeNow(now, 'manuell') + pruefeKontext(ctx, 'manuell') + const e = ctx.einstellung + const g = pruefeEingabe(e, roh, now, { manuell: true }) + if (roh.bestaetigungSenden !== undefined && roh.bestaetigungSenden !== null && typeof roh.bestaetigungSenden !== 'boolean') { + throw fehlerMit(400, { bestaetigungSenden: MELDUNG.ungueltig }) + } + const senden = roh.bestaetigungSenden === true && g.email !== '' + + const { start, ressourceId } = await pruefeSlot(ctx, g, now, { ohneRegeln: true }) + const block = blockEnde(start, g.leistung) + if ((block.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) throw fehlerMit(400, { leistung: MELDUNG_BETRIEB.zuLang }) + + const id = randomUUID() + const buchung = await legeBelegungAn( + ctx, + id, + { + featureId: ctx.feature.$id, + customerId: ctx.feature.customerId, + projectId: ctx.feature.projectId, + leistungId: g.leistung.id, + leistungName: g.leistung.name, + ressourceId, + ressourceName: e.ressourcen.find((r) => r.id === ressourceId)?.name ?? '', + startsAt: iso(start), + endsAt: iso(new Date(start.getTime() + g.leistung.dauer * MIN_MS)), + blockEndsAt: iso(block), + status: 'bestaetigt', + quelle: 'manuell', + gastName: g.name, + gastEmail: g.email, + gastTelefon: g.telefon, + gastNachricht: g.nachricht, + gastZusatz: Object.keys(g.zusatz).length > 0 ? JSON.stringify(g.zusatz) : '', + erinnerung: false, + // der Verwaltungslink der Bestaetigung (und spaeterer Mails) findet die Buchung nur ueber diesen Hash + manageHash: hashToken(verwaltungsToken(id)), + bestaetigtAt: iso(now), + preisCent: g.leistung.preisCent, + preisArt: g.leistung.preisArt, + buchungsart: g.leistung.buchungsart, + verbraucher: false, + zahlart: 'vorort', + zahlStatus: 'keine', + icsSequenz: 0, + createdAt: iso(now), + updatedAt: iso(now), + }, + ressourceId, + start, + block, + now, + zeitVergeben, + ) + if (senden) await mailAnGast('G02', ctx, buchung, {}, now) + return { id, nummer: buchungsNummer(id), status: 'bestaetigt' } +} + +// ---------------------------------------------------------------- Sperren + +/** Prueft die Eingabe einer Sperre; => { ressource, tag, start, ende, titel }, sonst 400 mit Feldfehlern. */ +function pruefeSperre(e, roh, now) { + if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) + const { fehler, fehl } = fehlerSammler() + + const ressource = typeof roh.ressource === 'string' ? (e.ressourcen || []).find((r) => r.id === roh.ressource) : undefined + if (!ressource) fehl('ressource', MELDUNG.ressource) + const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null + if (tag === null) fehl('tag', MELDUNG.tag) + + const vonOk = typeof roh.von === 'string' && VON_RE.test(roh.von) + const bisOk = typeof roh.bis === 'string' && BIS_RE.test(roh.bis) + if (!vonOk) fehl('von', MELDUNG.zeit) + else if (Number(roh.von.slice(3)) % 5 !== 0) fehl('von', MELDUNG_BETRIEB.schritt) + if (!bisOk) fehl('bis', MELDUNG.zeit) + else if (Number(roh.bis.slice(3)) % 5 !== 0) fehl('bis', MELDUNG_BETRIEB.schritt) + + let start = null + let ende = null + if (tag !== null && !Object.hasOwn(fehler, 'von')) { + start = zuUtc(tag, roh.von) + if (start === null) fehl('von', MELDUNG.zeit) // Luecke der Maerz-Umstellung + } + if (tag !== null && !Object.hasOwn(fehler, 'bis')) { + ende = zuUtc(tag, roh.bis) + if (ende === null) fehl('bis', MELDUNG.zeit) + } + if (start && ende) { + if (ende.getTime() <= start.getTime()) fehl('bis', MELDUNG_BETRIEB.endeVorBeginn) + else if ((ende.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) fehl('bis', MELDUNG_BETRIEB.sperreLang) + else if (ende.getTime() <= now.getTime()) fehl('bis', MELDUNG_BETRIEB.sperreVorbei) + } + + const titel = bereinigterText(roh.titel, TITEL_MAX + 1) + if ([...titel].length > TITEL_MAX) fehl('titel', MELDUNG_BETRIEB.titelLang) + + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + return { ressource, start, ende, titel: titel === '' ? SPERRE_TITEL : titel } +} + +/** + * Zeit sperren: eine bestaetigte Buchung ohne Gast (`quelle 'sperre'`, `leistungName` = Titel, Vorgabe "Gesperrt") auf einer + * Ressource an einem Berliner Tag von `von` bis `bis` ('HH:MM', Vielfache von 5 Minuten, `bis` auch '24:00'), mit Zellen + * wie jede Buchung. Hoechstens 500 Minuten (100 Zellen in einem Bulk-Create), sonst 400 Feld `bis`; laengere Zeiten sind + * Sache der Abwesenheiten. Das Ende muss in der Zukunft liegen. Liegt schon ein Termin oder eine Sperre in der Zeit, + * 409 (code SLOT_VERGEBEN, Feld von): erst den Termin absagen oder verschieben. + * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) + * @param {{ ressource: string, tag: string, von: string, bis: string, titel?: string }} roh + * @param {Date} now + * @returns {Promise<{ id: string, status: 'bestaetigt' }>} + */ +export async function sperre(ctx, roh, now) { + pruefeNow(now, 'sperre') + pruefeKontext(ctx, 'sperre') + const s = pruefeSperre(ctx.einstellung, roh, now) + const id = randomUUID() + await legeBelegungAn( + ctx, + id, + { + featureId: ctx.feature.$id, + customerId: ctx.feature.customerId, + projectId: ctx.feature.projectId, + leistungId: '', + leistungName: s.titel, + ressourceId: s.ressource.id, + ressourceName: s.ressource.name, + startsAt: iso(s.start), + endsAt: iso(s.ende), + blockEndsAt: iso(s.ende), + status: 'bestaetigt', + quelle: 'sperre', + erinnerung: false, + bestaetigtAt: iso(now), + preisCent: 0, + preisArt: 'keiner', + verbraucher: false, + icsSequenz: 0, + createdAt: iso(now), + updatedAt: iso(now), + }, + s.ressource.id, + s.start, + s.ende, + now, + () => fehlerMit(409, { von: MELDUNG_BETRIEB.sperreBelegt }, { code: 'SLOT_VERGEBEN' }), + ) + return { id, status: 'bestaetigt' } +} + +/** + * Sperre aufheben: loescht die Buchung und danach ihre Zellen (nur `quelle 'sperre'` der eigenen Funktion, sonst 404). + * Wie bei einer Absage gilt eine Zelle ohne Buchung als frei; scheitert das Freigeben, steht ein Eintrag im Log und der + * Tagesjob raeumt auf. + * @returns {Promise<{ status: 'entsperrt' }>} + */ +export async function entsperre(ctx, bookingId) { + pruefeKontext(ctx, 'entsperre') + if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden(MELDUNG_BETRIEB.keineSperre) + return withLocks([`termine-buchung:${bookingId}`], async () => { + const frisch = await hole(bookingId) + if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle !== 'sperre') throw nichtGefunden(MELDUNG_BETRIEB.keineSperre) + await loesche(bookingId) + await gibFreiSicher(bookingId) + return { status: 'entsperrt' } + }) +} diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js index 22f26a5..acaa7ec 100644 --- a/server/services/termine/buchungen.js +++ b/server/services/termine/buchungen.js @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { config } from '../../config.js' -import { Query, createDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' +import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { istBelegend } from './slots.js' import { buchungsNummer } from './token.js' import { pruefeNow } from './util.js' @@ -68,6 +68,15 @@ export async function aendere(id, felder) { return updateDocument(col(), id, { ...aenderung, updatedAt: aenderung.updatedAt ?? new Date().toISOString() }) } +/** Loescht eine Buchung (nur Sperren und gescheiterte Handeintraege; 404 = schon weg). */ +export async function loesche(id) { + try { + await deleteDocument(col(), id) + } catch (err) { + if (err?.status !== 404) throw err + } +} + /** * Alle Buchungen (jeder Status) einer Funktion, die den Zeitraum [von, bis] beruehren: Beginn vor bis und Blockende * nach von (strikt). Findet damit auch eine Belegung vom Vortag, die in den Tag hineinreicht. diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index f73f961..7c39544 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -1,11 +1,14 @@ import { randomBytes } from 'node:crypto' +import { config } from '../../config.js' +import { withdrawalDeadline } from '../deadlines.js' import { BUNDESLAENDER } from './feiertage.js' import { heute, zuUtc } from './zeit.js' /** * Terminplaner-Einstellungen (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2, termineConfig.einstellung). - * Reine Funktionen ohne Appwrite und ohne config: Pruefen und Normalisieren der Betriebs-Einstellung, Texte fuer - * Knoepfe und Preise, die oeffentliche Sicht fuer GET /config. `now` kommt immer als Parameter. + * Reine Funktionen ohne Appwrite (config nur fuer die Haltezeit in brauchtZustimmungVorzeitig): Pruefen und + * Normalisieren der Betriebs-Einstellung, Texte fuer Knoepfe und Preise, die oeffentliche Sicht fuer GET /config. + * `now` kommt immer als Parameter. * Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal). */ @@ -19,7 +22,7 @@ const RESERVIERTE_SCHLUESSEL = ['__proto__', 'constructor', 'prototype'] const VORLAGE_RE = /^[a-z]{1,20}$/ const FEED_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' -const VORZEITIG_TAGE = 14 +const MIN_MS = 60000 const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) const idRe = (praefix) => new RegExp(`^${praefix}_[a-z0-9]{8}$`) @@ -576,12 +579,20 @@ export function istVerbraucher(einstellung) { } /** - * Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): verbindlich + Preis + Verbraucher und - * Terminbeginn vor now + 14 Tagen. Ein unbrauchbarer Beginn verlangt die Zustimmung lieber. + * Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): Preis + Verbraucher und + * - sofort buchbar: Terminbeginn bis zum Ende der Widerrufsfrist (withdrawalDeadline, Berliner Kalender, §§ 187, 188, 193 + * BGB). Der Vertrag kommt spaetestens nach der Haltezeit (config.termine.holdMinutes) zustande, gerechnet wird mit + * `now` plus Haltezeit. + * - Anfrage: immer. Den Tag der Annahme kennt niemand im Voraus, die Frist beginnt erst dann; die Zustimmung zu viel zu + * verlangen schadet nicht. + * Ein unbrauchbarer Beginn verlangt die Zustimmung lieber. */ export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) { - if (leistung?.buchungsart !== 'sofort' || !hatPreis(leistung) || !istVerbraucher(einstellung)) return false - return !(start.getTime() >= now.getTime() + VORZEITIG_TAGE * 86400000) + if (!hatPreis(leistung) || !istVerbraucher(einstellung)) return false + if (leistung.buchungsart === 'anfrage') return true + if (leistung.buchungsart !== 'sofort') return false + const fristEnde = withdrawalDeadline(new Date(now.getTime() + config.termine.holdMinutes * MIN_MS)).endsAt.getTime() + return !(start.getTime() > fristEnde) } export function preisHinweis(einstellung) { diff --git a/server/services/termine/rechtstexte.js b/server/services/termine/rechtstexte.js index f51e1f7..8b404ab 100644 --- a/server/services/termine/rechtstexte.js +++ b/server/services/termine/rechtstexte.js @@ -150,8 +150,9 @@ export function formularHinweis(einstellung) { // ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben /** - * Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, § 357a Abs. 2 BGB): nur bei verbindlicher Leistung - * mit Preis, Verbrauchern und Terminbeginn vor Ablauf der Widerrufsfrist. Sie-Form auch bei Du-Betrieben. + * Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, § 357a Abs. 2 BGB): nur bei Leistung mit Preis und + * Verbrauchern, bei sofort buchbaren Leistungen mit Terminbeginn vor Ablauf der Widerrufsfrist, bei Anfragen immer + * (brauchtZustimmungVorzeitig). Sie-Form auch bei Du-Betrieben. */ export const ZUSTIMMUNG_VORZEITIG = (name) => `Ich verlange ausdrücklich, dass ${name} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. ` diff --git a/test/termineAblaufBetrieb.test.js b/test/termineAblaufBetrieb.test.js new file mode 100644 index 0000000..9050d60 --- /dev/null +++ b/test/termineAblaufBetrieb.test.js @@ -0,0 +1,943 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Betrieb im Portal: Aktionen auf Buchungen (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren), +// Termine von Hand und Sperren. Aufbau wie test/termineAblaufVerwalten.test.js: Nachbau von Appwrite mit Schema +// (Standard-Limit 25), Mails in der Senke, feste Zeiten per Parameter. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + termineConfig: schemaKeys('termineConfig'), + termineBookings: schemaKeys('termineBookings'), + termineClaims: schemaKeys('termineClaims'), + termineOutbox: schemaKeys('termineOutbox'), + }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js') +const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { halte, vorschau, bestaetige, kannWiderrufen, verschiebeGast } = await import('../server/services/termine/ablauf.js') +const { aktion, manuell, sperre, entsperre } = await import('../server/services/termine/ablaufBetrieb.js') +after(() => setMailTransportForTests(null)) + +const MIN = 60000 +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ) +const TAG_NAH = '2026-10-12' // Montag, 7 Tage spaeter: Beginn innerhalb der Widerrufsfrist +const TAG_FERN = '2026-10-26' // Montag, 21 Tage spaeter (MEZ) +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_termine' +const R1 = 'r_aaaaaaa1' +const R2 = 'r_aaaaaaa2' +const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort +const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage +const ABSENDER_VORHER = config.smtp.from +const MODUS_VORHER = config.termine.mode +const BASIS_VORHER = config.publicBaseUrl + +const iso = (date) => new Date(date).toISOString() +const plus = (date, ms) => new Date(new Date(date).getTime() + ms) + +const feature = (id = FEATURE) => ({ $id: id, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) + +const eingabeEinstellung = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + bundesland: 'RP', + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, + { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, + ], + leistungen: [ + { id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] }, + { id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] }, + ], + regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }, + gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [{ key: 'marke', label: 'Marke', typ: 'text', pflicht: false }] }, + erinnerung: { anbieten: true, stundenVorher: 24 }, + storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, + ...over, +}) +const einstellung = (over) => { + const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} +const ctxMit = (e = einstellung(), id = FEATURE) => ({ feature: feature(id), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 }) + +/** Gastformular wie in den Gast-Tests: Montag in 3 Wochen, 10:00, Verbraucher, Stornohaken gesetzt. */ +const eingabe = (over = {}) => ({ + leistung: S_SOFORT, + ressource: R1, + tag: TAG_FERN, + zeit: '10:00', + name: 'Max Mustermann', + email: 'max@example.com', + telefon: '0171 1234567', + nachricht: '', + zusatz: {}, + erinnerung: false, + zustimmungen: { storno: true, vorzeitig: false }, + ...over, +}) + +/** Eingabe fuer einen Termin von Hand (keine Zustimmungen). */ +const handEingabe = (over = {}) => ({ + leistung: S_SOFORT, + ressource: R1, + tag: TAG_FERN, + zeit: '10:00', + name: 'Paula Telefon', + email: '', + telefon: '06301 555', + nachricht: '', + zusatz: {}, + bestaetigungSenden: false, + ...over, +}) + +let sink +let log +/** Die Einstellung wird gespeichert, damit die Token-Funktionen (kontextPerFeature) sie finden. */ +async function speichere(e = einstellung()) { + resetKontextForTests() + const doc = aw.docs('termineConfig').get(FEATURE) + await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT }) + return ctxMit(e) +} + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + resetKontextForTests() + resetOutboxForTests() + resetTermineLimitsForTests() + config.termine.mode = 'pilot' + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('portalFeatures', feature()) + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' }) + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.termine.mode = MODUS_VORHER + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +const buchungen = () => [...aw.docs('termineBookings').values()] +const buchung = (id) => aw.docs('termineBookings').get(id) +const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) +const eintraege = () => [...aw.docs('termineOutbox').values()] +const arten = () => eintraege().map((d) => d.art).sort() +const entfalte = (ics) => ics.replace(/\r\n /g, '') +const claimsStand = () => JSON.stringify([...aw.docs('termineClaims').entries()]) +const an = (liste, adresse) => liste.filter((m) => m.to.some((t) => t.address === adresse)) +const zeitenIso = (zellen) => zellen.map((c) => iso(c.zelle)).sort() +const linkIn = (mail) => /termin\.html#m=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1] + +/** Fehler eines Aufrufs (status, fehler, code); schlaegt fehl, wenn nichts geworfen wird. */ +async function fehlerVon(fn) { + try { + await fn() + } catch (err) { + return err + } + assert.fail('es wurde kein Fehler geworfen') +} + +/** Haelt und bestaetigt einen Termin; => { id, buchung (frisch gelesen), verwaltung (Verwaltungstoken), token, mailStart }. */ +async function gebucht(ctx, over = {}, now = JETZT) { + await warteAufVersand() // Mails frueherer Aufrufe sind raus, bevor der Index der neuen gilt + const vorher = sink.sent.length + const erg = await halte(ctx, eingabe(over), now) + await warteAufVersand() + assert.equal(erg.status, 'gehalten') + const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(sink.sent[vorher].text)[1] + const gehalten = buchungen().find((b) => b.verifyHash === hashToken(token)) + assert.ok(gehalten, 'Buchung zum Token') + const mailStart = sink.sent.length + await bestaetige(token, now) + await warteAufVersand() + return { id: gehalten.$id, buchung: buchung(gehalten.$id), verwaltung: verwaltungsToken(gehalten.$id), token, mailStart } +} + +/** Fehler im Postausgang simulieren: jede neue Mail scheitert beim Einreihen (Appwrite 500). */ +async function mitDefektemPostausgang(fn) { + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/termineOutbox\/documents/.test(String(url)) && optionen?.method === 'POST') { + return new Response(JSON.stringify({ message: 'Server error', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } }) + } + return echt(url, optionen) + } + try { + return await fn() + } finally { + globalThis.fetch = echt + } +} + +// ---------------------------------------------------------------- annehmen + +test('annehmen: angefragt => bestaetigt mit angenommenAt, G02 (Block anfrage_angenommen) mit Einladung und demselben Verwaltungslink wie G03', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE }) + assert.equal(buchung(id).status, 'angefragt') + const g03 = an(sink.sent, 'max@example.com').find((m) => /Terminanfrage/.test(m.subject)) + assert.equal(linkIn(g03), verwaltung) + const standZellen = claimsStand() + const vorher = sink.sent.length + + const jetzt = plus(JETZT, 60 * MIN) + assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, jetzt), { status: 'bestaetigt' }) + await warteAufVersand() + + const nachher = buchung(id) + assert.equal(nachher.status, 'bestaetigt') + assert.equal(iso(nachher.angenommenAt), iso(jetzt)) + assert.equal(nachher.bestaetigtAt, undefined, 'der Vertrag kommt bei einer Anfrage erst mit der Annahme zustande') + assert.equal(iso(nachher.updatedAt), iso(jetzt)) + assert.equal(claimsStand(), standZellen, 'die Zellen bleiben') + assert.equal(kannWiderrufen(nachher, plus(jetzt, 2 * 24 * 60 * MIN)), true, 'die Widerrufsfrist laeuft ab der Annahme') + + const neu = sink.sent.slice(vorher) + assert.equal(neu.length, 1, 'nur G02, kein K20/K21') + const g02 = neu[0] + assert.equal(g02.to[0].address, 'max@example.com') + assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026') + assert.match(g02.text, /Salon Müller hat Ihre Terminanfrage angenommen/) + assert.equal(linkIn(g02), verwaltung, 'derselbe Verwaltungslink wie in G03') + assert.equal(g02.icalEvent.method, 'REQUEST') + const ics = entfalte(g02.icalEvent.content) + assert.match(ics, /SEQUENCE:0\r\n/) + assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`)) + assert.deepEqual(arten(), ['G01', 'G02', 'G03', 'K20']) + + // der Link aus der Mail funktioniert: Verwaltung zeigt den bestaetigten Termin + assert.equal((await vorschau(verwaltung, jetzt)).status, 'bestaetigt') +}) + +test('annehmen: zweiter Klick meldet schon; nur Anfragen; eine Anfrage mit vergangenem Beginn nicht; nichts wird gesendet', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + await aktion(ctx, id, { aktion: 'annehmen' }, JETZT) + await warteAufVersand() + const mails = sink.sent.length + assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, plus(JETZT, MIN)), { status: 'bestaetigt', schon: true }) + await warteAufVersand() + assert.equal(sink.sent.length, mails) + + // eine sofort gebuchte Leistung ist keine Anfrage + const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' }) + const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'annehmen' }, JETZT)) + assert.equal(err.status, 409) + assert.equal(err.code, 'nicht_moeglich') + assert.deepEqual(err.fehler, { _: 'Diese Anfrage ist nicht mehr offen.' }) + + // Beginn schon vorbei (der Minutenjob hat die Anfrage noch nicht abgelehnt) + const alt = await gebucht(ctx, { leistung: S_ANFRAGE, email: 'drei@example.com', zeit: '14:00' }) + const spaet = plus(new Date(alt.buchung.startsAt), MIN) + const vorbei = await fehlerVon(() => aktion(ctx, alt.id, { aktion: 'annehmen' }, spaet)) + assert.equal(vorbei.status, 409) + assert.equal(vorbei.code, 'vergangen') + assert.equal(buchung(alt.id).status, 'angefragt') +}) + +// ---------------------------------------------------------------- ablehnen + +test('ablehnen: abgelehnt, Zellen frei, G04 mit der Nachricht des Betriebs; die Zeit ist wieder buchbar', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + assert.equal(claims(id).length, 12) + const vorher = sink.sent.length + + const jetzt = plus(JETZT, 30 * MIN) + assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, jetzt), { status: 'abgelehnt' }) + await warteAufVersand() + + assert.equal(buchung(id).status, 'abgelehnt') + assert.equal(iso(buchung(id).updatedAt), iso(jetzt)) + assert.equal(claims(id).length, 0) + const neu = sink.sent.slice(vorher) + assert.equal(neu.length, 1) + assert.equal(neu[0].to[0].address, 'max@example.com') + assert.equal(neu[0].subject, 'Ihre Terminanfrage bei Salon Müller') + assert.match(neu[0].text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/) + assert.match(neu[0].text, /Am Montag sind wir im Urlaub\./) + assert.equal('icalEvent' in neu[0], false) + + assert.equal((await halte(ctx, eingabe({ leistung: S_ANFRAGE, email: 'zwei@example.com' }), jetzt)).status, 'gehalten') + assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen' }, plus(jetzt, MIN)), { status: 'abgelehnt', schon: true }) +}) + +test('ablehnen: ohne Nachricht kein Nachrichtenblock; bestaetigte Termine lassen sich nicht ablehnen', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + const vorher = sink.sent.length + await aktion(ctx, id, { aktion: 'ablehnen' }, JETZT) + await warteAufVersand() + assert.doesNotMatch(sink.sent[vorher].text, /Nachricht von/i) + + const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' }) + const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'ablehnen' }, JETZT)) + assert.equal(err.code, 'nicht_moeglich') + assert.equal(buchung(sofort.id).status, 'bestaetigt') + assert.equal(claims(sofort.id).length, 10) +}) + +// ---------------------------------------------------------------- absagen + +test('absagen: abgesagt von betrieb mit Grund, Zellen frei, G06 mit Betriebsname, Grund, Nachricht und Kalenderabsage; kein K21; keine Frist', async () => { + const ctx = await speichere() + const { id, buchung: b, mailStart } = await gebucht(ctx) + assert.equal(claims(id).length, 10) + + // eine Stunde vor dem Termin: der Gast koennte nicht mehr absagen, der Betrieb schon + const jetzt = plus(new Date(b.startsAt), -60 * MIN) + assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, jetzt), { status: 'abgesagt' }) + await warteAufVersand() + + const nachher = buchung(id) + assert.equal(nachher.status, 'abgesagt') + assert.equal(nachher.abgesagtVon, 'betrieb') + assert.equal(nachher.absageGrund, 'Krankheit') + assert.equal(iso(nachher.abgesagtAt), iso(jetzt)) + assert.equal(nachher.widerrufenAt, undefined) + assert.equal(claims(id).length, 0) + + const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen + assert.equal(neu.length, 1, 'nur G06 an den Gast, kein K21') + const g06 = neu[0] + assert.equal(g06.to[0].address, 'max@example.com') + assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') + assert.match(g06.text, /Salon Müller/) + assert.doesNotMatch(g06.text, /Sie selbst/) + assert.match(g06.text, /Krankheit/) + assert.match(g06.text, /Gute Besserung wuenschen wir\./) + assert.equal(g06.icalEvent.method, 'CANCEL') + assert.match(entfalte(g06.icalEvent.content), /SEQUENCE:1\r\n/) + assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20']) + + assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, MIN)), { status: 'abgesagt', schon: true }) +}) + +test('absagen: Grund ist Pflicht (400 Feld grund), zu lange Texte werden abgelehnt, nichts aendert sich', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + const standZellen = claimsStand() + const vorher = sink.sent.length + for (const [feld, roh] of [ + ['grund', { aktion: 'absagen' }], + ['grund', { aktion: 'absagen', grund: ' ' }], + ['grund', { aktion: 'absagen', grund: 7 }], + ['grund', { aktion: 'absagen', grund: 'x'.repeat(101) }], + ['nachricht', { aktion: 'absagen', grund: 'Krankheit', nachricht: 'y'.repeat(501) }], + ]) { + const err = await fehlerVon(() => aktion(ctx, id, roh, JETZT)) + assert.equal(err.status, 400, JSON.stringify(roh).slice(0, 60)) + assert.deepEqual(Object.keys(err.fehler), [feld]) + } + assert.equal(buchung(id).status, 'bestaetigt') + assert.equal(claimsStand(), standZellen) + await warteAufVersand() + assert.equal(sink.sent.length, vorher) +}) + +test('absagen: eine Anfrage (nie eine Einladung erhalten) bekommt G06 ohne Kalenderdatei (R21); erledigte Termine lassen sich nicht absagen', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + const vorher = sink.sent.length + await aktion(ctx, id, { aktion: 'absagen', grund: 'Betriebsstoerung' }, JETZT) + await warteAufVersand() + const g06 = sink.sent.slice(vorher)[0] + assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') + assert.equal('icalEvent' in g06, false) + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(claims(id).length, 0) + + const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' }) + aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' }) + const err = await fehlerVon(() => aktion(ctx, zwei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)) + assert.equal(err.status, 409) + assert.equal(err.code, 'nicht_moeglich') +}) + +// ---------------------------------------------------------------- verschieben + +test('verschieben: wie beim Gast (10:00 -> 10:30 ueberlappend), Sequenz + 1, neue Zellen, G07 mit Einladung, kein K21', async () => { + const ctx = await speichere() + const { id, buchung: alt, mailStart } = await gebucht(ctx) + const altZellen = zeitenIso(claims(id)) + + const jetzt = plus(JETZT, 60 * MIN) + const erg = await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:30' }, jetzt) + await warteAufVersand() + assert.equal(erg.status, 'bestaetigt') + assert.match(erg.termin.datum, /26\.10\.2026/) + assert.equal(erg.termin.zeit, '10:30') + + const nachher = buchung(id) + assert.equal(iso(nachher.startsAt), '2026-10-26T09:30:00.000Z') + assert.equal(iso(nachher.endsAt), '2026-10-26T10:15:00.000Z') + assert.equal(iso(nachher.blockEndsAt), '2026-10-26T10:20:00.000Z') + assert.equal(nachher.icsSequenz, alt.icsSequenz + 1) + assert.equal(nachher.status, 'bestaetigt') + const neuZellen = zeitenIso(zellenFuer(nachher.startsAt, nachher.blockEndsAt).map((z) => ({ zelle: z }))) + assert.deepEqual(zeitenIso(claims(id)), neuZellen) + assert.notDeepEqual(zeitenIso(claims(id)), altZellen) + + const neu = sink.sent.slice(mailStart + 2) + assert.equal(neu.length, 1) + assert.equal(neu[0].subject, 'Ihr Termin wurde verschoben') + assert.equal(neu[0].icalEvent.method, 'REQUEST') + assert.match(entfalte(neu[0].icalEvent.content), /SEQUENCE:1\r\n/) + assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20']) +}) + +test('verschieben: der Betrieb darf in den Vorlauf, ueber den Horizont und in die Widerrufsfrist, der Gast nicht', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) // TAG_FERN ohne Zustimmung zum vorzeitigen Beginn + const zurueck = () => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:00' }, JETZT) + + // Vorlauf (120 Minuten): heute 11:00 + const vorlauf = await fehlerVon(() => verschiebeGast(verwaltung, { tag: '2026-10-05', zeit: '11:00' }, JETZT)) + assert.equal(vorlauf.code, 'SLOT_VERGEBEN') + await aktion(ctx, id, { aktion: 'verschieben', tag: '2026-10-05', zeit: '11:00' }, JETZT) + assert.equal(iso(buchung(id).startsAt), '2026-10-05T09:00:00.000Z') + await zurueck() + + // Horizont (90 Tage) + const weit = '2027-02-01' + assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: weit, zeit: '10:00' }, JETZT))).code, 'SLOT_VERGEBEN') + await aktion(ctx, id, { aktion: 'verschieben', tag: weit, zeit: '10:00' }, JETZT) + assert.equal(iso(buchung(id).startsAt), '2027-02-01T09:00:00.000Z') + await zurueck() + + // Termin innerhalb der Widerrufsfrist ohne Zustimmung zum vorzeitigen Beginn + assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_NAH, zeit: '10:00' }, JETZT))).code, 'zustimmung_noetig') + await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_NAH, zeit: '10:00' }, JETZT) + assert.equal(iso(buchung(id).startsAt), '2026-10-12T08:00:00.000Z') + assert.equal(claims(id).length, 10) +}) + +test('verschieben: nie in die Vergangenheit, nie auf belegte Zellen, nie auf dieselbe Zeit; alles bleibt wie es war', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + await gebucht(ctx, { email: 'zwei@example.com', zeit: '14:00' }) // R1 14:00-14:50 + const standZellen = claimsStand() + const stand = JSON.stringify(buchung(id)) + const vorher = sink.sent.length + + for (const [roh, status, feld] of [ + [{ tag: '2026-10-05', zeit: '09:00' }, 409, 'zeit'], // heute, vor jetzt (10:00) + [{ tag: '2026-10-04', zeit: '10:00' }, 409, 'zeit'], // gestern + [{ tag: TAG_FERN, zeit: '14:15' }, 409, 'zeit'], // ueberlappt den Termin um 14:00 + [{ tag: TAG_FERN, zeit: '08:00' }, 409, 'zeit'], // vor der Oeffnung + [{ tag: '2026-10-27', zeit: '10:00' }, 409, 'zeit'], // Dienstag, kein Plan + [{ tag: TAG_FERN, zeit: '10:00' }, 400, 'zeit'], // dieselbe Zeit + [{ tag: 'kaputt', zeit: '10:00' }, 400, 'tag'], + [{ tag: TAG_FERN, zeit: '25:00' }, 400, 'zeit'], + ]) { + const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', ...roh }, JETZT)) + assert.equal(err.status, status, JSON.stringify(roh)) + assert.deepEqual(Object.keys(err.fehler), [feld], JSON.stringify(roh)) + } + assert.equal(JSON.stringify(buchung(id)), stand) + assert.equal(claimsStand(), standZellen) + await warteAufVersand() + assert.equal(sink.sent.length, vorher) +}) + +test('verschieben: eine Anfrage bekommt G07 ohne Kalenderdatei (R21), ein abgesagter Termin laesst sich nicht verschieben', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + const vorher = sink.sent.length + await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '11:00' }, JETZT) + await warteAufVersand() + const g07 = sink.sent.slice(vorher)[0] + assert.equal(g07.subject, 'Ihr Termin wurde verschoben') + assert.equal('icalEvent' in g07, false) + assert.equal(buchung(id).status, 'angefragt') + + await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT) + const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '12:00' }, JETZT)) + assert.equal(err.code, 'nicht_moeglich') +}) + +// ---------------------------------------------------------------- nichterschienen, anonymisieren + +test('nichterschienen: vor Beginn 409, danach nichterschienen (Zellen frei); zweiter Klick meldet schon', async () => { + const ctx = await speichere() + const { id, buchung: b } = await gebucht(ctx) + const standZellen = claimsStand() + + const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, JETZT)) + assert.equal(err.status, 409) + assert.equal(err.code, 'vor_beginn') + assert.deepEqual(err.fehler, { _: 'Das geht erst, wenn der Termin begonnen hat.' }) + assert.equal(buchung(id).status, 'bestaetigt') + assert.equal(claimsStand(), standZellen) + assert.equal((await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, plus(new Date(b.startsAt), -1)))).code, 'vor_beginn') + + const beginn = new Date(b.startsAt) + assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, beginn), { status: 'nichterschienen' }) + assert.equal(buchung(id).status, 'nichterschienen') + assert.equal(claims(id).length, 0) + assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, plus(beginn, MIN)), { status: 'nichterschienen', schon: true }) + + // auch nach dem automatischen Erledigen noch moeglich; abgesagte Termine nicht + const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' }) + aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' }) + assert.deepEqual(await aktion(ctx, zwei.id, { aktion: 'nichterschienen' }, plus(new Date(zwei.buchung.endsAt), 90 * MIN)), { status: 'nichterschienen' }) + const drei = await gebucht(ctx, { email: 'drei@example.com', zeit: '14:00' }) + await aktion(ctx, drei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT) + assert.equal((await fehlerVon(() => aktion(ctx, drei.id, { aktion: 'nichterschienen' }, plus(new Date(drei.buchung.startsAt), MIN)))).code, 'nicht_moeglich') +}) + +test('anonymisieren: Gastfelder leer ("Gelöscht"), Status und Zeiten bleiben, Links ungueltig, keine Erinnerung, keine Mail', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true, nachricht: 'Bitte ohne Foehn.', zusatz: { marke: 'Wella' } }) + assert.equal(b.erinnerung, true) + const vorher = sink.sent.length + + const jetzt = plus(JETZT, 5 * 24 * 60 * MIN) + assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, jetzt), { status: 'bestaetigt' }) + await warteAufVersand() + const nachher = buchung(id) + assert.equal(nachher.gastName, 'Gelöscht') + for (const feld of ['gastEmail', 'gastTelefon', 'gastNachricht', 'gastZusatz', 'verifyHash', 'manageHash']) assert.equal(nachher[feld], '', feld) + assert.equal(nachher.erinnerung, false) + assert.equal(nachher.status, 'bestaetigt') + assert.equal(iso(nachher.startsAt), iso(b.startsAt)) + assert.equal(iso(nachher.endsAt), iso(b.endsAt)) + assert.equal(claims(id).length, 10) + assert.equal(sink.sent.length, vorher) + assert.equal((await fehlerVon(() => vorschau(verwaltung, jetzt))).status, 404, 'der alte Link gilt nicht mehr') + + // zweiter Aufruf aendert nichts; ohne Adresse gehen weitere Aktionen ohne Mail durch + assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(jetzt, MIN)), { status: 'bestaetigt' }) + await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, 2 * MIN)) + await warteAufVersand() + assert.equal(sink.sent.length, vorher, 'ohne Adresse keine G06') + assert.equal(log.error.mock.callCount(), 0) +}) + +test('anonymisieren: bei abgesagten Terminen bleibt updatedAt (die Loeschfrist laeuft weiter); eine Haltung bleibt unberuehrt', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT) + const abgesagtUm = iso(buchung(id).updatedAt) + await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 10 * 24 * 60 * MIN)) + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(buchung(id).gastName, 'Gelöscht') + assert.equal(iso(buchung(id).updatedAt), abgesagtUm) + + await halte(ctx, eingabe({ email: 'zwei@example.com', zeit: '12:00' }), JETZT) + const gehalten = buchungen().find((b) => b.status === 'gehalten') + const err = await fehlerVon(() => aktion(ctx, gehalten.$id, { aktion: 'anonymisieren' }, JETZT)) + assert.equal(err.code, 'nicht_moeglich') + assert.equal(buchung(gehalten.$id).gastEmail, 'zwei@example.com') +}) + +// ---------------------------------------------------------------- aktion allgemein + +test('aktion: fremde Funktion, Sperren, unbekannte Nummern und kaputte Eingaben aendern nichts', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + const standZellen = claimsStand() + const stand = JSON.stringify(buchung(id)) + + const fremd = ctxMit(einstellung(), 'f_andere_funktion') + const err = await fehlerVon(() => aktion(fremd, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)) + assert.equal(err.status, 404) + assert.equal(err.code, 'unbekannt') + assert.deepEqual(err.fehler, { _: 'Diesen Termin gibt es nicht.' }) + for (const falsch of ['gibt-es-nicht', '', null, undefined, 42, '../x', 'a'.repeat(40)]) { + assert.equal((await fehlerVon(() => aktion(ctx, falsch, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, String(falsch)) + } + + for (const roh of [undefined, null, 'absagen', [], {}, { aktion: 'loeschen' }, { aktion: 7 }]) { + const e = await fehlerVon(() => aktion(ctx, id, roh, JETZT)) + assert.equal(e.status, 400, JSON.stringify(roh)) + } + assert.equal(JSON.stringify(buchung(id)), stand) + assert.equal(claimsStand(), standZellen) + + const s = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '10:00', titel: 'Pause' }, JETZT) + assert.equal((await fehlerVon(() => aktion(ctx, s.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, 'eine Sperre ist keine Buchung') + assert.equal(buchung(s.id).status, 'bestaetigt') + + await assert.rejects(() => aktion(ctx, id, { aktion: 'anonymisieren' }, undefined), TypeError) + await assert.rejects(() => aktion({ feature: ctx.feature }, id, { aktion: 'anonymisieren' }, JETZT), TypeError) +}) + +test('aktion: scheitert die Mail beim Einreihen, bleibt die Aenderung bestehen und der Fehler steht im Log', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + const erg = await mitDefektemPostausgang(() => aktion(ctx, id, { aktion: 'annehmen' }, JETZT)) + assert.deepEqual(erg, { status: 'bestaetigt' }) + assert.equal(buchung(id).status, 'bestaetigt') + assert.ok(log.error.mock.calls.some((c) => /G02 nicht eingereiht/.test(String(c.arguments[0])))) + assert.ok(!log.error.mock.calls.some((c) => /max@example\.com/.test(String(c.arguments[0]))), 'keine Adresse im Log') +}) + +test('aktion: parallele Aktionen auf dieselbe Buchung ergeben genau einen Endzustand', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx, { leistung: S_ANFRAGE }) + const erg = await Promise.allSettled([ + aktion(ctx, id, { aktion: 'annehmen' }, JETZT), + aktion(ctx, id, { aktion: 'ablehnen' }, JETZT), + aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT), + ]) + await warteAufVersand() + const gut = erg.filter((e) => e.status === 'fulfilled' && !e.value.schon) + assert.equal(gut.length >= 1, true) + const endzustand = buchung(id).status + assert.ok(['bestaetigt', 'abgelehnt', 'abgesagt'].includes(endzustand)) + // genau die Mail zum Endzustand plus hoechstens die der Annahme, wenn danach abgesagt wurde + const gast = arten().filter((a) => ['G02', 'G04', 'G06'].includes(a)) + assert.ok(gast.length >= 1 && gast.length <= 2, gast.join(',')) + if (endzustand === 'abgelehnt') assert.deepEqual(gast, ['G04']) + if (endzustand !== 'bestaetigt') assert.equal(claims(id).length, 0) +}) + +// ---------------------------------------------------------------- manuell + +test('manuell: quelle manuell, bestaetigt, kein Verbraucher, Zellen, ohne Haken keine Mail', async () => { + const ctx = await speichere() + const erg = await manuell(ctx, handEingabe({ email: 'paula@example.com', zusatz: { marke: 'Schwarzkopf' } }), JETZT) + await warteAufVersand() + assert.equal(erg.status, 'bestaetigt') + assert.equal(erg.nummer, buchungsNummer(erg.id)) + + const b = buchung(erg.id) + assert.equal(b.quelle, 'manuell') + assert.equal(b.status, 'bestaetigt') + assert.equal(b.verbraucher, false) + assert.equal(b.featureId, FEATURE) + assert.equal(b.customerId, KUNDE) + assert.equal(b.projectId, PROJEKT) + assert.equal(b.leistungId, S_SOFORT) + assert.equal(b.leistungName, 'Damenschnitt') + assert.equal(b.ressourceId, R1) + assert.equal(b.ressourceName, 'Anna') + assert.equal(iso(b.startsAt), '2026-10-26T09:00:00.000Z') + assert.equal(iso(b.endsAt), '2026-10-26T09:45:00.000Z') + assert.equal(iso(b.blockEndsAt), '2026-10-26T09:50:00.000Z') + assert.equal(b.gastName, 'Paula Telefon') + assert.equal(b.gastEmail, 'paula@example.com') + assert.equal(b.gastTelefon, '06301 555') + assert.deepEqual(JSON.parse(b.gastZusatz), { marke: 'Schwarzkopf' }) + assert.equal(b.erinnerung, false) + assert.equal(iso(b.bestaetigtAt), iso(JETZT)) + assert.equal(b.manageHash, hashToken(verwaltungsToken(erg.id))) + assert.equal(b.icsSequenz, 0) + assert.equal(b.preisCent, 4500) + assert.equal(claims(erg.id).length, 10) + assert.equal(kannWiderrufen(b, JETZT), false, 'ein Handeintrag hat kein Widerrufsrecht') + assert.equal(sink.sent.length, 0, 'ohne Haken keine Gastmail (und kein K20 an den Betrieb)') + assert.equal(eintraege().length, 0) + + // die Zeit ist belegt + assert.equal((await fehlerVon(() => halte(ctx, eingabe({ email: 'zwei@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT))).code, 'SLOT_VERGEBEN') +}) + +test('manuell: mit Haken und Adresse geht G02 mit Einladung und funktionierendem Verwaltungslink, ohne Widerrufsbelehrung', async () => { + const ctx = await speichere() + const erg = await manuell(ctx, handEingabe({ email: ' Paula@Example.com ', bestaetigungSenden: true }), JETZT) + await warteAufVersand() + assert.equal(buchung(erg.id).gastEmail, 'paula@example.com') + assert.equal(sink.sent.length, 1) + const g02 = sink.sent[0] + assert.equal(g02.to[0].address, 'paula@example.com') + assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026') + assert.doesNotMatch(g02.text, /angenommen/) + assert.doesNotMatch(g02.text, /Widerruf/) + assert.equal(g02.icalEvent.method, 'REQUEST') + assert.match(entfalte(g02.icalEvent.content), /SEQUENCE:0\r\n/) + assert.deepEqual(arten(), ['G02']) + const link = linkIn(g02) + assert.equal(link, verwaltungsToken(erg.id)) + const v = await vorschau(link, JETZT) + assert.equal(v.art, 'verwalten') + assert.equal(v.aktionen.widerrufen, false) +}) + +test('manuell: Haken ohne Adresse ist kein Fehler, es geht nur keine Mail raus', async () => { + const ctx = await speichere() + const erg = await manuell(ctx, handEingabe({ bestaetigungSenden: true }), JETZT) + await warteAufVersand() + assert.equal(buchung(erg.id).gastEmail, '') + assert.equal(sink.sent.length, 0) + assert.equal(log.error.mock.callCount(), 0) + // ohne Adresse gehen auch spaetere Aktionen ohne Mail durch + await aktion(ctx, erg.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 0) + assert.equal(buchung(erg.id).status, 'abgesagt') +}) + +test('manuell: Pruefung wie beim Gast, aber ohne Zustimmungen; Adresse freiwillig, aber wenn angegeben gueltig', async () => { + const ctx = await speichere() + // Storno-Haken und Zustimmung zum vorzeitigen Beginn entfallen (Termin in 7 Tagen, mit Preis, Verbraucher-Betrieb) + assert.equal((await manuell(ctx, handEingabe({ tag: TAG_NAH }), JETZT)).status, 'bestaetigt') + + for (const [feld, over] of [ + ['name', { name: ' ' }], + ['name', { name: 'N'.repeat(101) }], + ['email', { email: 'kein-postfach' }], + ['email', { email: 'a@example.com, b@example.com' }], + ['email', { email: 5 }], + ['leistung', { leistung: 's_gibtesnicht' }], + ['ressource', { ressource: 'r_gibtesnicht' }], + ['tag', { tag: '2026-02-30' }], + ['zeit', { zeit: '25:61' }], + ['telefon', { telefon: 'abc' }], + ['nachricht', { nachricht: 'n'.repeat(501) }], + ['zusatz', { zusatz: { unbekannt: 'x' } }], + ['bestaetigungSenden', { bestaetigungSenden: 'ja' }], + ]) { + const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '15:00', ...over }), JETZT)) + assert.equal(err.status, 400, feld) + assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`) + } + for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => manuell(ctx, roh, JETZT))).status, 400) + assert.equal(buchungen().length, 1, 'nur der gueltige Eintrag') + await assert.rejects(() => manuell(ctx, handEingabe(), undefined), TypeError) + await assert.rejects(() => manuell({ feature: ctx.feature }, handEingabe(), JETZT), TypeError) +}) + +test('manuell: ohne Vorlauf und Horizont, nie in der Vergangenheit, nie ausserhalb der Oeffnung, nie auf belegten Zellen', async () => { + const ctx = await speichere() + // Vorlauf (120 Minuten): heute 11:00 geht; Horizont (90 Tage): Februar geht + assert.equal((await manuell(ctx, handEingabe({ tag: '2026-10-05', zeit: '11:00' }), JETZT)).status, 'bestaetigt') + assert.equal((await manuell(ctx, handEingabe({ tag: '2027-02-01', zeit: '10:00' }), JETZT)).status, 'bestaetigt') + + const vorher = buchungen().length + const standZellen = claimsStand() + for (const over of [ + { tag: '2026-10-05', zeit: '09:00' }, // vergangen + { tag: '2026-10-04', zeit: '10:00' }, // gestern + { tag: '2026-10-27', zeit: '10:00' }, // Dienstag, kein Plan + { tag: TAG_FERN, zeit: '08:45' }, // vor der Oeffnung + { tag: TAG_FERN, zeit: '10:07' }, // nicht im Raster + { tag: '2026-10-05', zeit: '11:15' }, // ueberlappt den Termin um 11:00 + ]) { + const err = await fehlerVon(() => manuell(ctx, handEingabe(over), JETZT)) + assert.equal(err.status, 409, JSON.stringify(over)) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.deepEqual(Object.keys(err.fehler), ['zeit']) + } + assert.equal(buchungen().length, vorher) + assert.equal(claimsStand(), standZellen) +}) + +test('manuell: "egal" waehlt die Person mit den wenigsten Terminen des Tages', async () => { + const ctx = await speichere() + const a = await manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT) // R1 + assert.equal(buchung(a.id).ressourceId, R1) + const b = await manuell(ctx, handEingabe({ ressource: 'egal', zeit: '10:00' }), JETZT) + assert.equal(buchung(b.id).ressourceId, R2) + assert.equal(buchung(b.id).ressourceName, 'Ben') +}) + +test('manuell: ein Konflikt beim Belegen der Zellen (409) raeumt die Buchung wieder weg', async () => { + const ctx = await speichere() + // Zelle um 10:00 gehoert einer fremden, bestaetigten Buchung, die der Tagesabruf nicht sieht (anderer Tag in der Datenbank) + aw.seed('termineBookings', { + $id: 'fremd-0001', + featureId: FEATURE, + ressourceId: R1, + status: 'bestaetigt', + startsAt: '2026-12-01T09:00:00.000Z', + endsAt: '2026-12-01T09:45:00.000Z', + blockEndsAt: '2026-12-01T09:50:00.000Z', + }) + aw.seed('termineClaims', { + $id: zellenId(FEATURE, R1, '2026-10-26T09:00:00.000Z'), + featureId: FEATURE, + ressourceId: R1, + bookingId: 'fremd-0001', + zelle: '2026-10-26T09:00:00.000Z', + bis: '2026-10-26T09:50:00.000Z', + }) + const err = await fehlerVon(() => manuell(ctx, handEingabe(), JETZT)) + assert.equal(err.status, 409) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.deepEqual(buchungen().map((b) => b.$id), ['fremd-0001'], 'keine Reste der gescheiterten Buchung') + assert.equal(aw.docs('termineClaims').size, 1) +}) + +test('manuell: eine Belegung ueber 500 Minuten wird abgelehnt (400 Feld leistung), bevor etwas angelegt wird', async () => { + const e = einstellung() + const lang = { ...e, leistungen: e.leistungen.map((l) => (l.id === S_SOFORT ? { ...l, dauer: 480, pufferNach: 25 } : l)) } + const ctx = ctxMit(lang) + const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT)) + assert.equal(err.status, 400) + assert.deepEqual(Object.keys(err.fehler), ['leistung']) + assert.equal(buchungen().length, 0) +}) + +// ---------------------------------------------------------------- Sperren + +test('sperre: 09:00 bis 17:30 sind 510 Minuten => 400 Feld bis mit dem Hinweis auf Abwesenheiten; 500 Minuten gehen', async () => { + const ctx = await speichere() + const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:30', titel: 'Fortbildung' }, JETZT)) + assert.equal(err.status, 400) + assert.deepEqual(err.fehler, { bis: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.' }) + assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:25' }, JETZT))).status, 400) + assert.equal(buchungen().length, 0) + assert.equal(aw.docs('termineClaims').size, 0) + + const ok = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:20', titel: 'Fortbildung' }, JETZT) + assert.equal(ok.status, 'bestaetigt') + assert.equal(claims(ok.id).length, 100) +}) + +test('sperre: 09:00 bis 13:00 ist eine bestaetigte Buchung ohne Gast und blockiert die Slots dieser Ressource', async () => { + const ctx = await speichere() + const erg = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT) + await warteAufVersand() + const b = buchung(erg.id) + assert.equal(b.quelle, 'sperre') + assert.equal(b.status, 'bestaetigt') + assert.equal(b.leistungName, 'Fortbildung') + assert.equal(b.ressourceId, R1) + assert.equal(b.ressourceName, 'Anna') + assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z') + assert.equal(iso(b.endsAt), '2026-10-26T12:00:00.000Z') + assert.equal(iso(b.blockEndsAt), '2026-10-26T12:00:00.000Z') + assert.equal(b.gastName, undefined) + assert.equal(b.gastEmail, undefined) + assert.equal(b.verbraucher, false) + assert.equal(claims(erg.id).length, 48) + assert.equal(eintraege().length, 0, 'eine Sperre schickt keine Mail') + + // R1 ist gesperrt, R2 nicht; um 13:00 ist R1 wieder frei + for (const zeit of ['09:00', '10:00', '12:00']) { + const err = await fehlerVon(() => halte(ctx, eingabe({ zeit, email: `x${zeit.slice(0, 2)}@example.com` }), JETZT)) + assert.equal(err.code, 'SLOT_VERGEBEN', zeit) + } + assert.equal((await halte(ctx, eingabe({ zeit: '10:00', ressource: R2, email: 'ben@example.com' }), JETZT)).status, 'gehalten') + assert.equal((await halte(ctx, eingabe({ zeit: '13:00', email: 'dreizehn@example.com' }), JETZT)).status, 'gehalten') + // "egal" weicht auf die freie Person aus + assert.equal((await halte(ctx, eingabe({ zeit: '11:00', ressource: 'egal', email: 'egal@example.com' }), JETZT)).status, 'gehalten') + assert.equal(buchungen().find((x) => x.gastEmail === 'egal@example.com').ressourceId, R2) +}) + +test('sperre: Titel (Vorgabe "Gesperrt"), Ressource, Tag und Uhrzeiten in 5-Minuten-Schritten werden geprueft', async () => { + const ctx = await speichere() + const gut = { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '10:00' } + const ohneTitel = await sperre(ctx, gut, JETZT) + assert.equal(buchung(ohneTitel.id).leistungName, 'Gesperrt') + const bis24 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '20:00', bis: '24:00', titel: ' Nachtruhe ' }, JETZT) + assert.equal(buchung(bis24.id).leistungName, 'Nachtruhe') + assert.equal(iso(buchung(bis24.id).endsAt), '2026-10-26T23:00:00.000Z') + + const vorher = buchungen().length + for (const [feld, over] of [ + ['ressource', { ressource: 'r_gibtesnicht' }], + ['ressource', { ressource: undefined }], + ['tag', { tag: '2026-13-01' }], + ['von', { von: '09:03' }], + ['von', { von: '9:00' }], + ['von', { von: '24:00' }], + ['bis', { bis: '10:07' }], + ['bis', { bis: undefined }], + ['bis', { von: '10:00', bis: '10:00' }], // Ende nicht nach Beginn + ['bis', { von: '10:00', bis: '09:00' }], + ['bis', { tag: '2026-10-05', von: '08:00', bis: '09:00' }], // endet in der Vergangenheit + ['von', { tag: '2026-03-29', von: '02:30', bis: '04:00' }], // Luecke der Maerz-Umstellung + ['titel', { titel: 't'.repeat(121) }], + ]) { + const err = await fehlerVon(() => sperre(ctx, { ...gut, ...over }, JETZT)) + assert.equal(err.status, 400, feld) + assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`) + } + for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => sperre(ctx, roh, JETZT))).status, 400) + assert.equal(buchungen().length, vorher) + + // eine Sperre darf schon laufen (Beginn vor jetzt, Ende danach) + assert.equal((await sperre(ctx, { ressource: R1, tag: '2026-10-05', von: '09:00', bis: '12:00' }, JETZT)).status, 'bestaetigt') + await assert.rejects(() => sperre(ctx, gut, undefined), TypeError) + await assert.rejects(() => sperre({ feature: ctx.feature }, gut, JETZT), TypeError) +}) + +test('sperre: ueber einem Termin oder einer anderen Sperre 409 (Feld von), ohne Reste; erst absagen, dann sperren', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) // R1 10:00-10:50 + const standZellen = claimsStand() + const vorher = buchungen().length + + const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)) + assert.equal(err.status, 409) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.deepEqual(err.fehler, { von: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.' }) + assert.equal(buchungen().length, vorher) + assert.equal(claimsStand(), standZellen) + + // die andere Person ist frei + const r2 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT) + assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '12:30', bis: '14:00' }, JETZT))).status, 409) + assert.equal(claims(r2.id).length, 48) + + await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT) + assert.equal((await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)).status, 'bestaetigt') +}) + +test('entsperre: loescht die Sperre samt Zellen, die Zeit ist wieder buchbar; nur Sperren der eigenen Funktion', async () => { + const ctx = await speichere() + const s = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT) + const { id } = await gebucht(ctx, { ressource: R2, email: 'ben@example.com' }) + assert.equal(claims(s.id).length, 48) + + // fremde Funktion, normale Buchung, unbekannte und kaputte Nummern + for (const [kontext, bid] of [[ctxMit(einstellung(), 'f_andere_funktion'), s.id], [ctx, id], [ctx, 'gibt-es-nicht'], [ctx, ''], [ctx, null], [ctx, '../x']]) { + const err = await fehlerVon(() => entsperre(kontext, bid)) + assert.equal(err.status, 404, String(bid)) + assert.deepEqual(err.fehler, { _: 'Diese Sperre gibt es nicht.' }) + } + assert.equal(buchung(s.id).status, 'bestaetigt') + assert.equal(buchung(id).status, 'bestaetigt') + + assert.deepEqual(await entsperre(ctx, s.id), { status: 'entsperrt' }) + assert.equal(buchung(s.id), undefined) + assert.equal(claims(s.id).length, 0) + assert.equal(claims(id).length, 10, 'andere Zellen bleiben') + assert.equal((await halte(ctx, eingabe({ zeit: '10:00', email: 'neu@example.com' }), JETZT)).status, 'gehalten') + assert.equal((await fehlerVon(() => entsperre(ctx, s.id))).status, 404) + await assert.rejects(() => entsperre({ feature: ctx.feature }, s.id), TypeError) +}) diff --git a/test/termineAblaufGast.test.js b/test/termineAblaufGast.test.js index a6a56de..959ff56 100644 --- a/test/termineAblaufGast.test.js +++ b/test/termineAblaufGast.test.js @@ -426,6 +426,24 @@ test('halte: fehlt die Zustimmung zum vorzeitigen Beginn (verbindlich, Preis, Ve assert.equal((await halte(unternehmer, eingabe({ tag: TAG_NAH, zeit: '16:00', email: 'c@example.com' }), JETZT)).status, 'gehalten') }) +test('halte: die Zustimmung zum vorzeitigen Beginn gilt bis zum Ende der Berliner Widerrufsfrist (R31), bei einer Anfrage mit Preis immer', async () => { + const ctx = await speichere() + // Buchung am Montag 05.10. (Haltezeit mitgerechnet) ⇒ Fristende Montag 19.10., 23:59:59 Uhr: 14 x 24 Stunden reichen nicht + const err = await fehlerVon(() => halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00' }), JETZT)) + assert.deepEqual(Object.keys(err.fehler), ['vorzeitig']) + assert.equal((await halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00', zustimmungen: { storno: true, vorzeitig: true } }), JETZT)).status, 'gehalten') + // der naechste Tag liegt hinter der Frist + assert.equal((await halte(ctx, eingabe({ tag: '2026-10-26', zeit: '10:00', email: 'a@example.com' }), JETZT)).status, 'gehalten') + + const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] } + const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] })) + const fern = eingabe({ leistung: 's_bbbbbbb4', tag: TAG_FERN, zeit: '10:00', email: 'b@example.com' }) + assert.deepEqual(Object.keys((await fehlerVon(() => halte(mitAnfrage, fern, JETZT))).fehler), ['vorzeitig']) + const ok = await halte(mitAnfrage, { ...fern, zustimmungen: { storno: true, vorzeitig: true } }, JETZT) + assert.equal(ok.status, 'gehalten') + assert.ok(JSON.parse(buchungen().find((b) => b.leistungId === 's_bbbbbbb4').nachweis).vorzeitig) +}) + // ---------------------------------------------------------------- Slot und Doppelbuchung test('halte: eine Zeit ausserhalb der freien Zeiten ist ein 409 mit Feld zeit und legt nichts an', async () => { diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index 2afc2a8..30217e5 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -721,28 +721,56 @@ test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => { assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false) }) -test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn in weniger als 14 Tagen', () => { +test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn bis zum Ende der Widerrufsfrist (Berliner Kalender, §§ 187, 188, 193 BGB)', () => { const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } } const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } } const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } } const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 } const inTagen = (n) => new Date(NOW.getTime() + n * TAG) + // NOW = Freitag 02.10.2026, 12:00 Berlin; Vertragsschluss spaetestens nach der Haltezeit (30 Minuten): Fristende + // Freitag 16.10.2026, 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC (MESZ) + const FRISTENDE = Date.parse('2026-10-16T21:59:59.999Z') assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true) - assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false) - assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), false) - assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(inTagen(14).getTime() - 1), NOW), true) assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false) + // Grenze: die letzte Millisekunde der Frist ja, die naechste nein + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE + 1), NOW), false) + // 14 mal 24 Stunden reichen nicht: 14 Tage nach der Buchung um 12:00 liegen noch in der Frist (bis 24 Uhr des Tages) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), true) assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true) - // Unternehmer, Freizeit, kein Preis, Anfrage: keine Zustimmung noetig + // Fristende auf einem Wochenende ⇒ naechster Werktag: Samstag 10.10.2026 + 14 = Samstag 24.10. ⇒ Montag 26.10., 23:59:59,999 Uhr Berlin (MEZ) + const SAMSTAG = new Date('2026-10-10T10:00:00.000Z') + const wochenendeEnde = Date.parse('2026-10-26T22:59:59.999Z') + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde), SAMSTAG), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde + 1), SAMSTAG), false) + // die Haltezeit zaehlt mit: wer um 23:45 Uhr bucht, schliesst den Vertrag erst nach Mitternacht (Fristende einen Tag spaeter) + // (Samstag 03.10. + 14 = Samstag 17.10. ⇒ Montag 19.10., 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC) + const spaet = new Date('2026-10-02T21:45:00.000Z') // 23:45 Uhr Berlin, Freitag; + 30 Minuten = Samstag 00:15 Uhr + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-16T21:59:59.999Z') + 1), spaet), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-19T21:59:59.999Z') + 1), spaet), false) + // Unternehmer, Freizeit, kein Preis: keine Zustimmung noetig assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false) assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false) assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false) - assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, buchungsart: 'anfrage' }, inTagen(3), NOW), false) // ungueltiger Beginn: lieber die Zustimmung verlangen assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true) }) +test('brauchtZustimmungVorzeitig: bei einer Anfrage mit Preis fuer Verbraucher immer (der Tag der Annahme ist unbekannt)', () => { + const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } } + const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 } + const inTagen = (n) => new Date(NOW.getTime() + n * TAG) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(3), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(60), NOW), true) + assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, new Date('kaputt'), NOW), true) + // ohne Preis, Unternehmer, Freizeit: nein + assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...anfrage, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false) + assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'unternehmer', freizeit: false } }, anfrage, inTagen(3), NOW), false) + assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'verbraucher', freizeit: true } }, anfrage, inTagen(3), NOW), false) +}) + test('preisText, preisHinweis, zahlungText', () => { assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €') assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €')