Files
Webklar-Kundenbereich/server/services/termine/ablauf.js
T
ClaudeandClaude Sonnet 5.5 1289b5697b Terminplaner: Bestaetigung prueft vorher, dass die Zellen der Haltung ihr noch gehoeren
bestaetige zieht unter der Buchungssperre fehlende eigene Zellen nach (belegeZellen mit ausser). Hat ein anderer
Gast sie uebernommen, wird die Buchung verfallen und es kommt dieselbe 410-Antwort wie bei abgelaufener Haltung,
ohne Mails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00

555 lines
26 KiB
JavaScript

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` = { <feld|_>: 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:<id>` 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<object>} { 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 }
}