From 2d9b75b3bfadc851e8b0e1df01853661a420f962 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:01:07 +0000 Subject: [PATCH] Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/ablauf.js | 455 ++++++++++- server/services/termine/limits.js | 85 +++ server/services/termine/mails.js | 7 +- test/termineAblaufVerwalten.test.js | 1076 +++++++++++++++++++++++++++ test/termineLimits.test.js | 83 +++ 5 files changed, 1682 insertions(+), 24 deletions(-) create mode 100644 server/services/termine/limits.js create mode 100644 test/termineAblaufVerwalten.test.js create mode 100644 test/termineLimits.test.js diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index e38bdb0..46637d5 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' import { config } from '../../config.js' import { withLocks } from '../keyedLock.js' -import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perHash } from './buchungen.js' +import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perEmailUndNummer, perHash } from './buchungen.js' import { brauchtZustimmungVorzeitig, emailAdresse, @@ -13,6 +13,7 @@ import { zahlungText, } from './einstellungen.js' import { kontextPerFeature, websiteUrl } from './kontext.js' +import { darfG08, darfK21NichtZugeordnet } from './limits.js' import { anBetrieb, anGast, dauerText } from './mails.js' import { RECHTSTEXTE_VERSION, @@ -22,11 +23,11 @@ import { formularHinweis, widerrufsbelehrung, } from './rechtstexte.js' -import { belegungAus, blockEnde, freieZeiten, waehleRessource } from './slots.js' +import { belegungAus, blockEnde, freieTage, freieZeiten, waehleRessource } from './slots.js' import { buchungsNummer, hashToken, neuesToken, verwaltungsToken } from './token.js' import { pruefeNow, zeitMs } from './util.js' -import { ausUtc, datumLang, uhrzeit, zuUtc } from './zeit.js' -import { belegeZellen } from './zellen.js' +import { ausUtc, datumKurz, datumLang, monatInfo, uhrzeit, zuUtc } from './zeit.js' +import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js' /** * Gast-Ablauf des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4): Termin halten, @@ -45,6 +46,14 @@ import { belegeZellen } from './zellen.js' * Zustandsaenderungen laufen unter `termine-buchung:` mit frischem Lesen. Mails gehen erst NACH der Aenderung und * ausserhalb der Sperre in die Warteschlange; scheitert das, bleibt die Aenderung bestehen (Log ohne personenbezogene * Daten, die Warteschlange vermerkt Fehler beim Senden selbst). + * + * Verwaltung durch den Gast (Verwaltungstoken, gueltig bis Terminende + 1 Tag): sageAbGast, verschiebeGast (mit + * zeitenFuerToken fuer die freien Zeiten), widerrufeGast; ohne Link widerrufeOhneLink. Absagen und Verschieben gehen bis + * absageBisStd vor Beginn (danach 409 "Absage nur telefonisch"), der Widerruf haengt nur an Vertragsschluss + 14 Tagen + * und am Beginn des Termins. Beim Verschieben werden die neuen Zellen VOR der Aenderung der Buchung belegt (scheitert + * 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). */ const MIN_MS = 60 * 1000 @@ -56,6 +65,7 @@ const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ const ZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/ const TELEFON_RE = /^[0-9 +()-]+$/ const NAME_MAX = 100 +const NUMMER_MAX = 40 const NACHRICHT_MAX = 500 const TELEFON_MAX = 40 const ZUSATZ_WERT_MAX = 200 @@ -66,7 +76,9 @@ const MELDUNG = { leistung: 'Bitte eine Leistung auswählen.', ressource: 'Bitte aus den angebotenen Möglichkeiten auswählen.', tag: 'Bitte ein Datum auswählen.', + monat: 'Bitte einen Monat auswählen.', zeit: 'Bitte eine Uhrzeit auswählen.', + gleicheZeit: 'Das ist bereits der gebuchte Termin. Bitte eine andere Zeit wählen.', zeitVergeben: 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.', nameLeer: 'Bitte einen Namen angeben.', nameLang: `Der Name darf höchstens ${NAME_MAX} Zeichen lang sein.`, @@ -83,6 +95,8 @@ const MELDUNG = { vorzeitig: 'Bitte der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zustimmen, sonst ist die Buchung nicht möglich.', unbekannt: 'Dieser Link ist ungültig oder abgelaufen.', abgelaufen: 'Diese Reservierung ist abgelaufen.', + nichtMoeglich: 'Dieser Termin kann nicht mehr abgesagt werden.', + keinWiderruf: 'Für diesen Termin ist kein Widerruf möglich.', } const iso = (wert) => new Date(wert).toISOString() @@ -99,6 +113,23 @@ const unbekannt = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbeka const betriebKurz = (ctx) => ({ name: ctx.einstellung.betrieb.name, telefon: ctx.einstellung.betrieb.telefon, websiteUrl: websiteUrl(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() { + const fehler = {} + const fehl = (feld, meldung) => { + if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung + } + return { fehler, fehl } +} + +/** Name: nicht leer, hoechstens 100 Zeichen (bereinigt); Fehler gehen an fehl. */ +function pruefeNameFeld(wert, fehl) { + const name = bereinigterText(wert, NAME_MAX + 1) + if (name === '') fehl('name', MELDUNG.nameLeer) + else if ([...name].length > NAME_MAX) fehl('name', MELDUNG.nameLang) + return name +} + /** Log ohne personenbezogene Daten: Vorgang, Buchungsnummer, Art und Text des Fehlers (kommt aus unserem Code oder Appwrite). */ function protokolliere(was, bookingId, err) { const nummer = bookingId ? buchungsNummer(bookingId) : '-' @@ -122,10 +153,7 @@ async function mailSicher(was, bookingId, fn) { */ function pruefeEingabe(e, roh, now) { if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) - const fehler = {} - const fehl = (feld, meldung) => { - if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung - } + const { fehler, fehl } = fehlerSammler() // Leistung, Ressource const leistung = typeof roh.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === roh.leistung && l.aktiv) : undefined @@ -150,9 +178,7 @@ function pruefeEingabe(e, roh, now) { if (zeit === null || (tag !== null && start === null)) fehl('zeit', MELDUNG.zeit) // Name, E-Mail - const name = bereinigterText(roh.name, NAME_MAX + 1) - if (name === '') fehl('name', MELDUNG.nameLeer) - else if ([...name].length > NAME_MAX) fehl('name', MELDUNG.nameLang) + const name = pruefeNameFeld(roh.name, fehl) const email = emailAdresse(roh.email) if (email === null) fehl('email', MELDUNG.email) @@ -250,6 +276,9 @@ function baueNachweis(e, g, verbraucher, now) { // ---------------------------------------------------------------- Halten +/** 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) { const e = ctx.einstellung @@ -261,8 +290,7 @@ async function pruefeSlot(ctx, g, now) { if (!treffer) throw zeitVergeben() if (g.ressource !== 'egal') return { start: treffer.start, ressourceId: g.ressource } // "Die wenigsten Buchungen an dem Tag": nur Buchungen, die an diesem Berliner Tag beginnen - const beginnenHeute = amTag.filter((b) => Number.isFinite(zeitMs(b.startsAt)) && ausUtc(new Date(zeitMs(b.startsAt))).tag === g.tag) - return { start: treffer.start, ressourceId: waehleRessource(treffer.ressourceIds, beginnenHeute, e, now) } + return { start: treffer.start, ressourceId: waehleRessource(treffer.ressourceIds, beginnenAn(amTag, g.tag), e, now) } } /** Haltung wieder aufgeben (nach gescheitertem Belegen): verfallen, unter der Buchungssperre. Fehler dabei werden nur protokolliert. */ @@ -362,12 +390,15 @@ export async function halte(ctx, eingabe, now) { // ---------------------------------------------------------------- Token -> Buchung -/** Buchung zu einem Token (Bestaetigungstoken zuerst, dann Verwaltungstoken) samt Kontext; null, wenn es nichts gibt. */ -async function findePerToken(token, { nurBestaetigung = false } = {}) { +/** + * Buchung zu einem Token (Bestaetigungstoken zuerst, dann Verwaltungstoken) samt Kontext; null, wenn es nichts gibt. + * nurBestaetigung / nurVerwaltung lassen die jeweils andere Art weg. + */ +async function findePerToken(token, { nurBestaetigung = false, nurVerwaltung = false } = {}) { if (typeof token !== 'string' || !TOKEN_RE.test(token)) return null const hash = hashToken(token) let art = 'bestaetigen' - let buchung = await perHash('verifyHash', hash) + let buchung = nurVerwaltung ? null : await perHash('verifyHash', hash) if (!buchung && !nurBestaetigung) { art = 'verwalten' buchung = await perHash('manageHash', hash) @@ -389,21 +420,37 @@ export function kannAbsagen(einstellung, buchung, now) { } /** - * Besteht noch ein Widerrufsrecht? Verbindlich (sofort), Verbraucher, bestaetigt (kein Handeintrag des Betriebs), - * weniger als 14 Tage seit dem Vertragsschluss (bestaetigtAt) und der Termin hat noch nicht begonnen. + * Zeitpunkt des Vertragsschlusses in ms: bestaetigtAt bei einer sofort buchbaren Leistung, angenommenAt bei einer vom + * Betrieb angenommenen Anfrage; NaN, wenn es keinen gibt (auch eine Anfrage, die nur bestaetigt wurde, ist kein Vertrag). + */ +function vertragAm(buchung) { + if (buchung.buchungsart === 'sofort') return zeitMs(buchung.bestaetigtAt) + if (buchung.buchungsart === 'anfrage') return zeitMs(buchung.angenommenAt) + return Number.NaN +} + +/** + * Besteht noch ein Widerrufsrecht? Verbindlich (sofort oder vom Betrieb angenommene Anfrage), Verbraucher, bestaetigt + * (kein Handeintrag des Betriebs), weniger als 14 Tage seit dem Vertragsschluss (vertragAm) und der Termin hat noch + * nicht begonnen. Unabhaengig von absageBisStd (§ 361 BGB: die Stornofrist schraenkt das Widerrufsrecht nicht ein). */ export function kannWiderrufen(buchung, now) { const nowMs = pruefeNow(now, 'kannWiderrufen') return ( - buchung.buchungsart === 'sofort' && buchung.verbraucher === true && buchung.status === 'bestaetigt' && buchung.quelle !== 'manuell' && - nowMs < zeitMs(buchung.bestaetigtAt) + WIDERRUF_TAGE * TAG_MS && + nowMs < vertragAm(buchung) + WIDERRUF_TAGE * TAG_MS && nowMs < zeitMs(buchung.startsAt) ) } +/** Hinweis, wenn ein Gast nicht mehr selbst absagen kann (Frist vorbei, Termin aber noch nicht begonnen). */ +const absageHinweis = (betrieb) => (betrieb.telefon ? `Absage nur telefonisch: ${betrieb.telefon}` : 'Absage nur direkt beim Betrieb möglich.') + +/** Der Verwaltungslink gilt bis Terminende + 1 Tag. */ +const linkAbgelaufen = (buchung, nowMs) => nowMs >= zeitMs(buchung.endsAt) + TAG_MS + // ---------------------------------------------------------------- Vorschau /** @@ -425,7 +472,7 @@ export async function vorschau(token, now) { if (art === 'bestaetigen' && (buchung.status === 'verfallen' || (buchung.status === 'gehalten' && !(zeitMs(buchung.heldUntil) > nowMs)))) { throw abgelaufenMit(404, ctx) } - if (art === 'verwalten' && nowMs >= zeitMs(buchung.endsAt) + TAG_MS) throw abgelaufenMit(404, ctx) // gueltig bis Terminende + 1 Tag + if (art === 'verwalten' && linkAbgelaufen(buchung, nowMs)) throw abgelaufenMit(404, ctx) const start = new Date(zeitMs(buchung.startsAt)) const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent }) @@ -440,7 +487,7 @@ export async function vorschau(token, now) { aktionen.absagen = kannAbsagen(e, buchung, now) aktionen.verschieben = aktionen.absagen aktionen.widerrufen = kannWiderrufen(buchung, now) - if (offen && !aktionen.absagen && nowMs < start.getTime()) hinweis = b.telefon ? `Absage nur telefonisch: ${b.telefon}` : 'Absage nur direkt beim Betrieb möglich.' + if (offen && !aktionen.absagen && nowMs < start.getTime()) hinweis = absageHinweis(b) } return { @@ -552,3 +599,367 @@ export async function bestaetige(token, now) { } return { status: ergebnis.status } } + +// ---------------------------------------------------------------- Verwaltung durch den Gast + +/** Buchung zum Verwaltungstoken samt Kontext; 404 (code 'unbekannt'), ab Terminende + 1 Tag 404 code 'abgelaufen'. */ +async function ladeVerwaltung(token, nowMs) { + const fund = await findePerToken(token, { nurVerwaltung: true }) + if (!fund) throw unbekannt() + if (linkAbgelaufen(fund.buchung, nowMs)) throw abgelaufenMit(404, fund.ctx) + return fund +} + +/** + * Absagen und Verschieben setzen eine offene Buchung (bestaetigt, angefragt) vor Ablauf der Absagefrist voraus. Sonst 409: + * ist der Termin noch nicht begonnen, mit "Absage nur telefonisch: ..." (code 'frist_vorbei'), andernfalls "nicht mehr + * moeglich" (code 'nicht_moeglich', z. B. erledigt oder abgesagt). + */ +function pruefeAbsagbar(einstellung, buchung, now) { + if (kannAbsagen(einstellung, buchung, now)) return + if (VERWALTBAR.includes(buchung.status) && now.getTime() < zeitMs(buchung.startsAt)) { + throw fehlerMit(409, { _: absageHinweis(einstellung.betrieb) }, { code: 'frist_vorbei' }) + } + throw fehlerMit(409, { _: MELDUNG.nichtMoeglich }, { code: 'nicht_moeglich' }) +} + +/** + * 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) { + try { + await gibZellenFrei(bookingId, optionen) + } catch (err) { + protokolliere('Zellen nicht freigegeben', bookingId, err) + } +} + +/** + * Gast sagt per Verwaltungslink ab (bis absageBisStd vor Beginn): `abgesagt` von `gast`, Zellen frei, G06 an den Gast + * (Kalenderabsage CANCEL; fuer eine unbestaetigte Anfrage ohne Kalenderdatei, sie hat nie eine Einladung bekommen), K21 + * `abgesagt` an den Betrieb. Ein weiterer Klick aendert nichts und meldet `schon`. + * @param {string} token Verwaltungstoken + * @param {Date} now + * @returns {Promise<{ status: 'abgesagt', schon?: true }>} + * @throws Error 404 (unbekannt; 'abgelaufen' mit betrieb), 409 code 'frist_vorbei' (Feld _ "Absage nur telefonisch: ...") + * oder 'nicht_moeglich' + */ +export async function sageAbGast(token, now) { + const nowMs = pruefeNow(now, 'sageAbGast') + const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs) + const id = gefunden.$id + + const ergebnis = await withLocks([`termine-buchung:${id}`], async () => { + const frisch = await hole(id) + if (!frisch) throw unbekannt() + if (frisch.status === 'abgesagt') return { schon: true } + pruefeAbsagbar(ctx.einstellung, frisch, now) + const buchung = await aendere(id, { status: 'abgesagt', abgesagtAt: iso(now), abgesagtVon: 'gast', updatedAt: iso(now) }) + await gibFreiSicher(id) + return { buchung, ohneIcs: frisch.status === 'angefragt' } + }) + if (ergebnis.schon) return { status: 'abgesagt', schon: true } + + const { buchung, ohneIcs } = ergebnis + await mailSicher('G06', id, () => anGast('G06', ctx, buchung, { von: 'gast', ohneIcs }, now)) + await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'abgesagt' }, now)) + return { status: 'abgesagt' } +} + +/** + * Dauer und Pause so, wie der Gast gebucht hat (nicht wie die Leistung heute eingestellt ist): eine Einstellung, in der + * die Leistung der Buchung diese Laenge hat. Beim Verschieben aendert der Termin seine Lage, nicht seinen Umfang. + */ +function mitGebuchterDauer(einstellung, buchung) { + const dauer = Math.round((zeitMs(buchung.endsAt) - zeitMs(buchung.startsAt)) / MIN_MS) + const pufferNach = Math.round((zeitMs(buchung.blockEndsAt) - zeitMs(buchung.endsAt)) / MIN_MS) + return { ...einstellung, leistungen: (einstellung.leistungen || []).map((l) => (l.id === buchung.leistungId ? { ...l, dauer, pufferNach } : l)) } +} + +/** + * Welche Ressourcen kommen beim Verschieben infrage? Ist die Person waehlbar, hat der Gast sie bewusst oder durch "egal" + * bekommen und behaelt sie; sonst (Plaetze, keine Wahl) jede freie, wobei die eigene bevorzugt wird. + */ +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) { + const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00')) + const zeiten = freieZeiten({ + einstellung: mitGebuchterDauer(ctx.einstellung, buchung), + leistungId: buchung.leistungId, + ressourceId: verschiebeRessource(ctx.einstellung, buchung), + tag, + belegung: belegungAus(amTag, now, { ohneBuchung: buchung.$id }), + now, + }) + return { zeiten, amTag } +} + +/** Tag ('YYYY-MM-DD') und Uhrzeit ('HH:MM') des neuen Termins; sonst 400 mit den Feldern tag und zeit. */ +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 + if (tag === null) fehl('tag', MELDUNG.tag) + const zeit = typeof roh.zeit === 'string' && ZEIT_RE.test(roh.zeit) ? roh.zeit : null + if (zeit === null) fehl('zeit', MELDUNG.zeit) + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + return { tag, zeit } +} + +/** + * 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 + * moeglich. Reihenfolge unter `termine-buchung:`: frisch lesen, neue Zeit berechnen, neue Zellen belegen (SLOT_VERGEBEN + * => 409, alles unveraendert), Buchung aendern (Beginn, Ende, Blockende, Ressource, icsSequenz + 1; scheitert das, werden + * die neuen Zellen wieder entfernt), erst dann die alten Zellen freigeben. Status und Verwaltungslink bleiben. Danach G07 + * (neuer Termin, Kalender-REQUEST mit hoeherer Sequenz; bei einer Anfrage ohne Kalenderdatei) und K21 `verschoben`. + * @param {string} token Verwaltungstoken + * @param {{ tag: string, zeit: string }} roh Berliner Tag 'YYYY-MM-DD' und Uhrzeit 'HH:MM' + * @param {Date} now + * @returns {Promise<{ status: string, termin: { datum: string, zeit: string } }>} + * @throws Error 400 (Felder tag, zeit; dieselbe Zeit), 404, 409 ('frist_vorbei', 'nicht_moeglich', 'SLOT_VERGEBEN' Feld zeit) + */ +export async function verschiebeGast(token, roh, now) { + const nowMs = pruefeNow(now, 'verschiebeGast') + 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 }) + + 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 } + }) + + const { alt, buchung, start } = ergebnis + const ohneIcs = alt.status === 'angefragt' + await mailSicher('G07', id, () => anGast('G07', ctx, buchung, { altStart: alt.startsAt, ohneIcs }, now)) + await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'verschoben', extra: { altStart: alt.startsAt } }, now)) + return { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } } +} + +/** + * Freie Zeiten fuer die Gaesteseite beim Verschieben (Verwaltungstoken), mit der Belegung ohne die eigene Buchung und in + * der gebuchten Dauer. Dieselben Formen wie die oeffentliche Schnittstelle: `{ tag }` => { zeiten: ['HH:MM', ...] }, + * `{ monat: 'YYYY-MM' }` => { tage: ['YYYY-MM-DD', ...], monat: monatInfo }. Nur solange Verschieben moeglich ist. + * @param {string} token Verwaltungstoken + * @param {{ tag?: string, monat?: string }} roh + * @param {Date} now + * @throws Error 400 (Feld tag bzw. monat), 404, 409 ('frist_vorbei', 'nicht_moeglich') + */ +export async function zeitenFuerToken(token, roh, now) { + const nowMs = pruefeNow(now, 'zeitenFuerToken') + const abfrage = istObjekt(roh) ? roh : {} + const nachMonat = abfrage.tag === undefined && abfrage.monat !== undefined + const tag = typeof abfrage.tag === 'string' && zuUtc(abfrage.tag, '12:00') !== null ? abfrage.tag : null + const info = nachMonat ? monatInfo(abfrage.monat) : null + if (nachMonat ? info === null : tag === null) throw fehlerMit(400, nachMonat ? { monat: MELDUNG.monat } : { tag: MELDUNG.tag }) + + const { ctx, buchung } = await ladeVerwaltung(token, nowMs) + pruefeAbsagbar(ctx.einstellung, buchung, now) + if (!nachMonat) return { zeiten: (await verschiebeZeiten(ctx, buchung, tag, now)).zeiten.map((z) => z.uhrzeit) } + + const imMonat = await imZeitraum(ctx.feature.$id, zuUtc(`${abfrage.monat}-01`, '00:00'), zuUtc(`${info.nachher}-01`, '00:00')) + const tage = freieTage({ + einstellung: mitGebuchterDauer(ctx.einstellung, buchung), + leistungId: buchung.leistungId, + ressourceId: verschiebeRessource(ctx.einstellung, buchung), + monat: abfrage.monat, + belegung: belegungAus(imMonat, now, { ohneBuchung: buchung.$id }), + now, + }) + return { tage, monat: info } +} + +// ---------------------------------------------------------------- Widerruf + +const VERTRAG_STATUS = ['bestaetigt', 'erledigt', 'nichterschienen'] + +/** + * Wie steht die Buchung zu einer Widerrufserklaerung? 'wirksam' (kannWiderrufen), 'nach_frist' (ein Verbrauchervertrag + * besteht oder bestand, aber die 14 Tage sind um oder der Termin hat begonnen: der Betrieb prueft) oder 'kein_recht' + * (Unternehmer, Freizeit, Handeintrag, noch nicht angenommene Anfrage, abgesagt, ...). + */ +function widerrufLage(buchung, now) { + if (kannWiderrufen(buchung, now)) return 'wirksam' + const vertrag = buchung.verbraucher === true && buchung.quelle !== 'manuell' && Number.isFinite(vertragAm(buchung)) && VERTRAG_STATUS.includes(buchung.status) + return vertrag ? 'nach_frist' : 'kein_recht' +} + +/** Eingang einer Erklaerung als Berliner Strings ('TT.MM.JJJJ', 'HH:MM'). */ +const eingangVon = (wann) => ({ datum: datumKurz(wann), uhrzeit: uhrzeit(wann) }) + +/** + * Eingabe der Widerrufserklaerung: Name (Pflicht) und, ohne Link, die E-Mail-Adresse (Pflicht, ein Postfach, klein und + * getrimmt). Die Buchungsnummer ist freiwillig: fehlt sie oder passt sie nicht, geht die Erklaerung als "nicht + * zugeordnet" an den Betrieb, statt dass das Formular sie ablehnt. + */ +function pruefeWiderrufEingabe(roh, { mitAdresse }) { + if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) + const { fehler, fehl } = fehlerSammler() + const name = pruefeNameFeld(roh.name, fehl) + let email = null + if (mitAdresse) { + email = emailAdresse(roh.email) + if (email === null) fehl('email', MELDUNG.email) + } + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + return { name, email, nummer: bereinigterText(roh.nummer, NUMMER_MAX) } +} + +/** + * Erklaerung zu einer gefundenen Buchung festhalten, unter `termine-buchung:` nach frischem Lesen. + * wirksam: `abgesagt` von `gast`, widerrufenAt, Zellen frei. nach_frist: nur ein Vermerk in absageGrund, sonst bleibt + * alles. kein_recht: nichts. schon: die Buchung war schon widerrufen (nichts). weg: gibt es nicht mehr. + * => { lage, buchung } + */ +async function haltWiderrufFest(id, name, now) { + return withLocks([`termine-buchung:${id}`], async () => { + const frisch = await hole(id) + if (!frisch) return { lage: 'weg' } + if (frisch.widerrufenAt) return { lage: 'schon', buchung: frisch } + const lage = widerrufLage(frisch, now) + if (lage === 'kein_recht') return { lage, buchung: frisch } + const eingang = eingangVon(now) + const wann = `${eingang.datum}, ${eingang.uhrzeit} Uhr` + if (lage === 'nach_frist') { + const vermerk = `Widerruf nach Ablauf der Frist erklärt von: ${name} (eingegangen ${wann})` + return { lage, buchung: await aendere(id, { absageGrund: vermerk, updatedAt: iso(now) }) } + } + const buchung = await aendere(id, { + status: 'abgesagt', + abgesagtAt: iso(now), + abgesagtVon: 'gast', + widerrufenAt: iso(now), + absageGrund: `Widerruf erklärt von: ${name} (eingegangen ${wann})`, + updatedAt: iso(now), + }) + await gibFreiSicher(id) + return { lage, buchung } + }) +} + +/** Nicht gesendet, weil eine Grenze erreicht ist: Log ohne personenbezogene Daten, die Antwort an den Gast aendert das nicht. */ +function ueberspringe(was, bookingId) { + console.warn(`[termine-ablauf] ${was} nicht gesendet (${bookingId ? buchungsNummer(bookingId) : '-'}): Grenze erreicht`) +} + +/** Mails zu einer festgehaltenen Erklaerung: G08 an die Adresse des Gastes (mit Grenzen) und K21 an den Betrieb. */ +async function widerrufsMails(ctx, lage, buchung, adresse, now) { + const id = buchung.$id + if (darfG08(ctx.feature.$id, adresse, now)) { + await mailSicher('G08', id, () => anGast('G08', ctx, buchung, { fall: lage === 'wirksam' ? 'zugeordnet' : 'nach_frist', eingang: now, an: adresse }, now)) + } else { + ueberspringe('G08', id) + } + await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: lage === 'wirksam' ? 'widerrufen' : 'nach_frist', extra: { eingang: now } }, now)) +} + +/** Erklaerung ohne passende Buchung: G08 (ohne Bezug) an die angegebene Adresse, K21 `nicht_zugeordnet` an den Betrieb, beide mit Grenzen. */ +async function nichtZugeordnetMails(ctx, { name, email, nummer }, now) { + const featureId = ctx.feature.$id + if (darfG08(featureId, email, now)) { + await mailSicher('G08', null, () => anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: now, an: email }, now)) + } else { + ueberspringe('G08', null) + } + if (!darfK21NichtZugeordnet(featureId, now)) return ueberspringe('K21', null) + const erklaerung = { gastName: name, gastEmail: email, gastTelefon: '', gastNachricht: `Angegebene Buchungsnummer: ${nummer || 'keine Angabe'}` } + await mailSicher('K21', null, () => anBetrieb('K21', ctx, erklaerung, { block: 'nicht_zugeordnet', extra: { eingang: now } }, now)) +} + +/** + * Gast widerruft per Verwaltungslink (Formular nach § 356a BGB, Knopf "Widerruf bestätigen"). Wirksam, solange ein + * Widerrufsrecht besteht (kannWiderrufen), unabhaengig von der Stornofrist: `abgesagt` von `gast`, widerrufenAt, Zellen + * frei, G08 `zugeordnet` an die Adresse der Buchung, K21 `widerrufen`. Nach Ablauf der Frist (oder bei begonnenem Termin) + * wird die Erklaerung nur vermerkt und mit G08/K21 `nach_frist` gemeldet. Hat die Buchung kein Widerrufsrecht (nicht + * angenommene Anfrage, Unternehmer, ...), 409 code 'kein_widerruf'. Ein weiterer Klick meldet den Eingang der ersten Erklaerung. + * @param {string} token Verwaltungstoken + * @param {{ name: string }} roh + * @param {Date} now + * @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>} Berliner Zeit ('TT.MM.JJJJ', 'HH:MM') + */ +export async function widerrufeGast(token, roh, now) { + const nowMs = pruefeNow(now, 'widerrufeGast') + const { name } = pruefeWiderrufEingabe(roh, { mitAdresse: false }) + const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs) + + const erg = await haltWiderrufFest(gefunden.$id, name, now) + if (erg.lage === 'weg') throw unbekannt() + if (erg.lage === 'kein_recht') throw fehlerMit(409, { _: MELDUNG.keinWiderruf }, { code: 'kein_widerruf' }) + if (erg.lage === 'schon') return { eingang: eingangVon(new Date(zeitMs(erg.buchung.widerrufenAt))) } + await widerrufsMails(ctx, erg.lage, erg.buchung, erg.buchung.gastEmail, now) + return { eingang: eingangVon(now) } +} + +/** + * Widerruf ohne Link (Widerrufsfunktion im Widget, § 356a BGB). Die Antwort ist in jedem Fall dieselbe + * (`{ eingang }` mit dem Eingang jetzt): sie verraet nicht, ob eine Buchung zu E-Mail-Adresse und Nummer passt. + * - Treffer mit Widerrufsrecht: wie widerrufeGast (abgesagt, widerrufenAt, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`). + * - Treffer nach Ablauf der Frist: nur vermerkt, G08 `nach_frist` und K21 `nach_frist`; der Termin bleibt. + * - sonst (kein Treffer oder Buchung ohne Widerrufsrecht): G08 ohne Bezug an die angegebene Adresse und K21 + * `nicht_zugeordnet` an den Betrieb, damit der Betrieb die Erklaerung pruefen kann. + * G08 geht nie mit Text des Gastes raus. Grenzen (limits.js): 3 G08 je Adresse und Tag, 20 je Funktion und Stunde, + * 10 K21 `nicht_zugeordnet` je Funktion und Stunde; eine erreichte Grenze ueberspringt nur die Mail. + * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) + * @param {{ name: string, email: string, nummer?: string }} roh + * @param {Date} now + * @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>} + * @throws Error 400 (Felder name, email) + */ +export async function widerrufeOhneLink(ctx, roh, now) { + pruefeNow(now, 'widerrufeOhneLink') + if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError('widerrufeOhneLink: ctx fehlt') + const erklaerung = pruefeWiderrufEingabe(roh, { mitAdresse: true }) + const antwort = { eingang: eingangVon(now) } + + const treffer = await perEmailUndNummer(ctx.feature.$id, erklaerung.email, erklaerung.nummer) + const erg = treffer ? await haltWiderrufFest(treffer.$id, erklaerung.name, now) : { lage: 'weg' } + if (erg.lage === 'schon') return antwort // schon bestaetigt und gemeldet + if (erg.lage === 'wirksam' || erg.lage === 'nach_frist') await widerrufsMails(ctx, erg.lage, erg.buchung, erklaerung.email, now) + else await nichtZugeordnetMails(ctx, erklaerung, now) + return antwort +} diff --git a/server/services/termine/limits.js b/server/services/termine/limits.js new file mode 100644 index 0000000..c728280 --- /dev/null +++ b/server/services/termine/limits.js @@ -0,0 +1,85 @@ +import { pruefeNow, pruefeText } from './util.js' + +/** + * Grenzen des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 8): Zaehler im Speicher dieses + * Prozesses, je Schluessel ein festes Fenster ab dem ersten gezaehlten Ereignis. Die Schluessel enthalten Adressen oder + * IPs und verlassen den Speicher nie (kein Log, keine Datenbank). Hoechstens 10 000 Schluessel: ist die Map voll, + * faellt der aelteste (zuerst begonnene) Zaehler weg; ein abgelaufenes Fenster wird beim naechsten Zaehlen neu begonnen. + * Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht). + * + * Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt). + * Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet. Die Grenzen je IP, siteKey und G01 ergaenzt + * Aufgabe 16 in diesem Modul. + */ + +const MAX_SCHLUESSEL = 10000 +const STUNDE_MS = 60 * 60 * 1000 +const TAG_MS = 24 * STUNDE_MS + +/** G08 (Eingangsbestaetigung des Widerrufs): je Empfaengeradresse und Tag, je Funktion und Stunde. */ +const G08_JE_ADRESSE = 3 +const G08_JE_FUNKTION = 20 +/** K21 "nicht zugeordnet": je Funktion und Stunde. */ +const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 10 + +/** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */ +const zaehler = new Map() + +/** Der Zaehler des Schluessels, wenn sein Fenster zum Zeitpunkt nowMs noch laeuft; sonst undefined. */ +function laufend(schluessel, fensterMs, nowMs) { + const eintrag = zaehler.get(schluessel) + return eintrag && nowMs < eintrag.start + fensterMs ? eintrag : undefined +} + +/** + * Zaehlt ein Ereignis je Regel, aber nur, wenn keine Regel ihre Grenze erreicht hat (alle oder keine). + * @param {{ schluessel: string, max: number, fensterMs: number }[]} regeln + * @param {Date} now + * @returns {boolean} true = erlaubt (gezaehlt), false = mindestens eine Grenze erreicht + */ +export function zaehleAlle(regeln, now) { + const nowMs = pruefeNow(now, 'zaehleAlle') + if (!Array.isArray(regeln) || regeln.length === 0) throw new TypeError('zaehleAlle: regeln fehlen') + for (const { schluessel, max, fensterMs } of regeln) { + pruefeText(schluessel, 'schluessel') + if (!Number.isInteger(max) || max < 0 || !(fensterMs > 0)) throw new TypeError('zaehleAlle: max oder fensterMs ungueltig') + if ((laufend(schluessel, fensterMs, nowMs)?.n ?? 0) >= max) return false + } + for (const { schluessel, fensterMs } of regeln) { + const eintrag = laufend(schluessel, fensterMs, nowMs) + if (eintrag) { + eintrag.n += 1 + continue + } + zaehler.delete(schluessel) // neu einfuegen = ans Ende der Reihenfolge + if (zaehler.size >= MAX_SCHLUESSEL) zaehler.delete(zaehler.keys().next().value) + zaehler.set(schluessel, { start: nowMs, n: 1 }) + } + return true +} + +/** Ein Ereignis zaehlen: hoechstens max je Fenster. => true = erlaubt (gezaehlt), false = Grenze erreicht. */ +export function zaehle(schluessel, max, fensterMs, now) { + return zaehleAlle([{ schluessel, max, fensterMs }], now) +} + +/** Darf eine G08 an diese (normalisierte) Adresse gehen? Hoechstens 3 je Adresse und Tag, 20 je Funktion und Stunde. */ +export function darfG08(featureId, adresse, now) { + return zaehleAlle( + [ + { schluessel: `g08-adresse:${adresse}`, max: G08_JE_ADRESSE, fensterMs: TAG_MS }, + { schluessel: `g08-funktion:${featureId}`, max: G08_JE_FUNKTION, fensterMs: STUNDE_MS }, + ], + now, + ) +} + +/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 10 je Funktion und Stunde. */ +export function darfK21NichtZugeordnet(featureId, now) { + return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now) +} + +/** Leert alle Zaehler (Tests). */ +export function resetTermineLimitsForTests() { + zaehler.clear() +} diff --git a/server/services/termine/mails.js b/server/services/termine/mails.js index 37ceff2..2f60337 100644 --- a/server/services/termine/mails.js +++ b/server/services/termine/mails.js @@ -33,6 +33,8 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js' * G04 nachricht (optionale Nachricht des Betriebs) * G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1) * G07 altStart (bisheriger Beginn, Pflicht) + * G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen, + * es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben) * G08 fall 'zugeordnet' | 'nach_frist' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (bei 'nicht_zugeordnet') * K20 fall (= block) 'anfrage' oder nichts * K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' (Pflicht); altStart (verschoben); @@ -455,7 +457,8 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) { /** * Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist - * extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL). + * extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils + * ausser mit extra.ohneIcs. * @param {string} art 'G01' ... 'G08' * @param {object} ctx * @param {object|null} buchung @@ -469,7 +472,7 @@ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) { extra = extra ?? {} const { values, blocks } = werteFuer(art, ctx, buchung, extra) const b = ctx.einstellung.betrieb - const ics = kalenderDatei(art, ctx, buchung, extra, now) + const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now) return einreihen( { featureId: ctx.feature?.$id, diff --git a/test/termineAblaufVerwalten.test.js b/test/termineAblaufVerwalten.test.js new file mode 100644 index 0000000..d5e19af --- /dev/null +++ b/test/termineAblaufVerwalten.test.js @@ -0,0 +1,1076 @@ +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' + +// Gast-Verwaltung des Terminplaners: absagen, verschieben, Zeiten fuer das Verschieben, Widerruf (mit und ohne Link). +// Aufbau wie test/termineAblaufGast.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 { monatInfo } = await import('../server/services/termine/zeit.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { halte, vorschau, bestaetige, kannWiderrufen, sageAbGast, verschiebeGast, zeitenFuerToken, widerrufeGast, widerrufeOhneLink } = await import( + '../server/services/termine/ablauf.js' +) +after(() => setMailTransportForTests(null)) + +const MIN = 60000 +const TAG_MS = 24 * 60 * MIN +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 (MESZ): vorzeitiger Beginn zustimmungspflichtig +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 = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, 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: 'aus', zusatz: [] }, + 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()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 }) + +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, +}) +const NAH = { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true } } + +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() + +/** 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 + } +} + +// ---------------------------------------------------------------- Absagen + +test('sageAbGast: bis absageBisStd vor Beginn; abgesagt von gast, Zellen frei, G06 mit CANCEL (Sequenz + 1), K21 abgesagt', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx) + assert.equal(claims(id).length, 10) + + const jetzt = plus(JETZT, 3 * 60 * MIN) + assert.deepEqual(await sageAbGast(verwaltung, jetzt), { status: 'abgesagt' }) + await warteAufVersand() + + const nachher = buchung(id) + assert.equal(nachher.status, 'abgesagt') + assert.equal(nachher.abgesagtVon, 'gast') + assert.equal(iso(nachher.abgesagtAt), iso(jetzt)) + assert.equal(iso(nachher.updatedAt), iso(jetzt)) + assert.equal(nachher.widerrufenAt, undefined, 'eine Absage ist kein Widerruf') + assert.equal(nachher.icsSequenz, b.icsSequenz) + assert.equal(claims(id).length, 0, 'Zellen frei') + + const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen + assert.equal(neu.length, 2) + const g06 = an(neu, 'max@example.com')[0] + assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') + assert.equal(g06.icalEvent.method, 'CANCEL') + const ics = entfalte(g06.icalEvent.content) + assert.match(ics, /STATUS:CANCELLED/) + assert.match(ics, /SEQUENCE:1\r\n/) + assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`)) + assert.match(g06.text, /Sie selbst/) + assert.equal('bcc' in g06, false) + + const k21 = an(neu, 'info@salon.de')[0] + assert.equal(k21.subject, 'Termin abgesagt: Damenschnitt am 26.10.2026 um 10:00') + assert.equal(k21.from.name, 'WEBklar') + assert.equal('icalEvent' in k21, false) + assert.equal('bcc' in k21, false) + assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) + + // die freie Zeit ist wieder buchbar + const neuerGast = await halte(ctx, eingabe({ email: 'zwei@example.com' }), jetzt) + assert.equal(neuerGast.status, 'gehalten') +}) + +test('sageAbGast: genau an der Frist noch ja, eine Millisekunde spaeter 409 "Absage nur telefonisch", nichts aendert sich', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx) + const start = new Date(b.startsAt) + const standVorher = claimsStand() + + const err = await fehlerVon(() => sageAbGast(verwaltung, plus(start, -24 * 60 * MIN + 1))) + assert.equal(err.status, 409) + assert.deepEqual(err.fehler, { _: 'Absage nur telefonisch: 06301 1234' }) + assert.equal(err.code, 'frist_vorbei') + assert.equal(buchung(id).status, 'bestaetigt') + assert.equal(claimsStand(), standVorher) + assert.equal(sink.sent.length, mailStart + 2, 'keine neuen Mails') + + assert.deepEqual(await sageAbGast(verwaltung, plus(start, -24 * 60 * MIN)), { status: 'abgesagt' }) +}) + +test('sageAbGast: ohne Telefonnummer des Betriebs steht ein allgemeiner Hinweis, nach Beginn und fuer erledigte Termine "nicht mehr moeglich"', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung } = await gebucht(ctx) + const start = new Date(b.startsAt) + const spaet = plus(start, -60 * MIN) + aw.seed('termineConfig', { ...aw.docs('termineConfig').get(FEATURE), einstellung: JSON.stringify({ ...ctx.einstellung, betrieb: { ...ctx.einstellung.betrieb, telefon: '' } }) }) + assert.deepEqual((await fehlerVon(() => sageAbGast(verwaltung, spaet))).fehler, { _: 'Absage nur direkt beim Betrieb möglich.' }) + + const begonnen = await fehlerVon(() => sageAbGast(verwaltung, plus(start, 5 * MIN))) + assert.equal(begonnen.status, 409) + assert.deepEqual(begonnen.fehler, { _: 'Dieser Termin kann nicht mehr abgesagt werden.' }) + assert.equal(begonnen.code, 'nicht_moeglich') + + aw.seed('termineBookings', { ...buchung(id), status: 'erledigt' }) + assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).code, 'nicht_moeglich') +}) + +test('sageAbGast: zweiter Klick und paralleler Klick aendern nichts und senden nichts doppelt', async () => { + const ctx = await speichere() + const { verwaltung } = await gebucht(ctx) + const erg = await Promise.all([sageAbGast(verwaltung, JETZT), sageAbGast(verwaltung, JETZT)]) + await warteAufVersand() + assert.deepEqual(erg.map((x) => x.status), ['abgesagt', 'abgesagt']) + assert.deepEqual(erg.filter((x) => x.schon).length, 1) + assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) + + assert.deepEqual(await sageAbGast(verwaltung, plus(JETZT, MIN)), { status: 'abgesagt', schon: true }) + await warteAufVersand() + assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21']) +}) + +test('sageAbGast: Anfrage (nie eine Einladung erhalten) => G06 ohne Kalenderdatei (R21), K21 geht trotzdem; Zellen frei', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE }) + assert.equal(b.status, 'angefragt') + assert.equal(claims(id).length, 12) + const vorher = sink.sent.length + + await sageAbGast(verwaltung, JETZT) + await warteAufVersand() + const neu = sink.sent.slice(vorher) + const g06 = an(neu, 'max@example.com')[0] + assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt') + assert.equal('icalEvent' in g06, false) + assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin abgesagt: Besichtigung am 26.10.2026 um 10:00') + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(claims(id).length, 0) + assert.deepEqual(arten(), ['G01', 'G03', 'G06', 'K20', 'K21']) +}) + +test('sageAbGast: nur mit Verwaltungstoken; Bestaetigungstoken, unbekannte Token, Terminende + 1 Tag, Modus off', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung, token } = await gebucht(ctx) + for (const falsch of ['', 'x', 'A'.repeat(43), null, undefined, 42, token, verwaltungsToken('andere-id')]) { + const err = await fehlerVon(() => sageAbGast(falsch, JETZT)) + assert.equal(err.status, 404, String(falsch).slice(0, 20)) + } + const ende = new Date(b.endsAt) + const abgelaufen = await fehlerVon(() => sageAbGast(verwaltung, plus(ende, TAG_MS))) + assert.equal(abgelaufen.status, 404) + assert.equal(abgelaufen.code, 'abgelaufen') + assert.equal(abgelaufen.betrieb.name, 'Salon Müller') + config.termine.mode = 'off' + assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).status, 404) + config.termine.mode = 'pilot' + assert.equal(buchung(id).status, 'bestaetigt') + await assert.rejects(() => sageAbGast(verwaltung, undefined), TypeError) +}) + +test('sageAbGast: eine abgeschaltete Funktion (disabled) erlaubt weiter das Absagen', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + aw.seed('portalFeatures', feature('disabled')) + assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' }) + assert.equal(buchung(id).status, 'abgesagt') +}) + +test('sageAbGast: scheitern die Mails, bleibt die Absage bestehen (Log ohne Gastdaten)', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + const erg = await mitDefektemPostausgang(() => sageAbGast(verwaltung, JETZT)) + assert.deepEqual(erg, { status: 'abgesagt' }) + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(claims(id).length, 0) + const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) + assert.match(protokoll, /G06/) + assert.match(protokoll, /K21/) + assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) +}) + +test('sageAbGast: schlaegt das Freigeben der Zellen fehl, bleibt die Absage bestehen und die Mails gehen raus', async () => { + const ctx = await speichere() + const { id, verwaltung, mailStart } = await gebucht(ctx) + aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 500 }) + assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' }) + await warteAufVersand() + assert.equal(buchung(id).status, 'abgesagt') + assert.ok(claims(id).length > 0, 'die Zellen bleiben liegen (gelten als frei, der Tagesjob raeumt sie)') + assert.equal(sink.sent.length, mailStart + 4, 'G02 und K20, dann G06 und K21') + assert.match(JSON.stringify(log.error.mock.calls.map((c) => c.arguments)), /Zellen/) +}) + +// ---------------------------------------------------------------- Verschieben + +test('verschiebeGast: 10:00 -> 10:30 (ueberlappend) gelingt; Zeiten, Zellen, Sequenz 1, G07 mit REQUEST, K21 verschoben', async () => { + const ctx = await speichere() + const { id, buchung: alt, verwaltung, mailStart } = await gebucht(ctx) + const jetzt = plus(JETZT, 3 * 60 * MIN) + + const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, jetzt) + await warteAufVersand() + assert.deepEqual(erg, { status: 'bestaetigt', termin: { datum: 'Montag, 26.10.2026', zeit: '10:30' } }) + + const b = buchung(id) + assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z') // MEZ + assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z') + assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z') + assert.equal(b.status, 'bestaetigt') + assert.equal(b.ressourceId, R1) + assert.equal(b.icsSequenz, 1) + assert.equal(iso(b.updatedAt), iso(jetzt)) + assert.equal(b.manageHash, alt.manageHash, 'der Verwaltungslink bleibt gleich') + + const erwartet = zellenFuer(b.startsAt, b.blockEndsAt).map(iso) + assert.equal(erwartet.length, 10) + assert.deepEqual(zeitenIso(claims(id)), erwartet, 'genau die Zellen des neuen Blocks') + assert.ok(claims(id).every((c) => c.ressourceId === R1)) + // belegeZellen(ausser) legt nur Fehlendes an: die Zellen, die schon zum alten Block gehoerten, behalten ihr bis + const bis = new Set(claims(id).map((c) => iso(c.bis))) + assert.deepEqual([...bis].sort(), [iso(alt.blockEndsAt), iso(b.blockEndsAt)].sort()) + + const neu = sink.sent.slice(mailStart + 2) + assert.equal(neu.length, 2) + const g07 = an(neu, 'max@example.com')[0] + assert.equal(g07.subject, 'Ihr Termin wurde verschoben') + assert.equal(g07.icalEvent.method, 'REQUEST') + const ics = entfalte(g07.icalEvent.content) + assert.match(ics, /SEQUENCE:1\r\n/) + assert.match(ics, /DTSTART:20261026T093000Z/) + assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`)) + assert.match(g07.text, /Montag, 26\.10\.2026/) + assert.match(g07.text, /10:00/) + assert.match(g07.text, /10:30/) + const k21 = an(neu, 'info@salon.de')[0] + assert.equal(k21.subject, 'Termin verschoben: Damenschnitt, jetzt am 26.10.2026 um 10:30') + assert.match(k21.text, /10:00/) + assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20', 'K21']) + + // zweites Verschieben: Sequenz 2, nur noch die Zellen der neuen Zeit + await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '14:00' }, jetzt) + await warteAufVersand() + assert.equal(buchung(id).icsSequenz, 2) + assert.deepEqual(zeitenIso(claims(id)), zellenFuer(buchung(id).startsAt, buchung(id).blockEndsAt).map(iso)) + assert.match(entfalte(an(sink.sent, 'max@example.com').at(-1).icalEvent.content), /SEQUENCE:2\r\n/) +}) + +test('verschiebeGast: auf eine belegte Zeit => 409 SLOT_VERGEBEN, alte Zellen, Buchung und Mails unveraendert', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) + await gebucht(ctx, { zeit: '11:00', email: 'zwei@example.com' }) // Block 11:00-11:50 auf derselben Ressource + const standVorher = claimsStand() + const mails = sink.sent.length + const docVorher = JSON.stringify(buchung(a.id)) + + for (const zeit of ['10:30', '11:00', '11:15']) { + const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit }, JETZT)) + assert.equal(err.status, 409, zeit) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.ok(err.fehler.zeit) + } + assert.equal(claimsStand(), standVorher) + assert.equal(JSON.stringify(buchung(a.id)), docVorher) + assert.equal(sink.sent.length, mails) + assert.equal(eintraege().filter((d) => d.art === 'G07' || d.art === 'K21').length, 0) +}) + +test('verschiebeGast: verliert das Belegen den Wettlauf (Zellen gehoeren einer anderen Buchung), bleibt alles beim Alten', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) + // eine fremde belegende Buchung vom Folgetag (nicht im Tageslesen) haelt Zellen zur Zielzeit: nur die Datenbank sieht den Konflikt + aw.seed('termineBookings', { + $id: 'x-fremd', featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, ressourceId: R1, status: 'bestaetigt', quelle: 'online', + startsAt: '2026-10-27T09:00:00.000Z', endsAt: '2026-10-27T09:45:00.000Z', blockEndsAt: '2026-10-27T09:50:00.000Z', + }) + for (const zelle of zellenFuer('2026-10-26T13:00:00.000Z', '2026-10-26T13:50:00.000Z')) { + aw.seed('termineClaims', { $id: zellenId(FEATURE, R1, zelle), featureId: FEATURE, ressourceId: R1, bookingId: 'x-fremd', zelle: iso(zelle), bis: '2026-10-26T13:50:00.000Z' }) + } + const standVorher = claimsStand() + const docVorher = JSON.stringify(buchung(a.id)) + const mails = sink.sent.length + + const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT)) // 14:00 MEZ = 13:00Z + assert.equal(err.status, 409) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.equal(claimsStand(), standVorher, 'alte Zellen unveraendert, keine neuen') + assert.equal(JSON.stringify(buchung(a.id)), docVorher) + assert.equal(sink.sent.length, mails) +}) + +test('verschiebeGast: auf einen anderen Tag; die alten Zellen sind weg, die neuen da (Wanduhr Berlin, MESZ -> MEZ)', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx, { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true }, zeit: '10:00' }) + const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '09:00' }, JETZT) + assert.deepEqual(erg.termin, { datum: 'Montag, 26.10.2026', zeit: '09:00' }) + const b = buchung(id) + assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z') + assert.deepEqual(zeitenIso(claims(id)), zellenFuer(b.startsAt, b.blockEndsAt).map(iso)) + assert.equal(claims(id).length, 10) +}) + +test('verschiebeGast: Frist wie beim Absagen (409 "Absage nur telefonisch", unveraendert); eine Anfrage laesst sich verschieben, G07 ohne Kalenderdatei', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) + const start = new Date(a.buchung.startsAt) + const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN + 1))) + assert.deepEqual([err.status, err.code, err.fehler._], [409, 'frist_vorbei', 'Absage nur telefonisch: 06301 1234']) + assert.equal(buchung(a.id).startsAt, a.buchung.startsAt) + assert.equal((await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN))).status, 'bestaetigt') + + const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' }) + const vorher = sink.sent.length + const erg = await verschiebeGast(anfrage.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT) + await warteAufVersand() + assert.equal(erg.status, 'angefragt') + assert.equal(buchung(anfrage.id).status, 'angefragt') + const g07 = an(sink.sent.slice(vorher), 'zwei@example.com')[0] + assert.equal(g07.subject, 'Ihr Termin wurde verschoben') + assert.equal('icalEvent' in g07, false, 'unbestaetigte Anfrage: keine Einladung') + assert.equal(an(sink.sent.slice(vorher), 'info@salon.de')[0].subject, 'Termin verschoben: Besichtigung, jetzt am 26.10.2026 um 15:00') +}) + +test('verschiebeGast: Eingaben pruefen (400 mit Feldern), nicht angebotene Zeit und dieselbe Zeit', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + const faelle = [ + [{}, ['tag', 'zeit']], + [{ tag: 'morgen', zeit: '10:30' }, ['tag']], + [{ tag: '2026-02-30', zeit: '10:30' }, ['tag']], + [{ tag: TAG_FERN, zeit: '25:00' }, ['zeit']], + [{ tag: TAG_FERN, zeit: 1030 }, ['zeit']], + [{ tag: 5, zeit: null }, ['tag', 'zeit']], + ] + for (const [roh, felder] of faelle) { + const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) + assert.equal(err.status, 400, JSON.stringify(roh)) + assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh)) + } + assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, null, JETZT))).status, 400) + assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, undefined, JETZT))).status, 400) + + // nicht im Raster, ausserhalb der Oeffnungszeit, an einem Tag ohne Plan: 409 Zeit vergeben + for (const roh of [{ tag: TAG_FERN, zeit: '10:07' }, { tag: TAG_FERN, zeit: '08:00' }, { tag: '2026-10-27', zeit: '10:00' }]) { + const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) + assert.equal(err.status, 409, JSON.stringify(roh)) + assert.equal(err.code, 'SLOT_VERGEBEN') + } + // dieselbe Zeit: nichts zu tun + const gleich = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT)) + assert.equal(gleich.status, 400) + assert.ok(gleich.fehler.zeit) + assert.equal(buchung(id).icsSequenz, 0) + assert.deepEqual(arten(), ['G01', 'G02', 'K20']) +}) + +test('verschiebeGast: Person nicht waehlbar => auf einen anderen Platz, wenn der gleiche belegt ist; sonst bleibt die Ressource', async () => { + const frei = await speichere(einstellung({ regeln: { ...eingabeEinstellung().regeln, personWaehlbar: false } })) + const a = await gebucht(frei, { zeit: '10:00' }) + const b = await gebucht(frei, { zeit: '14:00', email: 'zwei@example.com' }) // R1 14:00-14:50 + assert.equal(a.buchung.ressourceId, R1) + assert.equal(b.buchung.ressourceId, R1) + + // 11:00: R1 und R2 frei => die eigene Ressource bleibt + await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '11:00' }, JETZT) + assert.equal(buchung(a.id).ressourceId, R1) + + // 14:00: R1 ist belegt, R2 frei => Wechsel zu Ben; Zellen nur noch auf R2, auch zur selben Zeit nicht doppelt + const erg = await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT) + await warteAufVersand() + assert.equal(erg.status, 'bestaetigt') + const neu = buchung(a.id) + assert.equal(neu.ressourceId, R2) + assert.equal(neu.ressourceName, 'Ben') + assert.equal(claims(a.id).length, 10) + assert.ok(claims(a.id).every((c) => c.ressourceId === R2)) + assert.deepEqual(zeitenIso(claims(a.id)), zellenFuer(neu.startsAt, neu.blockEndsAt).map(iso)) + assert.equal(claims(b.id).length, 10, 'die Zellen der anderen Buchung bleiben') + + // zurueck auf die Zeit von vorhin: R1 ist wieder die bevorzugte? Nein - die eigene Ressource ist jetzt R2 und frei + await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT) + assert.equal(buchung(a.id).ressourceId, R2) + + // Person waehlbar (Vorgabe): nur die eigene Ressource wird angeboten + const gewaehlt = await speichere() + const c = await gebucht(gewaehlt, { zeit: '12:00', email: 'drei@example.com' }) + await gebucht(gewaehlt, { zeit: '16:00', email: 'vier@example.com' }) // R1 16:00-16:50 + const err = await fehlerVon(() => verschiebeGast(c.verwaltung, { tag: TAG_FERN, zeit: '16:00' }, JETZT)) + assert.equal(err.code, 'SLOT_VERGEBEN') + assert.equal(buchung(c.id).ressourceId, R1) +}) + +test('verschiebeGast: zwei Gaeste verschieben gleichzeitig auf dieselbe Zeit => genau einer gewinnt, der andere behaelt alles', async () => { + const ctx = await speichere() + const a = await gebucht(ctx, { zeit: '10:00' }) + const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) + const standB = JSON.stringify(zeitenIso(claims(b.id))) + const ergebnisse = await Promise.allSettled([ + verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT), + verschiebeGast(b.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT), + ]) + await warteAufVersand() + assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected']) + const verlierer = ergebnisse[0].status === 'rejected' ? ergebnisse[0] : ergebnisse[1] + assert.equal(verlierer.reason.code, 'SLOT_VERGEBEN') + const [gewinnerId, verliererId] = ergebnisse[0].status === 'fulfilled' ? [a.id, b.id] : [b.id, a.id] + assert.equal(iso(buchung(gewinnerId).startsAt), '2026-10-26T14:00:00.000Z') + assert.equal(claims(gewinnerId).length, 10) + assert.equal(claims(verliererId).length, 10) + if (verliererId === b.id) assert.equal(JSON.stringify(zeitenIso(claims(b.id))), standB) + assert.equal(buchung(verliererId).icsSequenz, 0) + assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1) +}) + +test('verschiebeGast: Doppelklick auf dieselbe neue Zeit verschiebt einmal (Sequenz 1, eine G07)', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + const ergebnisse = await Promise.allSettled([ + verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT), + verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT), + ]) + await warteAufVersand() + assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected']) + assert.equal(ergebnisse.find((x) => x.status === 'rejected').reason.status, 400) + assert.equal(buchung(id).icsSequenz, 1) + assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1) + assert.equal(claims(id).length, 10) +}) + +test('verschiebeGast: scheitert das Speichern der Buchung, werden die neuen Zellen wieder entfernt', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + const standVorher = claimsStand() + const docVorher = JSON.stringify(buchung(id)) + const mails = sink.sent.length + aw.failNext({ collection: 'termineBookings', method: 'PATCH', status: 500 }) + const err = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT)) + assert.equal(err.status, 500) + assert.equal(JSON.stringify(buchung(id)), docVorher) + assert.equal(claimsStand(), standVorher, 'nur die alten Zellen') + assert.equal(sink.sent.length, mails) +}) + +test('verschiebeGast: scheitern die Mails, bleibt die neue Zeit bestehen; Dauer und Pause bleiben die der Buchung', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) // 45 + 5 Minuten + // der Betrieb aendert die Leistung nachtraeglich auf 60 + 10 Minuten + await speichere(einstellung({ leistungen: [{ ...eingabeEinstellung().leistungen[0], dauer: 60, pufferNach: 10 }, eingabeEinstellung().leistungen[1]] })) + const erg = await mitDefektemPostausgang(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT)) + assert.equal(erg.status, 'bestaetigt') + const b = buchung(id) + assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z') + assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z', 'weiter 45 Minuten') + assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z', 'weiter 5 Minuten Pause') + assert.equal(claims(id).length, 10) + const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) + assert.match(protokoll, /G07/) + assert.match(protokoll, /K21/) + assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) +}) + +test('verschiebeGast: eine fuer die alte Zeit gesendete Erinnerung wird zurueckgesetzt, damit die neue Zeit wieder eine bekommt', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true }) + const keine = await gebucht(ctx, { zeit: '14:00', email: 'zwei@example.com' }) + aw.seed('termineBookings', { ...b, erinnerungGesendetAt: iso(plus(JETZT, 60 * MIN)) }) + await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT) + assert.equal(buchung(id).erinnerungGesendetAt ?? null, null) + assert.equal(buchung(id).erinnerung, true, 'der Wunsch nach einer Erinnerung bleibt') + await verschiebeGast(keine.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT) + assert.equal(buchung(keine.id).erinnerungGesendetAt ?? null, null) +}) + +test('verschiebeGast: Token, Modus und Uhr werden geprueft; abgesagte Termine lassen sich nicht verschieben', async () => { + const ctx = await speichere() + const { id, verwaltung, token } = await gebucht(ctx) + const roh = { tag: TAG_FERN, zeit: '10:30' } + for (const falsch of ['', 'A'.repeat(43), null, token]) assert.equal((await fehlerVon(() => verschiebeGast(falsch, roh, JETZT))).status, 404) + await assert.rejects(() => verschiebeGast(verwaltung, roh, undefined), TypeError) + await sageAbGast(verwaltung, JETZT) + const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT)) + assert.deepEqual([err.status, err.code], [409, 'nicht_moeglich']) + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(claims(id).length, 0) +}) + +// ---------------------------------------------------------------- Zeiten fuer das Verschieben + +test('zeitenFuerToken { tag }: freie Zeiten ohne die eigene Buchung (die eigene Zeit ist wieder frei), nur HH:MM', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) // R1 10:00-10:50 + await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) // R1 12:00-12:50 + const erg = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT) + assert.deepEqual(Object.keys(erg), ['zeiten']) + for (const z of ['09:00', '10:00', '10:15', '10:30', '11:00']) assert.ok(erg.zeiten.includes(z), `${z} ist frei`) + for (const z of ['11:15', '11:30', '11:45', '12:00', '12:30']) assert.ok(!erg.zeiten.includes(z), `${z} ueberlappt die andere Buchung (12:00, Block 50 Minuten)`) + assert.ok(erg.zeiten.includes('13:00')) + assert.deepEqual([...erg.zeiten].sort(), erg.zeiten, 'aufsteigend') + assert.ok(erg.zeiten.every((z) => /^\d\d:\d\d$/.test(z))) + assert.equal(JSON.stringify(erg).includes(FEATURE), false) +}) + +test('zeitenFuerToken { monat }: Tage mit freien Zeiten und Monatsdaten; die eigene Zeit zaehlt als frei', async () => { + // Montag nur 10:00-10:50 offen: genau eine Startzeit, die der Termin selbst belegt + const eng = einstellung({ + ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['10:00', '10:50']] } }], + leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, ressourcen: [R1] }], + }) + const ctx = await speichere(eng) + const a = await gebucht(ctx, { zustimmungen: { storno: true, vorzeitig: false } }) + const erg = await zeitenFuerToken(a.verwaltung, { monat: '2026-10' }, JETZT) + assert.deepEqual(Object.keys(erg).sort(), ['monat', 'tage']) + assert.deepEqual(erg.monat, monatInfo('2026-10')) + assert.ok(erg.tage.includes(TAG_FERN), 'der Tag des eigenen Termins bleibt waehlbar') + assert.ok(erg.tage.includes('2026-10-19')) + assert.ok(!erg.tage.includes('2026-10-27'), 'Dienstag hat keinen Plan') + assert.deepEqual([...erg.tage].sort(), erg.tage) + assert.ok(erg.tage.every((t) => t.startsWith('2026-10-'))) + const tag = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT) + assert.deepEqual(tag.zeiten, ['10:00']) +}) + +test('zeitenFuerToken: Eingaben, Token, Frist und Uhr', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) + for (const roh of [{}, { tag: 'x' }, { monat: '2026-13' }, { monat: 5 }, null, undefined]) { + const err = await fehlerVon(() => zeitenFuerToken(a.verwaltung, roh, JETZT)) + assert.equal(err.status, 400, JSON.stringify(roh)) + } + assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: 'x' }, JETZT))).fehler.tag) + assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { monat: '2026-13' }, JETZT))).fehler.monat) + assert.equal((await fehlerVon(() => zeitenFuerToken(a.token, { tag: TAG_FERN }, JETZT))).status, 404, 'Bestaetigungstoken genuegt nicht') + assert.equal((await fehlerVon(() => zeitenFuerToken('A'.repeat(43), { tag: TAG_FERN }, JETZT))).status, 404) + // nach der Absagefrist wird nichts angeboten + const spaet = plus(new Date(a.buchung.startsAt), -60 * MIN) + assert.equal((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, spaet))).code, 'frist_vorbei') + await assert.rejects(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, undefined), TypeError) + assert.equal(buchung(a.id).status, 'bestaetigt', 'die Abfrage aendert nichts') +}) + +// ---------------------------------------------------------------- Widerruf per Link + +test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenAt; 14 Tage, Beginn, Verbraucher, Handeintrag', () => { + const basis = { buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: iso(JETZT), startsAt: iso(plus(JETZT, 30 * TAG_MS)) } + assert.equal(kannWiderrufen(basis, plus(JETZT, 14 * TAG_MS - 1)), true) + assert.equal(kannWiderrufen(basis, plus(JETZT, 14 * TAG_MS)), false) + assert.equal(kannWiderrufen(basis, plus(new Date(basis.startsAt), 0)), false, 'Termin hat begonnen') + assert.equal(kannWiderrufen({ ...basis, startsAt: iso(plus(JETZT, 3 * TAG_MS)) }, plus(JETZT, 3 * TAG_MS - 1)), true, 'bis zum Beginn') + assert.equal(kannWiderrufen({ ...basis, verbraucher: false }, JETZT), false) + assert.equal(kannWiderrufen({ ...basis, status: 'angefragt' }, JETZT), false) + assert.equal(kannWiderrufen({ ...basis, status: 'abgesagt' }, JETZT), false) + assert.equal(kannWiderrufen({ ...basis, quelle: 'manuell' }, JETZT), false) + assert.equal(kannWiderrufen({ ...basis, bestaetigtAt: undefined }, JETZT), false) + + const anfrage = { ...basis, buchungsart: 'anfrage', bestaetigtAt: undefined, angenommenAt: iso(plus(JETZT, 2 * TAG_MS)) } + assert.equal(kannWiderrufen(anfrage, plus(JETZT, 3 * TAG_MS)), true, 'angenommene Anfrage') + assert.equal(kannWiderrufen(anfrage, plus(JETZT, 16 * TAG_MS - 1)), true, 'die Frist zaehlt ab der Annahme') + assert.equal(kannWiderrufen(anfrage, plus(JETZT, 16 * TAG_MS)), false) + assert.equal(kannWiderrufen({ ...anfrage, angenommenAt: undefined }, JETZT), false, 'ohne Annahme kein Vertrag') + assert.equal(kannWiderrufen({ ...anfrage, bestaetigtAt: iso(JETZT), angenommenAt: undefined }, JETZT), false, 'bestaetigtAt allein macht eine Anfrage nicht verbindlich') +}) + +test('widerrufeGast: 2 Stunden vor dem Termin trotz abgelaufener Stornofrist; abgesagt von gast, Zellen frei, G08 zugeordnet, K21 widerrufen', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx, NAH) + const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) // 12.10.2026 06:00Z = 08:00 MESZ + assert.equal((await fehlerVon(() => sageAbGast(verwaltung, jetzt))).code, 'frist_vorbei', 'Absagen waere zu spaet') + assert.equal((await vorschau(verwaltung, jetzt)).aktionen.widerrufen, true) + + const erg = await widerrufeGast(verwaltung, { name: ' Max Mustermann ' }, jetzt) + await warteAufVersand() + assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) + + const nachher = buchung(id) + assert.equal(nachher.status, 'abgesagt') + assert.equal(nachher.abgesagtVon, 'gast') + assert.equal(iso(nachher.widerrufenAt), iso(jetzt)) + assert.equal(iso(nachher.abgesagtAt), iso(jetzt)) + assert.match(nachher.absageGrund, /Widerruf/) + assert.match(nachher.absageGrund, /Max Mustermann/) + assert.equal(claims(id).length, 0) + + const neu = sink.sent.slice(mailStart + 2) + assert.equal(neu.length, 2) + const g08 = an(neu, 'max@example.com')[0] + assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') + assert.match(g08.text, /12\.10\.2026, 08:00 Uhr/) + assert.ok(g08.text.includes(buchungsNummer(id))) + assert.match(g08.text, /Der Termin ist abgesagt/) + assert.doesNotMatch(g08.text, /Mustermann/, 'kein Text des Gastes in G08') + assert.equal('icalEvent' in g08, false) + const k21 = an(neu, 'info@salon.de')[0] + assert.equal(k21.subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00') + assert.match(k21.text, /12\.10\.2026, 08:00 Uhr/) + assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) +}) + +test('widerrufeGast: Eingang in Berliner Zeit (22:30 UTC = 00:30 am Folgetag)', async () => { + const ctx = await speichere() + const { verwaltung } = await gebucht(ctx) + const erg = await widerrufeGast(verwaltung, { name: 'Max' }, new Date('2026-10-10T22:30:00.000Z')) + assert.deepEqual(erg, { eingang: { datum: '11.10.2026', uhrzeit: '00:30' } }) +}) + +test('widerrufeGast: die 14 Tage gelten ab der Bestaetigung; am letzten Moment wirksam, danach nur gespeichert und gemeldet (nach_frist)', async () => { + const ctx = await speichere() + const a = await gebucht(ctx) // Termin 21 Tage nach der Buchung + const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) + const vorher = sink.sent.length + + await widerrufeGast(a.verwaltung, { name: 'Max' }, plus(JETZT, 14 * TAG_MS - 1)) + assert.equal(buchung(a.id).status, 'abgesagt') + + const spaet = plus(JETZT, 14 * TAG_MS) + const erg = await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, spaet) + await warteAufVersand() + assert.deepEqual(erg, { eingang: { datum: '19.10.2026', uhrzeit: '10:00' } }) + const nachher = buchung(b.id) + assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt') + assert.equal(nachher.widerrufenAt, undefined) + assert.equal(claims(b.id).length, 10) + assert.match(nachher.absageGrund, /nach Ablauf der Frist/) + assert.match(nachher.absageGrund, /Erika Beispiel/) + + const neu = sink.sent.slice(vorher).filter((m) => /Widerruf/i.test(m.subject)) + assert.equal(neu.length, 4) + const g08 = an(neu, 'zwei@example.com')[0] + assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') + assert.match(g08.text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) + assert.doesNotMatch(g08.text, /Der Termin ist abgesagt/) + assert.equal(an(neu, 'info@salon.de').find((m) => /nach Ablauf/i.test(m.subject)).subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 12:00') +}) + +test('widerrufeGast: eine nicht angenommene Anfrage kann nicht widerrufen werden (409), nur absagen; ebenso Unternehmer', async () => { + const ctx = await speichere() + const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE }) + assert.equal(anfrage.buchung.status, 'angefragt') + const mails = sink.sent.length + const standVorher = claimsStand() + const err = await fehlerVon(() => widerrufeGast(anfrage.verwaltung, { name: 'Max' }, JETZT)) + assert.deepEqual([err.status, err.code], [409, 'kein_widerruf']) + assert.ok(err.fehler._) + assert.equal(buchung(anfrage.id).status, 'angefragt') + assert.equal(buchung(anfrage.id).widerrufenAt, undefined) + assert.equal(buchung(anfrage.id).absageGrund, undefined) + assert.equal(claimsStand(), standVorher) + assert.equal(sink.sent.length, mails) + assert.deepEqual(await sageAbGast(anfrage.verwaltung, JETZT), { status: 'abgesagt' }) + + const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } })) + const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' }) + assert.equal(u.buchung.verbraucher, false) + assert.equal((await fehlerVon(() => widerrufeGast(u.verwaltung, { name: 'Max' }, JETZT))).code, 'kein_widerruf') + assert.equal(buchung(u.id).status, 'bestaetigt') +}) + +test('widerrufeGast: eine vom Betrieb angenommene Anfrage ist widerrufbar, die Frist laeuft ab angenommenAt', async () => { + const ctx = await speichere() + const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE }) + // Annahme durch den Betrieb (Aufgabe 14) von Hand nachgestellt + const angenommen = plus(JETZT, 2 * TAG_MS) + aw.seed('termineBookings', { ...b, status: 'bestaetigt', angenommenAt: iso(angenommen) }) + assert.equal((await vorschau(verwaltung, plus(angenommen, 13 * TAG_MS))).aktionen.widerrufen, true) + assert.equal((await vorschau(verwaltung, plus(angenommen, 14 * TAG_MS))).aktionen.widerrufen, false) + const jetzt = plus(angenommen, 13 * TAG_MS) // 20.10.2026, der Termin am 26.10. liegt noch in der Zukunft + const erg = await widerrufeGast(verwaltung, { name: 'Max' }, jetzt) + assert.ok(erg.eingang) + assert.equal(buchung(id).status, 'abgesagt') + assert.equal(iso(buchung(id).widerrufenAt), iso(jetzt)) + assert.equal(claims(id).length, 0) +}) + +test('widerrufeGast: zweiter und paralleler Klick melden denselben Eingang und senden nichts doppelt', async () => { + const ctx = await speichere() + const { id, verwaltung } = await gebucht(ctx) + const erste = plus(JETZT, 60 * MIN) + const erg = await Promise.all([widerrufeGast(verwaltung, { name: 'Max' }, erste), widerrufeGast(verwaltung, { name: 'Max' }, erste)]) + await warteAufVersand() + assert.deepEqual(erg[0], erg[1]) + assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) + const spaeter = await widerrufeGast(verwaltung, { name: 'Max' }, plus(erste, 3 * 60 * MIN)) + await warteAufVersand() + assert.deepEqual(spaeter, erg[0], 'der Eingang der ersten Erklaerung') + assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) + assert.equal(iso(buchung(id).widerrufenAt), iso(erste)) +}) + +test('widerrufeGast: Name Pflicht (400), Token und Uhr; scheitern die Mails, bleibt der Widerruf bestehen', async () => { + const ctx = await speichere() + const { id, verwaltung, token } = await gebucht(ctx) + for (const name of ['', ' ', undefined, null, 5, 'x'.repeat(101)]) { + const err = await fehlerVon(() => widerrufeGast(verwaltung, { name }, JETZT)) + assert.equal(err.status, 400, String(name).slice(0, 5)) + assert.deepEqual(Object.keys(err.fehler), ['name']) + } + assert.equal((await fehlerVon(() => widerrufeGast(verwaltung, null, JETZT))).status, 400) + assert.equal((await fehlerVon(() => widerrufeGast(token, { name: 'Max' }, JETZT))).status, 404) + assert.equal((await fehlerVon(() => widerrufeGast('A'.repeat(43), { name: 'Max' }, JETZT))).status, 404) + await assert.rejects(() => widerrufeGast(verwaltung, { name: 'Max' }, undefined), TypeError) + assert.equal(buchung(id).status, 'bestaetigt') + + const erg = await mitDefektemPostausgang(() => widerrufeGast(verwaltung, { name: 'Max' }, JETZT)) + assert.ok(erg.eingang) + assert.equal(buchung(id).status, 'abgesagt') + const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments)) + assert.match(protokoll, /G08/) + assert.match(protokoll, /K21/) + assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/) +}) + +// ---------------------------------------------------------------- Widerruf ohne Link + +test('widerrufeOhneLink: Treffer mit Widerrufsrecht; Adresse klein und getrimmt, Nummer egal in welcher Schreibweise; G08 an die angegebene Adresse', async () => { + const ctx = await speichere() + const { id, buchung: b, mailStart } = await gebucht(ctx, NAH) + const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) + const erg = await widerrufeOhneLink(ctx, { name: ' Max Mustermann ', email: ' Max@Example.COM ', nummer: ` ${buchungsNummer(id).toLowerCase()} ` }, jetzt) + await warteAufVersand() + assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) + + const nachher = buchung(id) + assert.equal(nachher.status, 'abgesagt') + assert.equal(nachher.abgesagtVon, 'gast') + assert.equal(iso(nachher.widerrufenAt), iso(jetzt)) + assert.equal(claims(id).length, 0) + const neu = sink.sent.slice(mailStart + 2) + const g08 = an(neu, 'max@example.com')[0] + assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') + assert.ok(g08.text.includes(buchungsNummer(id))) + assert.match(g08.text, /Der Termin ist abgesagt/) + assert.doesNotMatch(g08.text, /Mustermann/) + assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00') + assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21']) +}) + +test('widerrufeOhneLink: ohne Treffer dieselbe Antwort; G08 ohne Bezug an die angegebene Adresse, K21 nicht_zugeordnet an den Betrieb', async () => { + const ctx = await speichere() + const { id, buchung: b, mailStart } = await gebucht(ctx, NAH) + const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) + const doc = JSON.stringify(buchung(id)) + const stand = claimsStand() + + const richtig = await widerrufeOhneLink(ctx, { name: 'Erika', email: 'erika@example.com', nummer: 'ZZZZZZZZ' }, jetzt) // Nummer passt zu keiner Buchung + const fremdeAdresse = await widerrufeOhneLink(ctx, { name: 'Max', email: 'anderer@example.com', nummer: buchungsNummer(id) }, jetzt) // Nummer passt, Adresse nicht + const kaputt = await widerrufeOhneLink(ctx, { name: 'Hans', email: 'hans@example.com', nummer: 'abc' }, jetzt) + const leer = await widerrufeOhneLink(ctx, { name: 'Lena', email: 'lena@example.com', nummer: '' }, jetzt) + const keineZeichenkette = await widerrufeOhneLink(ctx, { name: 'Tom', email: 'tom@example.com', nummer: 12345678 }, jetzt) + await warteAufVersand() + const antworten = [richtig, fremdeAdresse, kaputt, leer, keineZeichenkette] + for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } }) + + assert.equal(JSON.stringify(buchung(id)), doc, 'nichts an der Buchung geaendert') + assert.equal(claimsStand(), stand) + const neu = sink.sent.slice(mailStart + 2) + assert.equal(neu.length, 10, '5 x G08 + 5 x K21') + for (const adresse of ['erika@example.com', 'anderer@example.com', 'hans@example.com', 'lena@example.com', 'tom@example.com']) { + const g08 = an(neu, adresse) + assert.equal(g08.length, 1, adresse) + assert.equal(g08[0].subject, 'Eingangsbestätigung Ihres Widerrufs') + assert.doesNotMatch(g08[0].text, /Der Termin ist abgesagt|Widerrufsfrist|Buchung [A-Z0-9]{8}/) + assert.doesNotMatch(g08[0].text, /Erika|Hans|Lena|Tom\b|ZZZZZZZZ/, 'kein vom Gast eingegebener Text') + assert.ok(g08[0].text.includes('12.10.2026, 08:00 Uhr')) + } + const k21 = an(neu, 'info@salon.de') + assert.equal(k21.length, 5) + for (const m of k21) assert.equal(m.subject, 'Widerruf ohne passende Buchung') + const erika = k21.find((m) => m.text.includes('erika@example.com')) + assert.match(erika.text, /Erika/) + assert.match(erika.text, /ZZZZZZZZ/, 'die angegebene Nummer steht beim Betrieb zur Pruefung') + assert.equal('bcc' in erika, false) + assert.equal(eintraege().filter((d) => d.art === 'K21' && d.bookingId !== '-').length, 0) +}) + +test('widerrufeOhneLink: Treffer nach Ablauf der Frist => nur gespeichert, G08 nach_frist und K21 nach_frist, Termin bleibt', async () => { + const ctx = await speichere() + const { id, mailStart } = await gebucht(ctx) + const jetzt = plus(JETZT, 15 * TAG_MS) + const erg = await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(id) }, jetzt) + await warteAufVersand() + assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '10:00' } }) + const b = buchung(id) + assert.equal(b.status, 'bestaetigt') + assert.equal(b.widerrufenAt, undefined) + assert.equal(claims(id).length, 10) + assert.match(b.absageGrund, /nach Ablauf der Frist/) + assert.match(b.absageGrund, /Max Mustermann/) + const neu = sink.sent.slice(mailStart + 2) + assert.match(an(neu, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) + assert.equal(an(neu, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 10:00') +}) + +test('widerrufeOhneLink: Buchung ohne Widerrufsrecht (nicht angenommene Anfrage, Unternehmer) => wie ohne Treffer, nichts geaendert', async () => { + const ctx = await speichere() + const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE }) + const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } })) + const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' }) + const vorher = sink.sent.length + const docA = JSON.stringify(buchung(anfrage.id)) + const docU = JSON.stringify(buchung(u.id)) + const a1 = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(anfrage.id) }, JETZT) + const a2 = await widerrufeOhneLink(unternehmer, { name: 'Zwei', email: 'zwei@example.com', nummer: buchungsNummer(u.id) }, JETZT) + await warteAufVersand() + assert.deepEqual(a1, a2) + assert.equal(JSON.stringify(buchung(anfrage.id)), docA) + assert.equal(JSON.stringify(buchung(u.id)), docU) + assert.equal(claims(anfrage.id).length, 12) + const neu = sink.sent.slice(vorher) + assert.equal(an(neu, 'info@salon.de').filter((m) => m.subject === 'Widerruf ohne passende Buchung').length, 2) + assert.equal(an(neu, 'max@example.com').length, 1) +}) + +test('widerrufeOhneLink: vierter G08 an dieselbe Adresse am Tag wird nicht gesendet, die Antwort bleibt gleich; am naechsten Tag wieder', async () => { + const ctx = await speichere() + const roh = { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' } + const antworten = [] + for (let i = 0; i < 4; i += 1) antworten.push(await widerrufeOhneLink(ctx, roh, plus(JETZT, i * MIN))) + await warteAufVersand() + for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '05.10.2026', uhrzeit: a.eingang.uhrzeit } }) + assert.deepEqual(antworten.map((a) => a.eingang.uhrzeit), ['10:00', '10:01', '10:02', '10:03'], 'die Zeit der jeweiligen Erklaerung') + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3) + assert.equal(eintraege().filter((d) => d.art === 'K21').length, 4, 'der Betrieb bekommt jede Erklaerung') + const protokoll = JSON.stringify([...log.error.mock.calls, ...log.warn.mock.calls].map((c) => c.arguments)) + assert.match(protokoll, /G08/) + assert.doesNotMatch(protokoll, /max@example\.com|Max\b/) + + await widerrufeOhneLink(ctx, roh, plus(JETZT, TAG_MS)) + await warteAufVersand() + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 4) +}) + +test('widerrufeOhneLink: 20 G08 je Funktion und Stunde, 10 K21 nicht_zugeordnet je Funktion und Stunde; weitere werden still uebersprungen', async () => { + const ctx = await speichere() + const antworten = [] + for (let i = 0; i < 22; i += 1) antworten.push(await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT)) + await warteAufVersand() + for (const a of antworten) assert.deepEqual(a, antworten[0]) + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 20) + assert.equal(eintraege().filter((d) => d.art === 'K21').length, 10) + + // eine Stunde spaeter beginnen beide Fenster neu + await widerrufeOhneLink(ctx, { name: 'Spaet', email: 'spaet@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 60 * MIN)) + await warteAufVersand() + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 21) + assert.equal(eintraege().filter((d) => d.art === 'K21').length, 11) +}) + +test('widerrufeOhneLink: Grenzen gelten auch fuer Treffer; bei erreichter G08-Grenze geht der Widerruf trotzdem durch', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + for (let i = 0; i < 3; i += 1) await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, JETZT) + await warteAufVersand() + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3) + const erg = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(id) }, plus(JETZT, MIN)) + await warteAufVersand() + assert.deepEqual(erg, { eingang: { datum: '05.10.2026', uhrzeit: '10:01' } }) + assert.equal(buchung(id).status, 'abgesagt', 'der Widerruf ist wirksam') + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3, 'aber keine vierte Bestaetigung') + assert.equal(eintraege().filter((d) => d.art === 'K21' && d.bookingId === id).length, 1, 'der Betrieb erfaehrt davon') +}) + +test('widerrufeOhneLink: Eingaben - Name und E-Mail Pflicht (400), sonst nichts gespeichert und keine Mail', async () => { + const ctx = await speichere() + const faelle = [ + [{ email: 'max@example.com', nummer: 'ZZZZZZZZ' }, ['name']], + [{ name: ' ', email: 'max@example.com' }, ['name']], + [{ name: 'x'.repeat(101), email: 'max@example.com' }, ['name']], + [{ name: 'Max', email: 'a@example.com, b@example.com' }, ['email']], + [{ name: 'Max', email: 'keine-adresse' }, ['email']], + [{ name: 'Max', email: 5 }, ['email']], + [{ name: '', email: '' }, ['email', 'name']], + ] + for (const [roh, felder] of faelle) { + const err = await fehlerVon(() => widerrufeOhneLink(ctx, roh, JETZT)) + assert.equal(err.status, 400, JSON.stringify(roh)) + assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh)) + } + assert.equal((await fehlerVon(() => widerrufeOhneLink(ctx, null, JETZT))).status, 400) + assert.equal(eintraege().length, 0) + await assert.rejects(() => widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com' }, undefined), TypeError) + await assert.rejects(() => widerrufeOhneLink({ feature: feature(), einstellung: null }, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError) + await assert.rejects(() => widerrufeOhneLink(null, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError) +}) + +test('widerrufeOhneLink: Erklaerung zu einer schon widerrufenen Buchung aendert nichts und sendet nichts; Antwort wie immer', async () => { + const ctx = await speichere() + const { id } = await gebucht(ctx) + const roh = { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(id) } + await widerrufeOhneLink(ctx, roh, JETZT) + await warteAufVersand() + const mails = eintraege().length + const zweite = await widerrufeOhneLink(ctx, roh, plus(JETZT, 5 * MIN)) + await warteAufVersand() + assert.deepEqual(zweite, { eingang: { datum: '05.10.2026', uhrzeit: '10:05' } }) + assert.equal(iso(buchung(id).widerrufenAt), iso(JETZT)) + assert.equal(eintraege().length, mails) +}) + +test('Mail G06 ohne Kalenderdatei: anGast mit ohneIcs laesst die Datei weg, ohne die Option bleibt sie', async () => { + const { anGast } = await import('../server/services/termine/mails.js') + const ctx = await speichere() + const { buchung: b } = await gebucht(ctx) + await anGast('G06', ctx, b, { von: 'gast' }, JETZT) + await anGast('G06', ctx, b, { von: 'gast', ohneIcs: true }, JETZT) + await warteAufVersand() + const g06 = sink.sent.filter((m) => /abgesagt/.test(m.subject)) + assert.equal(g06.length, 2) + assert.deepEqual(g06.map((m) => 'icalEvent' in m).sort(), [false, true]) +}) diff --git a/test/termineLimits.test.js b/test/termineLimits.test.js new file mode 100644 index 0000000..bf6a971 --- /dev/null +++ b/test/termineLimits.test.js @@ -0,0 +1,83 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { zaehle, zaehleAlle, darfG08, darfK21NichtZugeordnet, resetTermineLimitsForTests } from '../server/services/termine/limits.js' + +// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel. +const T0 = new Date('2026-10-05T08:00:00.000Z') +const STUNDE = 60 * 60 * 1000 +const TAG = 24 * STUNDE +const plus = (ms) => new Date(T0.getTime() + ms) + +beforeEach(() => resetTermineLimitsForTests()) + +test('zaehle: erlaubt bis max, danach false; das Fenster beginnt mit dem ersten Ereignis und endet nach fensterMs', () => { + assert.deepEqual([1, 2, 3].map(() => zaehle('a', 3, STUNDE, T0)), [true, true, true]) + assert.equal(zaehle('a', 3, STUNDE, plus(10 * 60000)), false) + assert.equal(zaehle('a', 3, STUNDE, plus(STUNDE - 1)), false, 'bis zur letzten Millisekunde des Fensters') + assert.equal(zaehle('b', 3, STUNDE, T0), true, 'andere Schluessel zaehlen getrennt') + // die abgelehnten Anfragen haben nichts gezaehlt: das neue Fenster (ab plus(STUNDE)) erlaubt wieder drei + assert.deepEqual([1, 2, 3, 4].map((i) => zaehle('a', 3, STUNDE, plus(STUNDE + i))), [true, true, true, false]) +}) + +test('zaehle: max 0 erlaubt nie, ungueltige Angaben werfen TypeError', () => { + assert.equal(zaehle('a', 0, STUNDE, T0), false) + assert.throws(() => zaehle('a', 1.5, STUNDE, T0), TypeError) + assert.throws(() => zaehle('a', -1, STUNDE, T0), TypeError) + assert.throws(() => zaehle('a', 1, 0, T0), TypeError) + assert.throws(() => zaehle('', 1, STUNDE, T0), TypeError) + assert.throws(() => zaehle('a', 1, STUNDE, undefined), TypeError) + assert.throws(() => zaehle('a', 1, STUNDE, Date.now()), TypeError) + assert.throws(() => zaehleAlle([], T0), TypeError) +}) + +test('zaehleAlle: alle oder keine - scheitert eine Regel, zaehlt keine', () => { + const regeln = [ + { schluessel: 'adresse', max: 3, fensterMs: TAG }, + { schluessel: 'funktion', max: 1, fensterMs: STUNDE }, + ] + assert.equal(zaehleAlle(regeln, T0), true) + assert.equal(zaehleAlle(regeln, plus(1000)), false, 'die Funktionsgrenze ist erreicht') + // der Adress-Zaehler wurde dabei nicht verbraucht: nach der Stunde sind noch zwei Ereignisse an diese Adresse frei + const nurAdresse = [{ schluessel: 'adresse', max: 3, fensterMs: TAG }] + assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true) + assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), true) + assert.equal(zaehleAlle(nurAdresse, plus(2 * STUNDE)), false) +}) + +test('darfG08: 3 je Adresse und Tag (auch ueber Funktionen), 20 je Funktion und Stunde', () => { + assert.deepEqual([1, 2, 3, 4].map(() => darfG08('f1', 'max@example.com', T0)), [true, true, true, false]) + assert.equal(darfG08('f2', 'max@example.com', plus(TAG - 1)), false, 'die Adresse zaehlt fuer alle Funktionen zusammen') + assert.equal(darfG08('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder') + + resetTermineLimitsForTests() + const erlaubt = Array.from({ length: 22 }, (_, i) => darfG08('f1', `gast${i}@example.com`, T0)) + assert.equal(erlaubt.filter(Boolean).length, 20) + assert.equal(darfG08('f2', 'neu@example.com', T0), true, 'andere Funktion hat ihr eigenes Kontingent') + assert.equal(darfG08('f1', 'spaet@example.com', plus(STUNDE)), true, 'nach einer Stunde wieder') +}) + +test('darfK21NichtZugeordnet: 10 je Funktion und Stunde', () => { + assert.equal(Array.from({ length: 12 }, () => darfK21NichtZugeordnet('f1', T0)).filter(Boolean).length, 10) + assert.equal(darfK21NichtZugeordnet('f2', T0), true) + assert.equal(darfK21NichtZugeordnet('f1', plus(STUNDE)), true) +}) + +test('Obergrenze: ueber 10 000 Schluessel faellt der aelteste Zaehler weg, die neueren bleiben', () => { + assert.equal(zaehle('aeltester', 1, TAG, T0), true) + assert.equal(zaehle('aeltester', 1, TAG, T0), false) + for (let i = 0; i < 9998; i += 1) zaehle(`k${i}`, 1, TAG, plus(1000)) + assert.equal(zaehle('aeltester', 1, TAG, T0), false, 'bei 9 999 Schluesseln noch da') + assert.equal(zaehle('k9997', 1, TAG, plus(2000)), false) + zaehle('neu1', 1, TAG, plus(3000)) // 10 000 + zaehle('neu2', 1, TAG, plus(3000)) // 10 001: der aelteste faellt weg + assert.equal(zaehle('aeltester', 1, TAG, plus(4000)), true, 'der aelteste Zaehler wurde verdraengt') + assert.equal(zaehle('neu2', 1, TAG, plus(4000)), false, 'der neueste bleibt') + assert.equal(zaehle('k9997', 1, TAG, plus(4000)), false, 'die juengeren bleiben') +}) + +test('resetTermineLimitsForTests leert alles', () => { + zaehle('a', 1, TAG, T0) + assert.equal(zaehle('a', 1, TAG, T0), false) + resetTermineLimitsForTests() + assert.equal(zaehle('a', 1, TAG, T0), true) +})