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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e25cd2d13
commit
d1470b894a
4 files changed
+1467
-3
No files matched your search
@@ -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` = { <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
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user