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 /** * Gehoeren die Zellen der Haltung ihr noch? Ein anderer Gast, dessen Anfrage mit einem spaeteren `now` lief, darf die * Zellen einer schon abgelaufenen Haltung geloescht und selbst belegt haben (zellen.js: eine abgelaufene Haltung gilt * als frei), waehrend diese Anfrage noch `heldUntil > now` sieht. Darum wird vor der Bestaetigung (unter der * Buchungssperre) nachgezogen, was von den eigenen Zellen fehlt (belegeZellen mit ausser = eigene ID). Gelingt das nicht * (SLOT_VERGEBEN), ist die Zeit weg: Buchung `verfallen` und dieselbe Antwort wie bei einer abgelaufenen Haltung (410), * ohne Mails. Andere Fehler (z. B. Datenbank) gehen durch, die Buchung bleibt `gehalten`. * Sperrenfolge termine-buchung -> termine-res (in belegeZellen); in umgekehrter Richtung nimmt niemand beide. */ async function sichereZellen(buchung, ctx, now) { try { await belegeZellen({ featureId: buchung.featureId, ressourceId: buchung.ressourceId, bookingId: buchung.$id, start: new Date(zeitMs(buchung.startsAt)), blockEnde: new Date(zeitMs(buchung.blockEndsAt)), now, ausser: buchung.$id, }) } catch (err) { if (err?.code !== 'SLOT_VERGEBEN') throw err try { await aendere(buchung.$id, { status: 'verfallen', updatedAt: iso(now) }) } catch (fehler) { protokolliere('Haltung nicht als verfallen vermerkt', buchung.$id, fehler) } throw abgelaufenMit(410, ctx) } } /** * 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, und die Zellen der Haltung muessen ihr noch gehoeren (sichereZellen). * @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 } await sichereZellen(frisch, ctx, now) 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 } }