From d1470b894a757513c1e966f03905d97827741406 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:35:39 +0000 Subject: [PATCH] Terminplaner: Buchung halten und bestaetigen Gast-Ablauf in ablauf.js: halte (Eingabe pruefen, Slot neu berechnen, Buchung gehalten vor den Zellen, Grenze offener Termine mit G01 zu_viele), vorschau (Bestaetigungs- und Verwaltungstoken) und bestaetige (gehalten -> bestaetigt/angefragt, G02/G03 und K20). einstellungen.js exportiert text und emailAdresse, mails.js dauerText zur Wiederverwendung. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/ablauf.js | 522 +++++++++++++ server/services/termine/einstellungen.js | 4 +- server/services/termine/mails.js | 2 +- test/termineAblaufGast.test.js | 942 +++++++++++++++++++++++ 4 files changed, 1467 insertions(+), 3 deletions(-) create mode 100644 server/services/termine/ablauf.js create mode 100644 test/termineAblaufGast.test.js diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js new file mode 100644 index 0000000..43318da --- /dev/null +++ b/server/services/termine/ablauf.js @@ -0,0 +1,522 @@ +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 { + brauchtZustimmungVorzeitig, + emailAdresse, + istVerbraucher, + knopfText, + preisHinweis, + preisText, + text as bereinigterText, + zahlungText, +} from './einstellungen.js' +import { kontextPerFeature, websiteUrl } from './kontext.js' +import { anBetrieb, anGast, dauerText } from './mails.js' +import { + RECHTSTEXTE_VERSION, + ZUSTIMMUNG_ERINNERUNG, + ZUSTIMMUNG_STORNO, + ZUSTIMMUNG_VORZEITIG, + formularHinweis, + widerrufsbelehrung, +} from './rechtstexte.js' +import { belegungAus, blockEnde, 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' + +/** + * Gast-Ablauf des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4): Termin halten, + * Vorschau fuer die Gaesteseite, per Link bestaetigen. Reine Geschaeftsregeln ueber den Modulen buchungen, zellen, + * slots, mails; `now` ist immer ein Parameter. + * + * Fehler: Error mit `status` und `fehler` = { : Text } (Formularfehler 400, Zeit vergeben 409 mit code + * 'SLOT_VERGEBEN', Link unbekannt oder abgelaufen 404, Reservierung abgelaufen 410 mit code 'abgelaufen'). Die Texte sind + * ohne Anredeform formuliert ("Bitte eine Uhrzeit auswaehlen."), weil sie fuer Sie- und Du-Betriebe gleich gelten. + * + * Reihenfolge beim Halten (zellen.js): Buchung `gehalten` ZUERST anlegen, dann die Zellen; scheitert das Belegen + * (SLOT_VERGEBEN), steht die Buchung auf `verfallen` und hat keine Zellen. Wer zwei Sperren braucht, nimmt sie immer in + * der Reihenfolge Adresse (termine-gast) -> Buchung (termine-buchung) bzw. Ressource (termine-res, nur innerhalb von + * belegeZellen); termine-res wird hier nie selbst gehalten, die Sperren sind nicht wiedereintrittsfaehig. + * + * 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). + */ + +const MIN_MS = 60 * 1000 +const STUNDE_MS = 60 * MIN_MS +const TAG_MS = 24 * STUNDE_MS +const WIDERRUF_TAGE = 14 +const ADRESSE_WARTE_MS = 5000 +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 NACHRICHT_MAX = 500 +const TELEFON_MAX = 40 +const ZUSATZ_WERT_MAX = 200 +const ZUSATZ_JSON_MAX = 2000 + +const MELDUNG = { + formular: 'Bitte das Formular ausfüllen.', + leistung: 'Bitte eine Leistung auswählen.', + ressource: 'Bitte aus den angebotenen Möglichkeiten auswählen.', + tag: 'Bitte ein Datum auswählen.', + zeit: 'Bitte eine Uhrzeit auswä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.`, + email: 'Bitte eine gültige E-Mail-Adresse angeben (genau ein Postfach).', + telefonLeer: 'Bitte eine Telefonnummer angeben.', + telefon: 'Bitte eine gültige Telefonnummer angeben (Ziffern, Leerzeichen, + - und Klammern).', + nachrichtLeer: 'Bitte eine Nachricht angeben.', + nachrichtLang: `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.`, + zusatzLeer: 'Bitte dieses Feld ausfüllen.', + zusatzLang: `Höchstens ${ZUSATZ_WERT_MAX} Zeichen.`, + zusatzAuswahl: 'Bitte eine der angebotenen Möglichkeiten wählen.', + ungueltig: 'Ungültige Eingabe.', + storno: 'Bitte bestätigen, dass die Stornobedingungen gelesen wurden.', + 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.', +} + +const iso = (wert) => new Date(wert).toISOString() +const sha256 = (text) => createHash('sha256').update(text).digest('hex') +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Fehler mit Status und Feldfehlern; message ist die erste Meldung (nie an Gaeste ausgeben, dafuer gibt es fehler). */ +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra }) + +const zeitVergeben = () => fehlerMit(409, { zeit: MELDUNG.zeitVergeben }, { code: 'SLOT_VERGEBEN' }) +const unbekannt = () => fehlerMit(404, { _: MELDUNG.unbekannt }, { code: 'unbekannt' }) + +/** Betriebsangaben fuer eine abgelaufene Reservierung: Name, Telefon und Website (Link "zurueck zur Website"). */ +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) }) + +/** 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) : '-' + console.error(`[termine-ablauf] ${was} (${nummer}): ${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`) +} + +/** Mail einreihen; ein Fehler macht die schon erfolgte Zustandsaenderung nicht rueckgaengig. */ +async function mailSicher(was, bookingId, fn) { + try { + await fn() + } catch (err) { + protokolliere(`${was} nicht eingereiht`, bookingId, err) + } +} + +// ---------------------------------------------------------------- Eingabe pruefen + +/** + * Prueft die Eingabe des Formulars gegen die Einstellung. => geprueft und bereinigt; sonst 400 mit allen Feldfehlern. + * Abgeschaltete Gastfelder (aus) werden ignoriert, Zusatzfelder muessen konfiguriert sein. + */ +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 + } + + // Leistung, Ressource + const leistung = typeof roh.leistung === 'string' ? (e.leistungen || []).find((l) => l.id === roh.leistung && l.aktiv) : undefined + const aktiv = new Set((e.ressourcen || []).filter((r) => r.aktiv).map((r) => r.id)) + const infrage = leistung ? (leistung.ressourcen || []).filter((id) => aktiv.has(id)) : [] + if (!leistung || infrage.length === 0) fehl('leistung', MELDUNG.leistung) + let ressource = null + if (leistung && infrage.length > 0) { + if (roh.ressource === 'egal') { + if (e.regeln?.personWaehlbar === true || infrage.length > 1) ressource = 'egal' + } else if (typeof roh.ressource === 'string' && infrage.includes(roh.ressource)) { + ressource = roh.ressource + } + if (ressource === null) fehl('ressource', MELDUNG.ressource) + } + + // Tag, Uhrzeit + 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 + const start = tag !== null && zeit !== null ? zuUtc(tag, zeit) : null // null auch fuer die Luecke der Maerz-Umstellung + 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 email = emailAdresse(roh.email) + if (email === null) fehl('email', MELDUNG.email) + + // Telefon, Nachricht, Zusatzfelder nach gastFelder + const felder = e.gastFelder || {} + let telefon = '' + if (felder.telefon !== 'aus') { + telefon = bereinigterText(roh.telefon, TELEFON_MAX + 1) + if (telefon === '') { + if (felder.telefon === 'pflicht') fehl('telefon', MELDUNG.telefonLeer) + } else if ([...telefon].length > TELEFON_MAX || !TELEFON_RE.test(telefon) || (telefon.match(/\d/g) || []).length < 3) { + fehl('telefon', MELDUNG.telefon) + } + } + let nachricht = '' + if (felder.nachricht !== 'aus') { + nachricht = bereinigterText(roh.nachricht, NACHRICHT_MAX + 1, { mehrzeilig: true }) + if (nachricht === '') { + if (felder.nachricht === 'pflicht') fehl('nachricht', MELDUNG.nachrichtLeer) + } else if ([...nachricht].length > NACHRICHT_MAX) { + fehl('nachricht', MELDUNG.nachrichtLang) + } + } + const zusatz = pruefeZusatz(felder.zusatz || [], roh.zusatz, fehl) + + // Erinnerung (freiwillig, nur wenn angeboten) + let erinnerung = false + if (roh.erinnerung !== undefined && roh.erinnerung !== null) { + if (typeof roh.erinnerung === 'boolean') erinnerung = roh.erinnerung && e.erinnerung?.anbieten === true + else fehl('erinnerung', MELDUNG.ungueltig) + } + + // Zustimmungen + const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {} + const stornoNoetig = typeof e.storno?.text === 'string' && e.storno.text !== '' + if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno) + const vorzeitigNoetig = leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false + if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig) + + if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler) + return { leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, stornoNoetig, vorzeitigNoetig } +} + +/** Zusatzfelder: nur konfigurierte Schluessel, Text bis 200 Zeichen bzw. eine der Optionen; => { key: Wert } ohne leere. */ +function pruefeZusatz(konfiguriert, roh, fehl) { + const zusatz = {} + if (roh !== undefined && roh !== null && !istObjekt(roh)) { + fehl('zusatz', MELDUNG.ungueltig) + return zusatz + } + const eingabe = roh ?? {} + const bekannt = new Set(konfiguriert.map((f) => f.key)) + if (Object.keys(eingabe).some((key) => !bekannt.has(key))) fehl('zusatz', MELDUNG.ungueltig) + for (const f of konfiguriert) { + const pfad = `zusatz.${f.key}` + const wert = Object.hasOwn(eingabe, f.key) ? eingabe[f.key] : undefined + if (wert !== undefined && wert !== null && typeof wert !== 'string') { + fehl(pfad, MELDUNG.ungueltig) + continue + } + const sauber = bereinigterText(wert, ZUSATZ_WERT_MAX + 1) + if (sauber === '') { + if (f.pflicht) fehl(pfad, MELDUNG.zusatzLeer) + } else if ([...sauber].length > ZUSATZ_WERT_MAX) { + fehl(pfad, MELDUNG.zusatzLang) + } else if (f.typ === 'auswahl' && !(f.optionen || []).includes(sauber)) { + fehl(pfad, MELDUNG.zusatzAuswahl) + } else { + zusatz[f.key] = sauber + } + } + if (JSON.stringify(zusatz).length > ZUSATZ_JSON_MAX) fehl('zusatz', MELDUNG.zusatzLang) + return zusatz +} + +// ---------------------------------------------------------------- Nachweis + +/** + * Nachweis zur Buchung (JSON, hoechstens 4000 Zeichen): Fassung der Rechtstexte, Zeitpunkt, Knopftext, Anrede und + * SHA-256 der Texte, die der Gast gesehen und bestaetigt hat. Keine IP, kein User-Agent. Wirft TypeError, wenn die + * Widerrufsbelehrung gezeigt werden muesste, dem Betrieb aber Anschrift oder Telefon fehlen (dann entsteht keine Buchung). + */ +function baueNachweis(e, g, verbraucher, now) { + const am = iso(now) + const nachweis = { fassung: RECHTSTEXTE_VERSION, am, knopf: knopfText(g.leistung), anrede: e.anrede, datenschutz: sha256(formularHinweis(e)) } + if (g.stornoNoetig) nachweis.storno = { version: e.storno.version, hash: sha256(e.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am } + if (verbraucher && g.leistung.buchungsart === 'sofort') nachweis.widerruf = { hash: sha256(widerrufsbelehrung(e.betrieb)) } + if (g.vorzeitigNoetig) nachweis.vorzeitig = { hash: sha256(ZUSTIMMUNG_VORZEITIG(e.betrieb.name)), am } + if (g.erinnerung) { + const stunden = e.erinnerung.stundenVorher + nachweis.erinnerung = { stunden, hash: sha256(ZUSTIMMUNG_ERINNERUNG(stunden)), am } + } + return JSON.stringify(nachweis) +} + +// ---------------------------------------------------------------- Halten + +/** 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 + const von = zuUtc(g.tag, '00:00') + const bis = zuUtc(g.tag, '24:00') + const amTag = await imZeitraum(ctx.feature.$id, von, bis) + const zeiten = freieZeiten({ einstellung: e, leistungId: g.leistung.id, ressourceId: g.ressource, tag: g.tag, belegung: belegungAus(amTag, now), now }) + const treffer = zeiten.find((z) => z.uhrzeit === g.zeit) + 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) } +} + +/** Haltung wieder aufgeben (nach gescheitertem Belegen): verfallen, unter der Buchungssperre. Fehler dabei werden nur protokolliert. */ +async function gibHaltungAuf(bookingId, now) { + try { + await withLocks([`termine-buchung:${bookingId}`], () => aendere(bookingId, { status: 'verfallen', updatedAt: iso(now) })) + } catch (err) { + protokolliere('Haltung nicht als verfallen vermerkt', bookingId, err) + } +} + +/** + * Termin halten (Formular abgeschickt). Prueft die Eingabe, berechnet die freie Zeit neu, waehlt bei "egal" die + * Ressource, legt die Buchung `gehalten` an (30 Minuten) und danach ihre Zellen, schickt G01 mit dem Bestaetigungslink. + * Hat die Adresse schon die erlaubte Zahl offener Termine, entsteht nichts: statt der Bestaetigung geht G01 `zu_viele` + * an die Adresse und die Antwort sieht aus wie sonst (kein Orakel fuer Dritte). Die Pruefung der Zeit kommt vor der + * Grenze, damit auch ein 409 nicht verraet, ob die Adresse an der Grenze ist. + * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) + * @param {object} eingabe { leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } } + * @param {Date} now + * @returns {Promise<{ status: 'gehalten'|'zu_viele', email: string }>} email = die normalisierte Adresse + * @throws Error mit status 400 und fehler (Feldfehler) oder 409 code SLOT_VERGEBEN (Feld zeit) + */ +export async function halte(ctx, eingabe, now) { + const nowMs = pruefeNow(now, 'halte') + const e = ctx?.einstellung + if (!e?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError('halte: ctx fehlt') + const g = pruefeEingabe(e, eingabe, now) + const { start, ressourceId } = await pruefeSlot(ctx, g, now) + const featureId = ctx.feature.$id + + const ergebnis = await withLocks( + [`termine-gast:${featureId}:${g.email}`], + async () => { + const offene = await offeneFuerEmail(featureId, g.email, now) + if (offene.length >= e.regeln.maxOffeneJeEmail) return { zuViele: true } + + const id = randomUUID() + const { token, hash } = neuesToken() + const verbraucher = istVerbraucher(e) + const ressource = e.ressourcen.find((r) => r.id === ressourceId) + const ende = new Date(start.getTime() + g.leistung.dauer * MIN_MS) + const block = blockEnde(start, g.leistung) + const buchung = await legeAn( + { + featureId, + customerId: ctx.feature.customerId, + projectId: ctx.feature.projectId, + leistungId: g.leistung.id, + leistungName: g.leistung.name, + ressourceId, + ressourceName: ressource.name, + startsAt: iso(start), + endsAt: iso(ende), + blockEndsAt: iso(block), + status: 'gehalten', + quelle: 'online', + gastName: g.name, + gastEmail: g.email, + gastTelefon: g.telefon, + gastNachricht: g.nachricht, + gastZusatz: Object.keys(g.zusatz).length > 0 ? JSON.stringify(g.zusatz) : '', + erinnerung: g.erinnerung, + heldUntil: iso(nowMs + config.termine.holdMinutes * MIN_MS), + verifyHash: hash, + nachweis: baueNachweis(e, g, verbraucher, now), + preisCent: g.leistung.preisCent, + preisArt: g.leistung.preisArt, + buchungsart: g.leistung.buchungsart, + verbraucher, + zahlart: 'vorort', + zahlStatus: 'keine', + icsSequenz: 0, + createdAt: iso(now), + updatedAt: iso(now), + }, + id, + ) + try { + await belegeZellen({ featureId, ressourceId, bookingId: id, start, blockEnde: block, now }) + } catch (err) { + await gibHaltungAuf(id, now) + throw err?.code === 'SLOT_VERGEBEN' ? zeitVergeben() : err + } + return { buchung, token } + }, + { waitMs: ADRESSE_WARTE_MS }, + ) + + if (ergebnis.zuViele) { + await mailSicher('G01 zu_viele', null, () => anGast('G01', ctx, null, { fall: 'zu_viele', an: g.email }, now)) + return { status: 'zu_viele', email: g.email } + } + await mailSicher('G01', ergebnis.buchung.$id, () => anGast('G01', ctx, ergebnis.buchung, { verifyToken: ergebnis.token }, now)) + return { status: 'gehalten', email: g.email } +} + +// ---------------------------------------------------------------- Token -> Buchung + +/** Buchung zu einem Token (Bestaetigungstoken zuerst, dann Verwaltungstoken) samt Kontext; null, wenn es nichts gibt. */ +async function findePerToken(token, { nurBestaetigung = false } = {}) { + if (typeof token !== 'string' || !TOKEN_RE.test(token)) return null + const hash = hashToken(token) + let art = 'bestaetigen' + let buchung = await perHash('verifyHash', hash) + if (!buchung && !nurBestaetigung) { + art = 'verwalten' + buchung = await perHash('manageHash', hash) + } + if (!buchung) return null + const ctx = await kontextPerFeature(buchung.featureId) // auch fuer abgeschaltete Funktionen; null bei Modus off + return ctx?.einstellung ? { buchung, art, ctx } : null +} + +// ---------------------------------------------------------------- Aktionen des Gastes + +const VERWALTBAR = ['bestaetigt', 'angefragt'] + +/** Darf der Gast jetzt absagen oder verschieben? Bis absageBisStd Stunden vor Beginn (genau auf die Minute noch ja). */ +export function kannAbsagen(einstellung, buchung, now) { + if (!VERWALTBAR.includes(buchung.status)) return false + const frist = (einstellung.regeln?.absageBisStd ?? 0) * STUNDE_MS + return pruefeNow(now, 'kannAbsagen') <= zeitMs(buchung.startsAt) - frist +} + +/** + * 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. + */ +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 < zeitMs(buchung.startsAt) + ) +} + +// ---------------------------------------------------------------- Vorschau + +/** + * Zusammenfassung fuer die Gaesteseite (termin.html): zu einem Bestaetigungstoken (art 'bestaetigen') oder einem + * Verwaltungstoken (art 'verwalten', dazu gast fuer das Widerrufsformular). Aendert nichts. Preis, Leistung und Knopf + * stammen aus der Buchung (was der Gast gebucht hat), nicht aus der aktuellen Einstellung. + * @param {string} token + * @param {Date} now + * @returns {Promise} { art, status, termin, betrieb, gast, anrede, farbe, knopf, aktionen, hinweis } + * @throws Error 404 (code 'unbekannt'; 'abgelaufen' mit betrieb, wenn die Reservierung oder der Verwaltungslink abgelaufen ist) + */ +export async function vorschau(token, now) { + const nowMs = pruefeNow(now, 'vorschau') + const fund = await findePerToken(token) + if (!fund) throw unbekannt() + const { buchung, art, ctx } = fund + const e = ctx.einstellung + + 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 + + const start = new Date(zeitMs(buchung.startsAt)) + const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent }) + const seite = websiteUrl(ctx) + const b = e.betrieb + const offen = VERWALTBAR.includes(buchung.status) + const aktionen = { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false } + let hinweis = '' + if (art === 'bestaetigen') { + aktionen.bestaetigen = buchung.status === 'gehalten' + } else { + 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.' + } + + return { + art, + status: buchung.status, + termin: { + nummer: buchungsNummer(buchung.$id), + leistung: buchung.leistungName, + person: (e.ressourcen || []).length > 1 ? (buchung.ressourceName || '') : '', + datum: datumLang(start), + zeit: uhrzeit(start), + dauer: dauerText(buchung), + preis: preis ? `${preis} (${preisHinweis(e)})` : '', + zahlung: preis ? zahlungText(e) : '', + ort: b.adresse || '', + }, + betrieb: { + name: b.name, + telefon: b.telefon || '', + adresse: b.adresse || '', + impressumUrl: b.impressumUrl || seite, + datenschutzUrl: b.datenschutzUrl || seite, + websiteUrl: seite, + }, + gast: art === 'verwalten' ? { name: buchung.gastName, email: buchung.gastEmail } : null, + anrede: e.anrede, + farbe: e.farbe, + knopf: art === 'bestaetigen' ? knopfText({ buchungsart: buchung.buchungsart, preisArt: buchung.preisArt, preisCent: buchung.preisCent }, { bestaetigen: true }) : '', + aktionen, + hinweis, + } +} + +// ---------------------------------------------------------------- Bestaetigen + +/** + * E-Mail bestaetigt (Klick auf den Knopf der Gaesteseite): `gehalten` mit heldUntil > now wird bei einer sofort + * buchbaren Leistung `bestaetigt` (bestaetigtAt, G02 an den Gast mit Einladung, K20 an den Betrieb), bei einer Anfrage + * `angefragt` (G03 an den Gast, K20-Block anfrage); in beiden Faellen wird der Hash des Verwaltungstokens gespeichert. + * Ein weiterer Klick aendert nichts und meldet `schon`. Die Haltung selbst laesst der Minutenjob verfallen; hier gilt + * nur heldUntil > now. + * @param {string} token Bestaetigungstoken aus dem Link + * @param {Date} now + * @returns {Promise<{ status: string, schon?: true }>} + * @throws Error 404 (unbekanntes Token), 410 code 'abgelaufen' mit betrieb { name, telefon, websiteUrl } + */ +export async function bestaetige(token, now) { + const nowMs = pruefeNow(now, 'bestaetige') + const fund = await findePerToken(token, { nurBestaetigung: true }) + if (!fund) throw unbekannt() + const { ctx } = fund + const id = fund.buchung.$id + + const ergebnis = await withLocks([`termine-buchung:${id}`], async () => { + const frisch = await hole(id) + if (!frisch) throw unbekannt() + if (frisch.status === 'verfallen' || (frisch.status === 'gehalten' && !(zeitMs(frisch.heldUntil) > nowMs))) throw abgelaufenMit(410, ctx) + if (frisch.status !== 'gehalten') return { schon: true, status: frisch.status } + const anfrage = frisch.buchungsart === 'anfrage' + const buchung = await aendere(id, { + status: anfrage ? 'angefragt' : 'bestaetigt', + ...(anfrage ? {} : { bestaetigtAt: iso(now) }), + manageHash: hashToken(verwaltungsToken(id)), + updatedAt: iso(now), + }) + return { status: buchung.status, buchung, anfrage } + }) + if (ergebnis.schon) return { status: ergebnis.status, schon: true } + + const { buchung, anfrage } = ergebnis + if (anfrage) { + await mailSicher('G03', id, () => anGast('G03', ctx, buchung, {}, now)) + await mailSicher('K20', id, () => anBetrieb('K20', ctx, buchung, { block: 'anfrage' }, now)) + } else { + await mailSicher('G02', id, () => anGast('G02', ctx, buchung, {}, now)) + await mailSicher('K20', id, () => anBetrieb('K20', ctx, buchung, {}, now)) + } + return { status: ergebnis.status } +} diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index f3b9ad5..93e8872 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -61,7 +61,7 @@ function ohneSteuerzeichen(s, { behalteVerbinder = false, umbruch = false } = {} const randTrim = (s) => s.replace(/^[\s\u200C\u200D]+|[\s\u200C\u200D]+$/g, '') /** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */ -function text(wert, max, { mehrzeilig = false } = {}) { +export function text(wert, max, { mehrzeilig = false } = {}) { if (typeof wert !== 'string') return '' let s = wert if (mehrzeilig) { @@ -106,7 +106,7 @@ function wahrheit(fehl, pfad, wert, vorgabe) { } /** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und >= 1 Punkt in der Domain, hoechstens 254 Zeichen. */ -function emailAdresse(wert) { +export function emailAdresse(wert) { if (typeof wert !== 'string') return null const s = ohneSteuerzeichen(wert).trim().toLowerCase() if (s.length === 0 || s.length > 254) return null diff --git a/server/services/termine/mails.js b/server/services/termine/mails.js index 6c32f59..37ceff2 100644 --- a/server/services/termine/mails.js +++ b/server/services/termine/mails.js @@ -160,7 +160,7 @@ function terminWerte(buchung) { } /** "45 Minuten", "1 Stunde", "1 Stunde 30 Minuten". */ -function dauerText(buchung) { +export function dauerText(buchung) { const minuten = Math.round((pflichtDatum(buchung?.endsAt, 'buchung.endsAt') - pflichtDatum(buchung?.startsAt, 'buchung.startsAt')) / 60000) const stunden = Math.floor(minuten / 60) const rest = minuten % 60 diff --git a/test/termineAblaufGast.test.js b/test/termineAblaufGast.test.js new file mode 100644 index 0000000..68c96e2 --- /dev/null +++ b/test/termineAblaufGast.test.js @@ -0,0 +1,942 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +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-Ablauf des Terminplaners: halten, Vorschau, bestaetigen. Nachbau von Appwrite mit Schema fuer die Termine- +// Collections (Standard-Limit 25 wie in Produktion), Mails landen 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 { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import( + '../server/services/termine/rechtstexte.js' +) +const { halte, vorschau, bestaetige } = await import('../server/services/termine/ablauf.js') +after(() => setMailTransportForTests(null)) + +const MIN = 60000 +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const TAG_NAH = '2026-10-12' // 7 Tage spaeter: vorzeitiger Beginn zustimmungspflichtig +const TAG_FERN = '2026-10-26' // 21 Tage spaeter, Montag +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 Minuten, 45 Euro, sofort +const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage +const S_GRATIS = 's_bbbbbbb3' // Kennenlernen, 30 Minuten, ohne Preis, sofort +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 sha256 = (text) => createHash('sha256').update(text).digest('hex') +const plus = (date, ms) => new Date(date.getTime() + ms) + +const feature = () => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) + +const eingabeEinstellung = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + bundesland: 'RP', + ressourcen: [ + { id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }, + { id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } }, + ], + leistungen: [ + { id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] }, + { id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] }, + { id: S_GRATIS, name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', 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: 'egal', + 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, +}) + +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() + 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 buchungMitStatus = (status) => buchungen().filter((b) => b.status === status) +const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) +const eintraege = () => [...aw.docs('termineOutbox').values()] +const entfalte = (ics) => ics.replace(/\r\n /g, '') + +/** 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 einen Termin und gibt { buchung, token } zurueck; das Token steht nur in G01 (Mail-Link #v=). */ +async function haltenMitToken(ctx, over = {}, now = JETZT) { + const vorher = sink.sent.length + const erg = await halte(ctx, eingabe(over), now) + await warteAufVersand() + assert.equal(erg.status, 'gehalten') + const mail = sink.sent[vorher] + const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)[1] + const buchung = buchungen().find((b) => b.verifyHash === hashToken(token)) + assert.ok(buchung, 'Buchung zum Token') + return { buchung, token, mail } +} + +/** Gehalten und bestaetigt; => { buchung (frisch gelesen), verwaltungsToken, mailStart (Index der ersten Bestaetigungsmail) }. */ +async function gebucht(ctx, over = {}, now = JETZT) { + const { buchung, token } = await haltenMitToken(ctx, over, now) + const mailStart = sink.sent.length + const erg = await bestaetige(token, now) + await warteAufVersand() + return { erg, id: buchung.$id, buchung: aw.docs('termineBookings').get(buchung.$id), verwaltung: verwaltungsToken(buchung.$id), token, mailStart } +} + +// ---------------------------------------------------------------- Halten + +test('halte: Buchung gehalten mit Haltezeit, Preis, Nachweis und Zellen; G01 ohne Gastname, Token nur in der Mail', async () => { + const ctx = await speichere() + const erg = await halte(ctx, eingabe({ name: ' Max Mustermann ', email: ' Max@Example.COM ', telefon: '0171 123', erinnerung: true }), JETZT) + await warteAufVersand() + assert.deepEqual(erg, { status: 'gehalten', email: 'max@example.com' }) + + const [b] = buchungen() + assert.equal(buchungen().length, 1) + assert.match(b.$id, /^[0-9a-f-]{36}$/) + assert.deepEqual( + { + featureId: b.featureId, customerId: b.customerId, projectId: b.projectId, leistungId: b.leistungId, leistungName: b.leistungName, + status: b.status, quelle: b.quelle, gastName: b.gastName, gastEmail: b.gastEmail, gastTelefon: b.gastTelefon, gastNachricht: b.gastNachricht, + gastZusatz: b.gastZusatz, erinnerung: b.erinnerung, preisCent: b.preisCent, preisArt: b.preisArt, buchungsart: b.buchungsart, + verbraucher: b.verbraucher, zahlart: b.zahlart, zahlStatus: b.zahlStatus, icsSequenz: b.icsSequenz, + }, + { + featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, leistungId: S_SOFORT, leistungName: 'Damenschnitt', + status: 'gehalten', quelle: 'online', gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 123', gastNachricht: '', + gastZusatz: '', erinnerung: true, preisCent: 4500, preisArt: 'fest', buchungsart: 'sofort', + verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, + }, + ) + // 10:00 Berlin (MESZ, bis 25.10.) und 26.10. = MEZ: 10:00 = 09:00Z + assert.equal(iso(b.startsAt), '2026-10-26T09:00:00.000Z') + assert.equal(iso(b.endsAt), '2026-10-26T09:45:00.000Z') + assert.equal(iso(b.blockEndsAt), '2026-10-26T09:50:00.000Z', 'Pause danach gehoert zum Block') + assert.equal(iso(b.heldUntil), iso(plus(JETZT, 30 * MIN))) + assert.equal(iso(b.createdAt), iso(JETZT)) + assert.equal(b.manageHash, undefined, 'Verwaltungstoken erst bei der Bestaetigung') + assert.equal(b.bestaetigtAt, undefined) + assert.ok([R1, R2].includes(b.ressourceId)) + + // Zellen: 50 Minuten = 10 Zellen, Ende = blockEndsAt + const zellen = claims(b.$id) + assert.equal(zellen.length, 10) + assert.ok(zellen.every((z) => z.ressourceId === b.ressourceId && z.featureId === FEATURE && iso(z.bis) === iso(b.blockEndsAt))) + assert.ok(aw.docs('termineClaims').has(zellenId(FEATURE, b.ressourceId, zellenFuer(b.startsAt, b.blockEndsAt)[0]))) + + // G01: genau eine Mail an die normalisierte Adresse, ohne Gastname, ohne Kalenderdatei, ohne BCC + assert.equal(sink.sent.length, 1) + const [mail] = sink.sent + assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Müller') + assert.deepEqual(mail.to, [{ address: 'max@example.com', name: '' }]) + assert.equal('bcc' in mail, false) + assert.equal('icalEvent' in mail, false) + for (const inhalt of [mail.text, mail.html]) { + assert.doesNotMatch(inhalt, /Mustermann/) + assert.doesNotMatch(inhalt, /0171/) + } + const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1] + assert.ok(token, 'Bestaetigungslink mit Token im Fragment') + assert.equal(b.verifyHash, hashToken(token)) + assert.doesNotMatch(JSON.stringify(buchungen()), new RegExp(token), 'das Token selbst wird nie gespeichert') + assert.equal(eintraege().filter((d) => d.art === 'G01').length, 1) +}) + +test('halte: nachweis enthaelt Fassung, Zeitpunkt, Knopftext, Anrede und die Hashes der gezeigten Texte (ohne IP und Browser)', async () => { + const ctx = await speichere() + await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: true, zustimmungen: { storno: true, vorzeitig: true } }), JETZT) + const nachweis = JSON.parse(buchungen()[0].nachweis) + const e = ctx.einstellung + assert.equal(nachweis.fassung, RECHTSTEXTE_VERSION) + assert.equal(nachweis.am, iso(JETZT)) + assert.equal(nachweis.knopf, 'Zahlungspflichtig buchen') + assert.equal(nachweis.anrede, 'sie') + assert.equal(nachweis.datenschutz, sha256(formularHinweis(e))) + assert.deepEqual(nachweis.storno, { version: e.storno.version, hash: sha256(e.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) }) + assert.deepEqual(nachweis.vorzeitig, { hash: sha256(ZUSTIMMUNG_VORZEITIG('Salon Müller')), am: iso(JETZT) }) + assert.deepEqual(nachweis.erinnerung, { stunden: 24, hash: sha256(ZUSTIMMUNG_ERINNERUNG(24)), am: iso(JETZT) }) + assert.match(nachweis.widerruf.hash, /^[0-9a-f]{64}$/, 'Widerrufsbelehrung bei verbindlicher Verbraucherbuchung') + assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'erinnerung', 'fassung', 'knopf', 'storno', 'vorzeitig', 'widerruf']) + assert.ok(buchungen()[0].nachweis.length < 4000) +}) + +test('halte: Zustimmungen, die nicht verlangt oder nicht gegeben wurden, stehen nicht im Nachweis', async () => { + const ctx = await speichere(einstellung({ storno: { text: '' }, recht: { gaesteSind: 'unternehmer' } })) + await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: false, zustimmungen: { storno: true, vorzeitig: true } }), JETZT) + const nachweis = JSON.parse(buchungen()[0].nachweis) + assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'fassung', 'knopf'], 'kein Storno-Text, Unternehmer, keine Erinnerung') + assert.equal(buchungen()[0].verbraucher, false) +}) + +test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen keine Zustimmung zum vorzeitigen Beginn', async () => { + const ctx = await speichere() + await halte(ctx, eingabe({ leistung: S_ANFRAGE, tag: TAG_NAH, zeit: '10:00' }), JETZT) + await halte(ctx, eingabe({ leistung: S_GRATIS, tag: TAG_NAH, zeit: '12:00', email: 'zwei@example.com' }), JETZT) + const nachweise = buchungen().map((b) => [b.buchungsart, JSON.parse(b.nachweis).knopf, JSON.parse(b.nachweis).vorzeitig]).sort() + assert.deepEqual(nachweise, [['anfrage', 'Termin anfragen', undefined], ['sofort', 'Termin buchen', undefined]]) + const anfrage = buchungen().find((b) => b.buchungsart === 'anfrage') + assert.equal(anfrage.preisCent, 0) + assert.equal(anfrage.preisArt, 'keiner') +}) + +test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => { + const ctx = await speichere() + await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT) + await halte(ctx, eingabe({ ressource: 'egal', zeit: '10:00', email: 'zwei@example.com' }), JETZT) + const nachZeit = Object.fromEntries(buchungen().map((b) => [new Date(b.startsAt).toISOString().slice(11, 16), b.ressourceId])) + assert.deepEqual(nachZeit, { '08:00': R1, '09:00': R2 }, 'Anna hat schon einen Termin, also Ben') + assert.equal(buchungen().find((b) => b.ressourceId === R2).ressourceName, 'Ben') +}) + +// ---------------------------------------------------------------- Pruefung der Eingabe + +test('halte: ungueltige Eingaben ergeben 400 mit Feldfehlern und speichern nichts', async () => { + const ctx = await speichere(einstellung({ + gastFelder: { + telefon: 'pflicht', + nachricht: 'optional', + zusatz: [ + { key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }, + { key: 'art', label: 'Art', typ: 'auswahl', optionen: ['Kurz', 'Lang'], pflicht: false }, + ], + }, + })) + const gut = eingabe({ zusatz: { kennzeichen: 'KL-AB 123' } }) + const faelle = [ + ['leistung', { leistung: 's_unbekannt' }], + ['leistung', { leistung: undefined }], + ['leistung', { leistung: { $ne: null } }], + ['ressource', { ressource: 'r_unbekannt' }], + ['ressource', { ressource: undefined }], + ['ressource', { ressource: ['egal'] }], + ['tag', { tag: '2026-02-30' }], + ['tag', { tag: '26.10.2026' }], + ['tag', { tag: undefined }], + ['zeit', { zeit: '10:7' }], + ['zeit', { zeit: '24:00' }], + ['zeit', { zeit: '25:00' }], + ['zeit', { tag: '2027-03-28', zeit: '02:30' }], // Luecke der Maerz-Umstellung + ['zeit', { zeit: undefined }], + ['name', { name: '' }], + ['name', { name: ' ' }], + ['name', { name: '​\u0000' }], + ['name', { name: 'M'.repeat(101) }], + ['name', { name: 42 }], + ['email', { email: 'max' }], + ['email', { email: 'max@example' }], + ['email', { email: 'a@example.com, b@example.com' }], + ['email', { email: 'a@example.com;b@example.com' }], + ['email', { email: '"Max" ' }], + ['email', { email: `${'a'.repeat(250)}@example.com` }], + ['email', { email: ['max@example.com'] }], + ['telefon', { telefon: '' }], + ['telefon', { telefon: 'abc' }], + ['telefon', { telefon: '0171