- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42) - Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und Terminplaner statt kopierter Funktion (R43) - Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern ueberschreibt den Schaden statt dauerhaft 409 - U+FEFF als Escape statt als rohes Zeichen - Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1058 lines
54 KiB
JavaScript
1058 lines
54 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { config } from '../../config.js'
|
|
import { withdrawalDeadline } from '../deadlines.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perEmailUndNummer, perHash } from './buchungen.js'
|
|
import {
|
|
brauchtZustimmungVorzeitig,
|
|
emailAdresse,
|
|
hatPreis,
|
|
istVerbraucher,
|
|
knopfText,
|
|
preisHinweis,
|
|
preisText,
|
|
text as bereinigterText,
|
|
zahlungText,
|
|
} from './einstellungen.js'
|
|
import { kontextPerFeature, websiteUrl } from './kontext.js'
|
|
import { darfG08, darfK21NichtZugeordnet } from './limits.js'
|
|
import { anBetrieb, anGast, dauerText } from './mails.js'
|
|
import {
|
|
RECHTSTEXTE_VERSION,
|
|
ZUSTIMMUNG_ERINNERUNG,
|
|
ZUSTIMMUNG_STORNO,
|
|
ZUSTIMMUNG_VORZEITIG,
|
|
formularHinweis,
|
|
widerrufsbelehrung,
|
|
} from './rechtstexte.js'
|
|
import { belegungAus, blockEnde, freieTage, freieZeiten, waehleRessource } from './slots.js'
|
|
import { buchungsNummer, hashToken, neuesToken, verwaltungsToken } from './token.js'
|
|
import { pruefeNow, zeitMs } from './util.js'
|
|
import { ausUtc, datumKurz, datumLang, monatInfo, uhrzeit, zuUtc } from './zeit.js'
|
|
import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js'
|
|
|
|
/**
|
|
* Gast-Ablauf des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4): Termin halten,
|
|
* 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).
|
|
*
|
|
* Verwaltung durch den Gast (Verwaltungstoken, gueltig bis Terminende + 1 Tag): sageAbGast, verschiebeGast (mit
|
|
* zeitenFuerToken fuer die freien Zeiten), widerrufeGast; ohne Link widerrufeOhneLink. Absagen und Verschieben gehen bis
|
|
* absageBisStd vor Beginn (danach 409 "Absage nur telefonisch"), der Widerruf haengt nur am Ende der Widerrufsfrist
|
|
* (14 Tage ab Vertragsschluss, §§ 187, 188, 193 BGB nach Berliner Kalender, deadlines.js) und am Beginn des Termins. Beim Verschieben werden die neuen Zellen VOR der Aenderung der Buchung belegt (scheitert
|
|
* das, bleibt alles wie es war), die alten erst danach freigegeben; die Zellen einer abgesagten Buchung gelten ohnehin
|
|
* als frei, ein Fehler beim Freigeben macht eine Absage also nicht rueckgaengig (nur ein Log). Fuer die Zellen gilt:
|
|
* nie selbst eine termine-res-Sperre halten (belegeZellen/gibZellenFrei sind nicht wiedereintrittsfaehig).
|
|
*
|
|
* Die Aktionen des Betriebs (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren, Termine von Hand,
|
|
* Sperren) stehen in ablaufBetrieb.js und nutzen die hier exportierten Bausteine (Pruefungen, Meldungen, mailSicher,
|
|
* pruefeSlot, verschiebeKern).
|
|
*/
|
|
|
|
const MIN_MS = 60 * 1000
|
|
const STUNDE_MS = 60 * MIN_MS
|
|
const TAG_MS = 24 * STUNDE_MS
|
|
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 NUMMER_MAX = 40
|
|
const NACHRICHT_MAX = 500
|
|
const TELEFON_MAX = 40
|
|
const ZUSATZ_WERT_MAX = 200
|
|
const ZUSATZ_JSON_MAX = 2000
|
|
|
|
export 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.',
|
|
monat: 'Bitte einen Monat auswählen.',
|
|
zeit: 'Bitte eine Uhrzeit auswählen.',
|
|
gleicheZeit: 'Das ist bereits der gebuchte Termin. Bitte eine andere Zeit wählen.',
|
|
zeitVergeben: 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.',
|
|
nameLeer: 'Bitte einen Namen angeben.',
|
|
nameLang: `Der Name darf höchstens ${NAME_MAX} Zeichen lang sein.`,
|
|
email: 'Bitte eine gültige E-Mail-Adresse angeben (genau ein Postfach).',
|
|
bestaetigungAdresse: 'Für die Bestätigung braucht der Gast eine E-Mail-Adresse.',
|
|
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.',
|
|
nichtMoeglich: 'Dieser Termin kann nicht mehr abgesagt werden.',
|
|
keinWiderruf: 'Für diesen Termin ist kein Widerruf möglich.',
|
|
}
|
|
|
|
export const iso = (wert) => new Date(wert).toISOString()
|
|
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
|
export 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). */
|
|
export const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra })
|
|
|
|
export 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) })
|
|
|
|
/** Sammelt Feldfehler (der erste je Feld gilt): { fehler, fehl(feld, meldung) }. */
|
|
export function fehlerSammler() {
|
|
const fehler = {}
|
|
const fehl = (feld, meldung) => {
|
|
if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung
|
|
}
|
|
return { fehler, fehl }
|
|
}
|
|
|
|
/** Name: nicht leer, hoechstens 100 Zeichen (bereinigt); Fehler gehen an fehl. */
|
|
function pruefeNameFeld(wert, fehl) {
|
|
const name = bereinigterText(wert, NAME_MAX + 1)
|
|
if (name === '') fehl('name', MELDUNG.nameLeer)
|
|
else if ([...name].length > NAME_MAX) fehl('name', MELDUNG.nameLang)
|
|
return name
|
|
}
|
|
|
|
/** Log ohne personenbezogene Daten: Vorgang, Buchungsnummer, Art und Text des Fehlers (kommt aus unserem Code oder Appwrite). */
|
|
export 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. */
|
|
export 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.
|
|
* manuell (Termin von Hand, ablaufBetrieb.js; meist Telefon oder Laufkundschaft): Pflicht ist nur der Name. E-Mail, Telefon,
|
|
* Nachricht und Zusatzfelder sind freiwillig, unabhaengig von gastFelder (auch bei "aus" oder "pflicht"); was angegeben ist,
|
|
* wird wie beim Gast auf Form und Laenge geprueft (Zusatzfelder muessen konfiguriert sein). Mit bestaetigung (der Gast soll
|
|
* eine Bestaetigung bekommen) ist die E-Mail-Adresse Pflicht. Erinnerung und Zustimmungen entfallen (der Betrieb traegt
|
|
* ein, der Gast klickt nichts an).
|
|
*/
|
|
export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = false } = {}) {
|
|
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
|
const { fehler, fehl } = fehlerSammler()
|
|
|
|
// 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 = pruefeNameFeld(roh.name, fehl)
|
|
const ohneAdresse = manuell && (roh.email === undefined || roh.email === null || (typeof roh.email === 'string' && roh.email.trim() === ''))
|
|
const email = ohneAdresse ? '' : emailAdresse(roh.email)
|
|
if (email === null) fehl('email', MELDUNG.email)
|
|
else if (ohneAdresse && bestaetigung) fehl('email', MELDUNG.bestaetigungAdresse)
|
|
|
|
// Telefon, Nachricht, Zusatzfelder nach gastFelder (bei Handeintraegen alles freiwillig, siehe oben)
|
|
const felder = manuell
|
|
? { telefon: 'optional', nachricht: 'optional', zusatz: (e.gastFelder?.zusatz || []).map((f) => ({ ...f, pflicht: false })) }
|
|
: 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; nicht bei Handeintraegen)
|
|
let erinnerung = false
|
|
if (!manuell && 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 = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== ''
|
|
if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno)
|
|
const vorzeitigNoetig = !manuell && 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
|
|
|
|
/** Buchungen der Liste, die an diesem Berliner Tag beginnen (Mass fuer "die wenigsten Buchungen an dem Tag"). */
|
|
const beginnenAn = (liste, tag) => liste.filter((b) => Number.isFinite(zeitMs(b.startsAt)) && ausUtc(new Date(zeitMs(b.startsAt))).tag === tag)
|
|
|
|
/**
|
|
* Freie Zeit neu berechnen (Belegung des Tages frisch aus der Datenbank) und bei "egal" die Ressource waehlen.
|
|
* ohneRegeln hebt Vorlauf und Horizont auf (Handeintrag des Betriebs; nie die Vergangenheit, nie belegte Zeiten).
|
|
*/
|
|
export async function pruefeSlot(ctx, g, now, { ohneRegeln = false } = {}) {
|
|
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, ohneRegeln })
|
|
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
|
|
return { start: treffer.start, ressourceId: waehleRessource(treffer.ressourceIds, beginnenAn(amTag, g.tag), e, now) }
|
|
}
|
|
|
|
/** Haltung wieder aufgeben (nach gescheitertem Belegen): verfallen, unter der Buchungssperre. Fehler dabei werden nur protokolliert. */
|
|
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.
|
|
* nurBestaetigung / nurVerwaltung lassen die jeweils andere Art weg.
|
|
*/
|
|
async function findePerToken(token, { nurBestaetigung = false, nurVerwaltung = false } = {}) {
|
|
if (typeof token !== 'string' || !TOKEN_RE.test(token)) return null
|
|
const hash = hashToken(token)
|
|
let art = 'bestaetigen'
|
|
let buchung = nurVerwaltung ? null : 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
|
|
|
|
export 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
|
|
}
|
|
|
|
/**
|
|
* Zeitpunkt des Vertragsschlusses in ms: bestaetigtAt bei einer sofort buchbaren Leistung, angenommenAt bei einer vom
|
|
* Betrieb angenommenen Anfrage; NaN, wenn es keinen gibt (auch eine Anfrage, die nur bestaetigt wurde, ist kein Vertrag).
|
|
*/
|
|
export function vertragAm(buchung) {
|
|
if (buchung.buchungsart === 'sofort') return zeitMs(buchung.bestaetigtAt)
|
|
if (buchung.buchungsart === 'anfrage') return zeitMs(buchung.angenommenAt)
|
|
return Number.NaN
|
|
}
|
|
|
|
/**
|
|
* Ende der Widerrufsfrist in ms (NaN ohne Vertragsschluss): 14 Tage ab dem Tag nach dem Vertragsschluss, auf den
|
|
* naechsten Werktag verschoben, wenn der letzte Tag ein Wochenende oder Feiertag ist (§§ 187, 188, 193 BGB), bis
|
|
* 23:59:59,999 Uhr Berliner Zeit (withdrawalDeadline).
|
|
*/
|
|
function fristEnde(buchung) {
|
|
const vertrag = vertragAm(buchung)
|
|
return Number.isFinite(vertrag) ? withdrawalDeadline(new Date(vertrag)).endsAt.getTime() : Number.NaN
|
|
}
|
|
|
|
/**
|
|
* Besteht noch ein Widerrufsrecht? Verbindlich (sofort oder vom Betrieb angenommene Anfrage), Verbraucher, bestaetigt
|
|
* (kein Handeintrag des Betriebs), die Widerrufsfrist (fristEnde) ist nicht abgelaufen und der Termin hat noch nicht
|
|
* begonnen. Unabhaengig von absageBisStd (§ 361 BGB: die Stornofrist schraenkt das Widerrufsrecht nicht ein).
|
|
*/
|
|
export function kannWiderrufen(buchung, now) {
|
|
const nowMs = pruefeNow(now, 'kannWiderrufen')
|
|
return (
|
|
buchung.verbraucher === true &&
|
|
buchung.status === 'bestaetigt' &&
|
|
buchung.quelle !== 'manuell' &&
|
|
nowMs <= fristEnde(buchung) &&
|
|
nowMs < zeitMs(buchung.startsAt)
|
|
)
|
|
}
|
|
|
|
/** Hinweis, wenn ein Gast nicht mehr selbst absagen kann (Frist vorbei, Termin aber noch nicht begonnen). */
|
|
const absageHinweis = (betrieb) => (betrieb.telefon ? `Absage nur telefonisch: ${betrieb.telefon}` : 'Absage nur direkt beim Betrieb möglich.')
|
|
|
|
/** Der Verwaltungslink gilt bis Terminende + 1 Tag. */
|
|
const linkAbgelaufen = (buchung, nowMs) => nowMs >= zeitMs(buchung.endsAt) + TAG_MS
|
|
|
|
// ---------------------------------------------------------------- Vorschau
|
|
|
|
/**
|
|
* 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' && linkAbgelaufen(buchung, nowMs)) throw abgelaufenMit(404, ctx)
|
|
|
|
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 = absageHinweis(b)
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Verwaltung durch den Gast
|
|
|
|
/** Buchung zum Verwaltungstoken samt Kontext; 404 (code 'unbekannt'), ab Terminende + 1 Tag 404 code 'abgelaufen'. */
|
|
async function ladeVerwaltung(token, nowMs) {
|
|
const fund = await findePerToken(token, { nurVerwaltung: true })
|
|
if (!fund) throw unbekannt()
|
|
if (linkAbgelaufen(fund.buchung, nowMs)) throw abgelaufenMit(404, fund.ctx)
|
|
return fund
|
|
}
|
|
|
|
/**
|
|
* Absagen und Verschieben setzen eine offene Buchung (bestaetigt, angefragt) vor Ablauf der Absagefrist voraus. Sonst 409:
|
|
* ist der Termin noch nicht begonnen, mit "Absage nur telefonisch: ..." (code 'frist_vorbei'), andernfalls "nicht mehr
|
|
* moeglich" (code 'nicht_moeglich', z. B. erledigt oder abgesagt).
|
|
*/
|
|
function pruefeAbsagbar(einstellung, buchung, now) {
|
|
if (kannAbsagen(einstellung, buchung, now)) return
|
|
if (VERWALTBAR.includes(buchung.status) && now.getTime() < zeitMs(buchung.startsAt)) {
|
|
throw fehlerMit(409, { _: absageHinweis(einstellung.betrieb) }, { code: 'frist_vorbei' })
|
|
}
|
|
throw fehlerMit(409, { _: MELDUNG.nichtMoeglich }, { code: 'nicht_moeglich' })
|
|
}
|
|
|
|
/**
|
|
* Zellen einer Buchung freigeben, nachdem sich ihr Zustand geaendert hat. Ein Fehler wird nur protokolliert: bei einer
|
|
* Absage gelten die Zellen einer nicht mehr belegenden Buchung ohnehin als frei (zellen.js, der Tagesjob raeumt sie).
|
|
*/
|
|
export async function gibFreiSicher(bookingId, optionen) {
|
|
try {
|
|
await gibZellenFrei(bookingId, optionen)
|
|
} catch (err) {
|
|
protokolliere('Zellen nicht freigegeben', bookingId, err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gast sagt per Verwaltungslink ab (bis absageBisStd vor Beginn): `abgesagt` von `gast`, Zellen frei, G06 an den Gast
|
|
* (Kalenderabsage CANCEL; fuer eine unbestaetigte Anfrage ohne Kalenderdatei, sie hat nie eine Einladung bekommen), K21
|
|
* `abgesagt` an den Betrieb. Ein weiterer Klick aendert nichts und meldet `schon`.
|
|
* @param {string} token Verwaltungstoken
|
|
* @param {Date} now
|
|
* @returns {Promise<{ status: 'abgesagt', schon?: true }>}
|
|
* @throws Error 404 (unbekannt; 'abgelaufen' mit betrieb), 409 code 'frist_vorbei' (Feld _ "Absage nur telefonisch: ...")
|
|
* oder 'nicht_moeglich'
|
|
*/
|
|
export async function sageAbGast(token, now) {
|
|
const nowMs = pruefeNow(now, 'sageAbGast')
|
|
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
|
const id = gefunden.$id
|
|
|
|
const ergebnis = await withLocks([`termine-buchung:${id}`], async () => {
|
|
const frisch = await hole(id)
|
|
if (!frisch) throw unbekannt()
|
|
if (frisch.status === 'abgesagt') return { schon: true }
|
|
pruefeAbsagbar(ctx.einstellung, frisch, now)
|
|
const buchung = await aendere(id, { status: 'abgesagt', abgesagtAt: iso(now), abgesagtVon: 'gast', updatedAt: iso(now) })
|
|
await gibFreiSicher(id)
|
|
return { buchung, ohneIcs: frisch.status === 'angefragt' }
|
|
})
|
|
if (ergebnis.schon) return { status: 'abgesagt', schon: true }
|
|
|
|
const { buchung, ohneIcs } = ergebnis
|
|
await mailSicher('G06', id, () => anGast('G06', ctx, buchung, { von: 'gast', ohneIcs }, now))
|
|
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'abgesagt' }, now))
|
|
return { status: 'abgesagt' }
|
|
}
|
|
|
|
/**
|
|
* Dauer und Pause so, wie der Gast gebucht hat (nicht wie die Leistung heute eingestellt ist): eine Einstellung, in der
|
|
* die Leistung der Buchung diese Laenge hat. Beim Verschieben aendert der Termin seine Lage, nicht seinen Umfang.
|
|
*/
|
|
function mitGebuchterDauer(einstellung, buchung) {
|
|
const dauer = Math.round((zeitMs(buchung.endsAt) - zeitMs(buchung.startsAt)) / MIN_MS)
|
|
const pufferNach = Math.round((zeitMs(buchung.blockEndsAt) - zeitMs(buchung.endsAt)) / MIN_MS)
|
|
return { ...einstellung, leistungen: (einstellung.leistungen || []).map((l) => (l.id === buchung.leistungId ? { ...l, dauer, pufferNach } : l)) }
|
|
}
|
|
|
|
/**
|
|
* Welche Ressourcen kommen beim Verschieben infrage? Ist die Person waehlbar, hat der Gast sie bewusst oder durch "egal"
|
|
* bekommen und behaelt sie; sonst (Plaetze, keine Wahl) jede freie, wobei die eigene bevorzugt wird.
|
|
*/
|
|
const verschiebeRessource = (einstellung, buchung) => (einstellung.regeln?.personWaehlbar === true ? buchung.ressourceId : 'egal')
|
|
|
|
/**
|
|
* Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }.
|
|
* Leistung, Dauer und Person kommen aus der Buchung (mitGebuchterDauer, verschiebeRessource), genau wie in verschiebeKern.
|
|
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont). Auch die Zeitenabfrage des Portals nutzt sie.
|
|
*/
|
|
export async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) {
|
|
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
|
|
const zeiten = freieZeiten({
|
|
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
|
leistungId: buchung.leistungId,
|
|
ressourceId: verschiebeRessource(ctx.einstellung, buchung),
|
|
tag,
|
|
belegung: belegungAus(amTag, now, { ohneBuchung: buchung.$id }),
|
|
now,
|
|
ohneRegeln,
|
|
})
|
|
return { zeiten, amTag }
|
|
}
|
|
|
|
/** Steht im Nachweis der Buchung die Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB)? */
|
|
function hatVorzeitigeZustimmung(buchung) {
|
|
try {
|
|
return istObjekt(JSON.parse(buchung.nachweis)?.vorzeitig)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Beim Verschieben darf die Pflicht-Zustimmung zum vorzeitigen Beginn nicht umgangen werden: eine verbindliche,
|
|
* kostenpflichtige Verbraucherbuchung ohne diese Zustimmung (sie war beim Buchen nicht noetig, weil der Termin
|
|
* damals mehr als 14 Tage entfernt lag) darf nicht auf einen Beginn innerhalb der Widerrufsfrist verlegt werden.
|
|
* Ohne ermittelbares Fristende gilt die Zustimmung als noetig.
|
|
*/
|
|
function verschiebenBrauchtZustimmung(buchung, start) {
|
|
if (buchung.verbraucher !== true || buchung.buchungsart !== 'sofort' || buchung.quelle === 'manuell' || !hatPreis(buchung)) return false
|
|
if (hatVorzeitigeZustimmung(buchung)) return false
|
|
return !(start.getTime() > fristEnde(buchung))
|
|
}
|
|
|
|
/** Anredeneutral (R22): die Gaesteseite zeigt den Text fuer Sie- und Du-Betriebe. */
|
|
const zustimmungHinweis = (betrieb) =>
|
|
`Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder ${betrieb.telefon ? `anrufen: ${betrieb.telefon}.` : 'direkt beim Betrieb melden.'}`
|
|
|
|
/** Tag ('YYYY-MM-DD') und Uhrzeit ('HH:MM') des neuen Termins; sonst 400 mit den Feldern tag und zeit. */
|
|
export function pruefeVerschiebung(roh) {
|
|
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
|
const { fehler, fehl } = fehlerSammler()
|
|
const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null
|
|
if (tag === null) fehl('tag', MELDUNG.tag)
|
|
const zeit = typeof roh.zeit === 'string' && ZEIT_RE.test(roh.zeit) ? roh.zeit : null
|
|
if (zeit === null) fehl('zeit', MELDUNG.zeit)
|
|
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
|
return { tag, zeit }
|
|
}
|
|
|
|
/**
|
|
* Kern des Verschiebens (Gast und Betrieb): MUSS unter `termine-buchung:<id>` laufen, `alt` ist die frisch gelesene
|
|
* Buchung. Berechnet die neue Zeit, belegt die neuen Zellen (SLOT_VERGEBEN => 409, alles unveraendert), aendert die
|
|
* Buchung (Beginn, Ende, Blockende, Ressource, icsSequenz + 1; scheitert das, werden die neuen Zellen wieder entfernt)
|
|
* und gibt erst danach die alten Zellen frei. Die Mails schickt der Aufrufer.
|
|
* betrieb: Verschieben durch den Betrieb ohne Vorlauf und Horizont (ohneRegeln; nie in die Vergangenheit, nie auf
|
|
* belegte Zellen) und ohne die Pruefung der Zustimmung zum vorzeitigen Beginn (Fristen und Zustimmung schuetzen den Gast).
|
|
* @returns {Promise<{ alt: object, buchung: object, start: Date }>}
|
|
* @throws Error 400 (dieselbe Zeit), 409 ('zustimmung_noetig', 'SLOT_VERGEBEN' Feld zeit)
|
|
*/
|
|
export async function verschiebeKern(ctx, alt, { tag, zeit }, now, { betrieb = false } = {}) {
|
|
const id = alt.$id
|
|
const featureId = ctx.feature.$id
|
|
const { zeiten, amTag } = await verschiebeZeiten(ctx, alt, tag, now, { ohneRegeln: betrieb })
|
|
const treffer = zeiten.find((z) => z.uhrzeit === zeit)
|
|
if (!treffer) throw zeitVergeben()
|
|
const ressourceId = treffer.ressourceIds.includes(alt.ressourceId)
|
|
? alt.ressourceId
|
|
: waehleRessource(treffer.ressourceIds, beginnenAn(amTag, tag), ctx.einstellung, now)
|
|
if (treffer.start.getTime() === zeitMs(alt.startsAt) && ressourceId === alt.ressourceId) throw fehlerMit(400, { zeit: MELDUNG.gleicheZeit })
|
|
if (!betrieb && verschiebenBrauchtZustimmung(alt, treffer.start)) {
|
|
throw fehlerMit(409, { _: zustimmungHinweis(ctx.einstellung.betrieb) }, { code: 'zustimmung_noetig' })
|
|
}
|
|
|
|
const start = treffer.start
|
|
const ende = new Date(start.getTime() + (zeitMs(alt.endsAt) - zeitMs(alt.startsAt)))
|
|
const block = new Date(start.getTime() + (zeitMs(alt.blockEndsAt) - zeitMs(alt.startsAt)))
|
|
try {
|
|
await belegeZellen({ featureId, ressourceId, bookingId: id, start, blockEnde: block, now, ausser: id })
|
|
} catch (err) {
|
|
throw err?.code === 'SLOT_VERGEBEN' ? zeitVergeben() : err
|
|
}
|
|
|
|
let buchung
|
|
try {
|
|
buchung = await aendere(id, {
|
|
startsAt: iso(start),
|
|
endsAt: iso(ende),
|
|
blockEndsAt: iso(block),
|
|
ressourceId,
|
|
ressourceName: ctx.einstellung.ressourcen.find((r) => r.id === ressourceId)?.name ?? alt.ressourceName,
|
|
icsSequenz: (Number.isInteger(alt.icsSequenz) ? alt.icsSequenz : 0) + 1,
|
|
// eine fuer die alte Zeit schon gesendete Erinnerung gilt nicht fuer die neue
|
|
...(alt.erinnerungGesendetAt ? { erinnerungGesendetAt: null } : {}),
|
|
updatedAt: iso(now),
|
|
})
|
|
} catch (err) {
|
|
// die neuen Zellen wieder abgeben: ausser den alten bleibt nichts stehen
|
|
await gibFreiSicher(id, { behalte: zellenFuer(alt.startsAt, alt.blockEndsAt), ressourceId: alt.ressourceId })
|
|
throw err
|
|
}
|
|
// Mit der Ressource des neuen Termins bleiben nur dessen Zellen; die der alten Zeit (und einer anderen Ressource) gehen.
|
|
await gibFreiSicher(id, { behalte: zellenFuer(start, block), ressourceId })
|
|
return { alt, buchung, start }
|
|
}
|
|
|
|
/**
|
|
* Gast verschiebt per Verwaltungslink (gleiche Frist wie beim Absagen) auf eine freie Zeit. Die eigene Buchung belegt
|
|
* die neue Zeit nicht selbst (belegungAus ohneBuchung, belegeZellen ausser), 10:00 -> 10:30 ueberlappend ist also
|
|
* moeglich. Reihenfolge unter `termine-buchung:<id>`: frisch lesen, neue Zeit berechnen, neue Zellen belegen (SLOT_VERGEBEN
|
|
* => 409, alles unveraendert), Buchung aendern (Beginn, Ende, Blockende, Ressource, icsSequenz + 1; scheitert das, werden
|
|
* die neuen Zellen wieder entfernt), erst dann die alten Zellen freigeben. Status und Verwaltungslink bleiben. Danach G07
|
|
* (neuer Termin, Kalender-REQUEST mit hoeherer Sequenz; bei einer Anfrage ohne Kalenderdatei) und K21 `verschoben`.
|
|
* @param {string} token Verwaltungstoken
|
|
* @param {{ tag: string, zeit: string }} roh Berliner Tag 'YYYY-MM-DD' und Uhrzeit 'HH:MM'
|
|
* @param {Date} now
|
|
* @returns {Promise<{ status: string, termin: { datum: string, zeit: string } }>}
|
|
* Eine verbindliche, kostenpflichtige Verbraucherbuchung ohne Zustimmung zum vorzeitigen Beginn laesst sich nicht auf einen
|
|
* Beginn innerhalb der Widerrufsfrist verschieben (409 'zustimmung_noetig').
|
|
* @throws Error 400 (Felder tag, zeit; dieselbe Zeit), 404, 409 ('frist_vorbei', 'nicht_moeglich', 'zustimmung_noetig',
|
|
* 'SLOT_VERGEBEN' Feld zeit)
|
|
*/
|
|
export async function verschiebeGast(token, roh, now) {
|
|
const nowMs = pruefeNow(now, 'verschiebeGast')
|
|
const { tag, zeit } = pruefeVerschiebung(roh)
|
|
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
|
const id = gefunden.$id
|
|
|
|
const ergebnis = await withLocks([`termine-buchung:${id}`], async () => {
|
|
const alt = await hole(id)
|
|
if (!alt) throw unbekannt()
|
|
pruefeAbsagbar(ctx.einstellung, alt, now)
|
|
return verschiebeKern(ctx, alt, { tag, zeit }, now)
|
|
})
|
|
|
|
const { alt, buchung, start } = ergebnis
|
|
const ohneIcs = alt.status === 'angefragt'
|
|
await mailSicher('G07', id, () => anGast('G07', ctx, buchung, { altStart: alt.startsAt, ohneIcs }, now))
|
|
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: 'verschoben', extra: { altStart: alt.startsAt } }, now))
|
|
return { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } }
|
|
}
|
|
|
|
/**
|
|
* Freie Zeiten fuer die Gaesteseite beim Verschieben (Verwaltungstoken), mit der Belegung ohne die eigene Buchung und in
|
|
* der gebuchten Dauer. Dieselben Formen wie die oeffentliche Schnittstelle: `{ tag }` => { zeiten: ['HH:MM', ...] },
|
|
* `{ monat: 'YYYY-MM' }` => { tage: ['YYYY-MM-DD', ...], monat: monatInfo }. Nur solange Verschieben moeglich ist.
|
|
* @param {string} token Verwaltungstoken
|
|
* @param {{ tag?: string, monat?: string }} roh
|
|
* @param {Date} now
|
|
* @throws Error 400 (Feld tag bzw. monat), 404, 409 ('frist_vorbei', 'nicht_moeglich')
|
|
*/
|
|
export async function zeitenFuerToken(token, roh, now) {
|
|
const nowMs = pruefeNow(now, 'zeitenFuerToken')
|
|
const abfrage = istObjekt(roh) ? roh : {}
|
|
const nachMonat = abfrage.tag === undefined && abfrage.monat !== undefined
|
|
const tag = typeof abfrage.tag === 'string' && zuUtc(abfrage.tag, '12:00') !== null ? abfrage.tag : null
|
|
const info = nachMonat ? monatInfo(abfrage.monat) : null
|
|
if (nachMonat ? info === null : tag === null) throw fehlerMit(400, nachMonat ? { monat: MELDUNG.monat } : { tag: MELDUNG.tag })
|
|
|
|
const { ctx, buchung } = await ladeVerwaltung(token, nowMs)
|
|
pruefeAbsagbar(ctx.einstellung, buchung, now)
|
|
if (!nachMonat) return { zeiten: (await verschiebeZeiten(ctx, buchung, tag, now)).zeiten.map((z) => z.uhrzeit) }
|
|
|
|
const imMonat = await imZeitraum(ctx.feature.$id, zuUtc(`${abfrage.monat}-01`, '00:00'), zuUtc(`${info.nachher}-01`, '00:00'))
|
|
const tage = freieTage({
|
|
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
|
|
leistungId: buchung.leistungId,
|
|
ressourceId: verschiebeRessource(ctx.einstellung, buchung),
|
|
monat: abfrage.monat,
|
|
belegung: belegungAus(imMonat, now, { ohneBuchung: buchung.$id }),
|
|
now,
|
|
})
|
|
return { tage, monat: info }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Widerruf
|
|
|
|
const VERTRAG_STATUS = ['bestaetigt', 'erledigt', 'nichterschienen']
|
|
|
|
/**
|
|
* Wie steht die Buchung zu einer Widerrufserklaerung? 'wirksam' (kannWiderrufen); 'nach_frist' (ein Verbrauchervertrag
|
|
* besteht oder bestand und die Widerrufsfrist ist wirklich abgelaufen); 'begonnen' (die Frist laeuft noch, aber der
|
|
* Termin hat schon begonnen: ob das Recht erloschen ist oder Wertersatz geschuldet wird, prueft der Betrieb, R27);
|
|
* 'kein_recht' (Unternehmer, Freizeit, Handeintrag, noch nicht angenommene Anfrage, abgesagt, ...).
|
|
*/
|
|
function widerrufLage(buchung, now) {
|
|
if (kannWiderrufen(buchung, now)) return 'wirksam'
|
|
const vertrag = buchung.verbraucher === true && buchung.quelle !== 'manuell' && Number.isFinite(vertragAm(buchung)) && VERTRAG_STATUS.includes(buchung.status)
|
|
if (!vertrag) return 'kein_recht'
|
|
return now.getTime() <= fristEnde(buchung) ? 'begonnen' : 'nach_frist'
|
|
}
|
|
|
|
/** Eingang einer Erklaerung als Berliner Strings ('TT.MM.JJJJ', 'HH:MM'). */
|
|
const eingangVon = (wann) => ({ datum: datumKurz(wann), uhrzeit: uhrzeit(wann) })
|
|
|
|
/**
|
|
* Eingabe der Widerrufserklaerung: Name (Pflicht) und, ohne Link, die E-Mail-Adresse (Pflicht, ein Postfach, klein und
|
|
* getrimmt). Die Buchungsnummer ist freiwillig: fehlt sie oder passt sie nicht, geht die Erklaerung als "nicht
|
|
* zugeordnet" an den Betrieb, statt dass das Formular sie ablehnt.
|
|
*/
|
|
function pruefeWiderrufEingabe(roh, { mitAdresse }) {
|
|
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
|
|
const { fehler, fehl } = fehlerSammler()
|
|
const name = pruefeNameFeld(roh.name, fehl)
|
|
let email = null
|
|
if (mitAdresse) {
|
|
email = emailAdresse(roh.email)
|
|
if (email === null) fehl('email', MELDUNG.email)
|
|
}
|
|
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
|
|
return { name, email, nummer: bereinigterText(roh.nummer, NUMMER_MAX) }
|
|
}
|
|
|
|
/**
|
|
* Erklaerung zu einer gefundenen Buchung festhalten, unter `termine-buchung:<id>` nach frischem Lesen.
|
|
* wirksam: `abgesagt` von `gast`, widerrufenAt, Zellen frei. nach_frist und begonnen: nur ein Vermerk in absageGrund,
|
|
* sonst bleibt alles. kein_recht: nichts. schon: die Buchung war schon widerrufen (nichts). weg: gibt es nicht mehr.
|
|
* => { lage, buchung }
|
|
*/
|
|
async function haltWiderrufFest(id, name, now) {
|
|
return withLocks([`termine-buchung:${id}`], async () => {
|
|
const frisch = await hole(id)
|
|
if (!frisch) return { lage: 'weg' }
|
|
if (frisch.widerrufenAt) return { lage: 'schon', buchung: frisch }
|
|
const lage = widerrufLage(frisch, now)
|
|
if (lage === 'kein_recht') return { lage, buchung: frisch }
|
|
const eingang = eingangVon(now)
|
|
const wann = `${eingang.datum}, ${eingang.uhrzeit} Uhr`
|
|
if (lage !== 'wirksam') {
|
|
const wannText = lage === 'begonnen' ? 'nach Terminbeginn' : 'nach Ablauf der Frist'
|
|
const vermerk = `Widerruf ${wannText} erklärt von: ${name} (eingegangen ${wann})`
|
|
return { lage, buchung: await aendere(id, { absageGrund: vermerk, updatedAt: iso(now) }) }
|
|
}
|
|
const buchung = await aendere(id, {
|
|
status: 'abgesagt',
|
|
abgesagtAt: iso(now),
|
|
abgesagtVon: 'gast',
|
|
widerrufenAt: iso(now),
|
|
absageGrund: `Widerruf erklärt von: ${name} (eingegangen ${wann})`,
|
|
updatedAt: iso(now),
|
|
})
|
|
await gibFreiSicher(id)
|
|
return { lage, buchung }
|
|
})
|
|
}
|
|
|
|
/** Nicht gesendet, weil eine Grenze erreicht ist: Log ohne personenbezogene Daten, die Antwort an den Gast aendert das nicht. */
|
|
function ueberspringe(was, bookingId) {
|
|
console.warn(`[termine-ablauf] ${was} nicht gesendet (${bookingId ? buchungsNummer(bookingId) : '-'}): Grenze erreicht`)
|
|
}
|
|
|
|
const WIDERRUF_MAIL = { wirksam: ['zugeordnet', 'widerrufen'], nach_frist: ['nach_frist', 'nach_frist'], begonnen: ['begonnen', 'begonnen'] }
|
|
|
|
/**
|
|
* Mails zu einer Erklaerung, die zu einer Buchung gehoert: G08 an die Adresse des Gastes und K21 an den Betrieb (Fall je
|
|
* Lage: G08 zugeordnet/nach_frist/begonnen, K21 widerrufen/nach_frist/begonnen). Ein Treffer unterliegt keiner Grenze
|
|
* (R30): die Eingangsbestaetigung muss immer rausgehen, die Grenzen schuetzen nur vor Erklaerungen ins Leere.
|
|
*/
|
|
async function widerrufsMails(ctx, lage, buchung, adresse, now) {
|
|
const id = buchung.$id
|
|
const [g08, k21] = WIDERRUF_MAIL[lage]
|
|
await mailSicher('G08', id, () => anGast('G08', ctx, buchung, { fall: g08, eingang: now, an: adresse }, now))
|
|
await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: k21, extra: { eingang: now } }, now))
|
|
}
|
|
|
|
/**
|
|
* Erklaerung ohne passende Buchung: G08 (ohne Bezug) an die angegebene Adresse, K21 `nicht_zugeordnet` an den Betrieb.
|
|
* begrenzt (kein Treffer): G08 und K21 unterliegen den Grenzen; ohne begrenzt (Buchung ohne Widerrufsrecht, aber
|
|
* mit passender Adresse und Nummer) gehen beide immer raus (R30).
|
|
*/
|
|
async function nichtZugeordnetMails(ctx, { name, email, nummer }, now, { begrenzt }) {
|
|
const featureId = ctx.feature.$id
|
|
if (!begrenzt || darfG08(featureId, email, now)) {
|
|
await mailSicher('G08', null, () => anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: now, an: email }, now))
|
|
} else {
|
|
ueberspringe('G08', null)
|
|
}
|
|
if (begrenzt && !darfK21NichtZugeordnet(featureId, now)) return ueberspringe('K21', null)
|
|
const erklaerung = { gastName: name, gastEmail: email, gastTelefon: '', gastNachricht: `Angegebene Buchungsnummer: ${nummer || 'keine Angabe'}` }
|
|
await mailSicher('K21', null, () => anBetrieb('K21', ctx, erklaerung, { block: 'nicht_zugeordnet', extra: { eingang: now } }, now))
|
|
}
|
|
|
|
/**
|
|
* Gast widerruft per Verwaltungslink (Formular nach § 356a BGB, Knopf "Widerruf bestätigen"). Wirksam, solange ein
|
|
* Widerrufsrecht besteht (kannWiderrufen), unabhaengig von der Stornofrist: `abgesagt` von `gast`, widerrufenAt, Zellen
|
|
* frei, G08 `zugeordnet` an die Adresse der Buchung, K21 `widerrufen`. Nach Ablauf der Frist wird die Erklaerung nur
|
|
* vermerkt und mit G08/K21 `nach_frist` gemeldet, nach Terminbeginn bei noch laufender Frist mit G08 (ohne Fallblock)
|
|
* und K21 `begonnen`. G08 geht hier immer raus (keine Grenze, R30). Hat die Buchung kein Widerrufsrecht (nicht
|
|
* angenommene Anfrage, Unternehmer, ...), 409 code 'kein_widerruf'. Ein weiterer Klick meldet den Eingang der ersten Erklaerung.
|
|
* @param {string} token Verwaltungstoken
|
|
* @param {{ name: string }} roh
|
|
* @param {Date} now
|
|
* @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>} Berliner Zeit ('TT.MM.JJJJ', 'HH:MM')
|
|
*/
|
|
export async function widerrufeGast(token, roh, now) {
|
|
const nowMs = pruefeNow(now, 'widerrufeGast')
|
|
const { name } = pruefeWiderrufEingabe(roh, { mitAdresse: false })
|
|
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
|
|
|
|
const erg = await haltWiderrufFest(gefunden.$id, name, now)
|
|
if (erg.lage === 'weg') throw unbekannt()
|
|
if (erg.lage === 'kein_recht') throw fehlerMit(409, { _: MELDUNG.keinWiderruf }, { code: 'kein_widerruf' })
|
|
if (erg.lage === 'schon') return { eingang: eingangVon(new Date(zeitMs(erg.buchung.widerrufenAt))) }
|
|
await widerrufsMails(ctx, erg.lage, erg.buchung, erg.buchung.gastEmail, now)
|
|
return { eingang: eingangVon(now) }
|
|
}
|
|
|
|
/**
|
|
* Widerruf ohne Link (Widerrufsfunktion im Widget, § 356a BGB). Die Antwort ist in jedem Fall dieselbe
|
|
* (`{ eingang }` mit dem Eingang jetzt): sie verraet nicht, ob eine Buchung zu E-Mail-Adresse und Nummer passt.
|
|
* - Treffer mit Widerrufsrecht: wie widerrufeGast (abgesagt, widerrufenAt, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`).
|
|
* - Treffer nach Ablauf der Widerrufsfrist: nur vermerkt, G08 `nach_frist` und K21 `nach_frist`; der Termin bleibt.
|
|
* - Treffer nach Terminbeginn, Frist noch nicht um: nur vermerkt, G08 ohne Fallblock und K21 `begonnen`; der Betrieb
|
|
* prueft, ob das Recht erloschen ist oder Wertersatz geschuldet wird.
|
|
* - sonst (kein Treffer oder Buchung ohne Widerrufsrecht): G08 ohne Bezug an die angegebene Adresse und K21
|
|
* `nicht_zugeordnet` an den Betrieb, damit der Betrieb die Erklaerung pruefen kann.
|
|
* G08 geht nie mit Text des Gastes raus. Grenzen (limits.js): 3 G08 je Adresse und Tag, 20 je Funktion und Stunde,
|
|
* 30 K21 `nicht_zugeordnet` je Funktion und Stunde, nur fuer Erklaerungen ohne Treffer (ein Treffer bekommt seine
|
|
* Mails immer, R30); eine erreichte Grenze ueberspringt nur die Mail.
|
|
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
|
|
* @param {{ name: string, email: string, nummer?: string }} roh
|
|
* @param {Date} now
|
|
* @returns {Promise<{ eingang: { datum: string, uhrzeit: string } }>}
|
|
* @throws Error 400 (Felder name, email)
|
|
*/
|
|
export async function widerrufeOhneLink(ctx, roh, now) {
|
|
pruefeNow(now, 'widerrufeOhneLink')
|
|
if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError('widerrufeOhneLink: ctx fehlt')
|
|
const erklaerung = pruefeWiderrufEingabe(roh, { mitAdresse: true })
|
|
const antwort = { eingang: eingangVon(now) }
|
|
|
|
const treffer = await perEmailUndNummer(ctx.feature.$id, erklaerung.email, erklaerung.nummer)
|
|
const erg = treffer ? await haltWiderrufFest(treffer.$id, erklaerung.name, now) : { lage: 'weg' }
|
|
if (erg.lage === 'schon') return antwort // schon bestaetigt und gemeldet
|
|
if (erg.lage === 'wirksam' || erg.lage === 'nach_frist' || erg.lage === 'begonnen') await widerrufsMails(ctx, erg.lage, erg.buchung, erklaerung.email, now)
|
|
else await nichtZugeordnetMails(ctx, erklaerung, now, { begrenzt: erg.lage === 'weg' })
|
|
return antwort
|
|
}
|