Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren

Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben,
nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert
die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot,
verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist
(withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent c4ec24448e
commit be49d38e0c
8 files changed
+1563 -79

No files matched your search

+93 -63
View File
@@ -56,6 +56,10 @@ import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js'
* 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
@@ -72,7 +76,7 @@ const TELEFON_MAX = 40
const ZUSATZ_WERT_MAX = 200
const ZUSATZ_JSON_MAX = 2000
const MELDUNG = {
export const MELDUNG = {
formular: 'Bitte das Formular ausfüllen.',
leistung: 'Bitte eine Leistung auswählen.',
ressource: 'Bitte aus den angebotenen Möglichkeiten auswählen.',
@@ -100,14 +104,14 @@ const MELDUNG = {
keinWiderruf: 'Für diesen Termin ist kein Widerruf möglich.',
}
const iso = (wert) => new Date(wert).toISOString()
export 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)
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). */
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(Object.values(fehler)[0]), { status, fehler, ...extra })
export 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' })
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"). */
@@ -115,7 +119,7 @@ const betriebKurz = (ctx) => ({ name: ctx.einstellung.betrieb.name, telefon: 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) }. */
function fehlerSammler() {
export function fehlerSammler() {
const fehler = {}
const fehl = (feld, meldung) => {
if (!Object.hasOwn(fehler, feld)) fehler[feld] = meldung
@@ -132,13 +136,13 @@ function pruefeNameFeld(wert, fehl) {
}
/** Log ohne personenbezogene Daten: Vorgang, Buchungsnummer, Art und Text des Fehlers (kommt aus unserem Code oder Appwrite). */
function protokolliere(was, bookingId, err) {
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. */
async function mailSicher(was, bookingId, fn) {
export async function mailSicher(was, bookingId, fn) {
try {
await fn()
} catch (err) {
@@ -151,8 +155,10 @@ async function mailSicher(was, bookingId, fn) {
/**
* 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): die E-Mail-Adresse ist freiwillig (leer => ''; ist sie angegeben, muss sie
* stimmen), Erinnerung und Zustimmungen entfallen (der Betrieb traegt ein, der Gast klickt nichts an).
*/
function pruefeEingabe(e, roh, now) {
export function pruefeEingabe(e, roh, now, { manuell = false } = {}) {
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
const { fehler, fehl } = fehlerSammler()
@@ -180,7 +186,8 @@ function pruefeEingabe(e, roh, now) {
// Name, E-Mail
const name = pruefeNameFeld(roh.name, fehl)
const email = emailAdresse(roh.email)
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)
// Telefon, Nachricht, Zusatzfelder nach gastFelder
@@ -205,18 +212,18 @@ function pruefeEingabe(e, roh, now) {
}
const zusatz = pruefeZusatz(felder.zusatz || [], roh.zusatz, fehl)
// Erinnerung (freiwillig, nur wenn angeboten)
// Erinnerung (freiwillig, nur wenn angeboten; nicht bei Handeintraegen)
let erinnerung = false
if (roh.erinnerung !== undefined && roh.erinnerung !== null) {
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 = typeof e.storno?.text === 'string' && e.storno.text !== ''
const stornoNoetig = !manuell && 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
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)
@@ -280,13 +287,16 @@ function baueNachweis(e, g, verbraucher, now) {
/** 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. */
async function pruefeSlot(ctx, g, now) {
/**
* 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 })
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 }
@@ -411,7 +421,7 @@ async function findePerToken(token, { nurBestaetigung = false, nurVerwaltung = f
// ---------------------------------------------------------------- Aktionen des Gastes
const VERWALTBAR = ['bestaetigt', 'angefragt']
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) {
@@ -638,7 +648,7 @@ function pruefeAbsagbar(einstellung, buchung, now) {
* 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).
*/
async function gibFreiSicher(bookingId, optionen) {
export async function gibFreiSicher(bookingId, optionen) {
try {
await gibZellenFrei(bookingId, optionen)
} catch (err) {
@@ -694,8 +704,11 @@ function mitGebuchterDauer(einstellung, buchung) {
*/
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 }. */
async function verschiebeZeiten(ctx, buchung, tag, now) {
/**
* Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }.
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont).
*/
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),
@@ -704,6 +717,7 @@ async function verschiebeZeiten(ctx, buchung, tag, now) {
tag,
belegung: belegungAus(amTag, now, { ohneBuchung: buchung.$id }),
now,
ohneRegeln,
})
return { zeiten, amTag }
}
@@ -734,7 +748,7 @@ 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. */
function pruefeVerschiebung(roh) {
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
@@ -745,6 +759,62 @@ function pruefeVerschiebung(roh) {
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
@@ -766,52 +836,12 @@ export async function verschiebeGast(token, roh, now) {
const { tag, zeit } = pruefeVerschiebung(roh)
const { ctx, buchung: gefunden } = await ladeVerwaltung(token, nowMs)
const id = gefunden.$id
const featureId = ctx.feature.$id
const ergebnis = await withLocks([`termine-buchung:${id}`], async () => {
const alt = await hole(id)
if (!alt) throw unbekannt()
pruefeAbsagbar(ctx.einstellung, alt, now)
const { zeiten, amTag } = await verschiebeZeiten(ctx, alt, tag, now)
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 (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 }
return verschiebeKern(ctx, alt, { tag, zeit }, now)
})
const { alt, buchung, start } = ergebnis
+444
View File
@@ -0,0 +1,444 @@
import { randomUUID } from 'node:crypto'
import { withLocks } from '../keyedLock.js'
import { aendere, hole, legeAn, loesche } from './buchungen.js'
import { text as bereinigterText } from './einstellungen.js'
import { anGast } from './mails.js'
import {
MELDUNG,
VERWALTBAR,
fehlerMit,
fehlerSammler,
gibFreiSicher,
iso,
istObjekt,
mailSicher,
protokolliere,
pruefeEingabe,
pruefeSlot,
pruefeVerschiebung,
verschiebeKern,
zeitVergeben,
} from './ablauf.js'
import { blockEnde } from './slots.js'
import { buchungsNummer, hashToken, verwaltungsToken } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { datumLang, uhrzeit, zuUtc } from './zeit.js'
import { belegeZellen } from './zellen.js'
/**
* Betrieb im Portal (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 4, "Betrieb im Portal"): Aktionen auf
* Buchungen, Termine von Hand und Sperren. Gleiche Regeln wie ablauf.js: Zustandsaenderungen unter
* `termine-buchung:<id>` nach frischem Lesen, `termine-res:` wird hier nie selbst gehalten, Mails gehen erst NACH der
* Aenderung und ausserhalb der Sperre in die Warteschlange (Fehler nur im Log), `now` ist immer ein Parameter.
*
* Der Betrieb handelt auf eigenen Wunsch, darum: keine K21-Mail an den Betrieb, keine Absagefrist, beim Verschieben kein
* Vorlauf und kein Horizont (ohneRegeln; nie in die Vergangenheit, nie auf belegte Zellen), keine Zustimmung zum
* vorzeitigen Beginn. Mails an den Gast gehen nur, wenn die Buchung eine Adresse hat (Handeintrag ohne Adresse,
* anonymisiert). Wer nie eine Einladung bekam (Status `angefragt`), bekommt bei Absage und Verschiebung keine Kalenderdatei (R21).
*
* Fehler: Error mit `status` und `fehler` = { <feld|_>: Text }, Texte in Du-Form (Portal). Unbekannte Buchung, Buchung einer
* anderen Funktion oder eine Sperre bei `aktion` 404 (code 'unbekannt'); nicht (mehr) moegliche Aktion 409 (code
* 'nicht_moeglich'); eine schon erfolgte Aktion (zweiter Klick) aendert nichts und meldet `schon: true`.
*/
const MIN_MS = 60 * 1000
const AKTIONEN = ['annehmen', 'ablehnen', 'absagen', 'verschieben', 'nichterschienen', 'anonymisieren']
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const BIS_RE = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/
const VON_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/
const GRUND_MAX = 100
const NACHRICHT_MAX = 500
const TITEL_MAX = 120
/** Laenge einer Belegung: hoechstens 100 Zellen zu 5 Minuten (ein Bulk-Create). */
const BELEGUNG_MAX_MIN = 500
const GELOESCHT = 'Gelöscht'
const SPERRE_TITEL = 'Gesperrt'
const MELDUNG_BETRIEB = {
unbekannt: 'Diesen Termin gibt es nicht.',
keineSperre: 'Diese Sperre gibt es nicht.',
aktion: 'Bitte eine Aktion auswählen.',
grund: 'Bitte einen Grund auswählen.',
grundLang: `Der Grund darf höchstens ${GRUND_MAX} Zeichen lang sein.`,
nichtOffen: 'Diese Anfrage ist nicht mehr offen.',
nichtAbsagbar: 'Dieser Termin ist nicht mehr offen. Er kann nicht abgesagt oder verschoben werden.',
vergangen: 'Der Termin liegt in der Vergangenheit. Lehne die Anfrage ab.',
vorBeginn: 'Das geht erst, wenn der Termin begonnen hat.',
nichtMarkierbar: 'Dieser Termin kann nicht als „nicht erschienen“ markiert werden.',
nochOffen: 'Dieser Termin ist noch nicht bestätigt.',
zuLang: `Dieser Termin ist länger als ${BELEGUNG_MAX_MIN} Minuten.`,
schritt: 'Bitte eine Uhrzeit in 5-Minuten-Schritten angeben (z. B. 09:00).',
endeVorBeginn: 'Das Ende muss nach dem Beginn liegen.',
sperreLang: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.',
sperreVorbei: 'Die Sperre muss in der Zukunft enden.',
sperreBelegt: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.',
titelLang: `Der Titel darf höchstens ${TITEL_MAX} Zeichen lang sein.`,
}
const nichtGefunden = (text = MELDUNG_BETRIEB.unbekannt) => fehlerMit(404, { _: text }, { code: 'unbekannt' })
const nichtMoeglich = (text) => fehlerMit(409, { _: text }, { code: 'nicht_moeglich' })
/** Kontext wie in ablauf.js (halte): Betriebsangaben und Funktion muessen da sein. */
function pruefeKontext(ctx, name) {
if (!ctx?.einstellung?.betrieb || typeof ctx.feature?.$id !== 'string') throw new TypeError(`${name}: ctx fehlt`)
}
const hatAdresse = (buchung) => typeof buchung?.gastEmail === 'string' && buchung.gastEmail.trim() !== ''
/** Mail an den Gast, falls die Buchung eine Adresse hat; ein Fehler macht die Aenderung nicht rueckgaengig (mailSicher). */
async function mailAnGast(art, ctx, buchung, extra, now) {
if (!hatAdresse(buchung)) return
await mailSicher(art, buchung.$id, () => anGast(art, ctx, buchung, extra, now))
}
// ---------------------------------------------------------------- Aktionen auf Buchungen
/** Prueft die Eingabe der Aktion; => { aktion, grund?, nachricht?, tag?, zeit? }, sonst 400 mit Feldfehlern. */
function pruefeAktion(roh) {
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
const art = roh.aktion
if (typeof art !== 'string' || !AKTIONEN.includes(art)) throw fehlerMit(400, { aktion: MELDUNG_BETRIEB.aktion })
if (art === 'verschieben') return { aktion: art, ...pruefeVerschiebung(roh) }
const { fehler, fehl } = fehlerSammler()
const eingabe = { aktion: art }
if (art === 'absagen') {
eingabe.grund = bereinigterText(roh.grund, GRUND_MAX + 1)
if (eingabe.grund === '') fehl('grund', MELDUNG_BETRIEB.grund)
else if ([...eingabe.grund].length > GRUND_MAX) fehl('grund', MELDUNG_BETRIEB.grundLang)
}
if (art === 'absagen' || art === 'ablehnen') {
eingabe.nachricht = bereinigterText(roh.nachricht, NACHRICHT_MAX + 1, { mehrzeilig: true })
if ([...eingabe.nachricht].length > NACHRICHT_MAX) fehl('nachricht', MELDUNG.nachrichtLang)
}
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
return eingabe
}
/** Anfrage annehmen: `angefragt` => `bestaetigt` mit angenommenAt (Beginn der Widerrufsfrist), G02 mit Einladung. */
async function annehmen(ctx, frisch, _eingabe, now) {
if (frisch.status === 'bestaetigt' && frisch.angenommenAt) return { antwort: { status: 'bestaetigt', schon: true } }
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
if (!(zeitMs(frisch.startsAt) > now.getTime())) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vergangen }, { code: 'vergangen' })
const buchung = await aendere(frisch.$id, { status: 'bestaetigt', angenommenAt: iso(now), updatedAt: iso(now) })
return { antwort: { status: 'bestaetigt' }, mail: () => mailAnGast('G02', ctx, buchung, { anfrageAngenommen: true }, now) }
}
/** Anfrage ablehnen: `abgelehnt`, Zellen frei, G04 (mit der Nachricht des Betriebs, falls angegeben). */
async function ablehnen(ctx, frisch, eingabe, now) {
if (frisch.status === 'abgelehnt') return { antwort: { status: 'abgelehnt', schon: true } }
if (frisch.status !== 'angefragt') throw nichtMoeglich(MELDUNG_BETRIEB.nichtOffen)
const buchung = await aendere(frisch.$id, { status: 'abgelehnt', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return { antwort: { status: 'abgelehnt' }, mail: () => mailAnGast('G04', ctx, buchung, { nachricht: eingabe.nachricht }, now) }
}
/** Absage durch den Betrieb: `abgesagt` von `betrieb` mit Grund, Zellen frei, G06 (Kalenderabsage nur nach einer Einladung). */
async function absagen(ctx, frisch, eingabe, now) {
if (frisch.status === 'abgesagt') return { antwort: { status: 'abgesagt', schon: true } }
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
const buchung = await aendere(frisch.$id, {
status: 'abgesagt',
abgesagtAt: iso(now),
abgesagtVon: 'betrieb',
absageGrund: eingabe.grund,
updatedAt: iso(now),
})
await gibFreiSicher(frisch.$id)
const extra = { von: 'betrieb', grund: eingabe.grund, nachricht: eingabe.nachricht, ohneIcs: frisch.status === 'angefragt' }
return { antwort: { status: 'abgesagt' }, mail: () => mailAnGast('G06', ctx, buchung, extra, now) }
}
/** Verschieben durch den Betrieb: derselbe Kern wie beim Gast (verschiebeKern), aber ohne Frist, Vorlauf und Horizont. G07 an den Gast. */
async function verschieben(ctx, frisch, eingabe, now) {
if (!VERWALTBAR.includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtAbsagbar)
const { alt, buchung, start } = await verschiebeKern(ctx, frisch, eingabe, now, { betrieb: true })
const extra = { altStart: alt.startsAt, ohneIcs: alt.status === 'angefragt' }
return {
antwort: { status: buchung.status, termin: { datum: datumLang(start), zeit: uhrzeit(start) } },
mail: () => mailAnGast('G07', ctx, buchung, extra, now),
}
}
/** Gast nicht erschienen: nur nach Beginn des Termins, aus `bestaetigt` (oder schon `erledigt`). */
async function nichterschienen(_ctx, frisch, _eingabe, now) {
if (frisch.status === 'nichterschienen') return { antwort: { status: 'nichterschienen', schon: true } }
if (!['bestaetigt', 'erledigt'].includes(frisch.status)) throw nichtMoeglich(MELDUNG_BETRIEB.nichtMarkierbar)
if (now.getTime() < zeitMs(frisch.startsAt)) throw fehlerMit(409, { _: MELDUNG_BETRIEB.vorBeginn }, { code: 'vor_beginn' })
await aendere(frisch.$id, { status: 'nichterschienen', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return { antwort: { status: 'nichterschienen' } }
}
/**
* Gastdaten auf Wunsch loeschen: Name "Gelöscht", E-Mail, Telefon, Nachricht und Zusatzfelder leer, keine Erinnerung,
* Links ungueltig (Hashes leer). Status und Zeiten bleiben. Bei abgesagt/abgelehnt bleibt updatedAt stehen, weil davon
* die Loeschfrist (30 Tage) laeuft. Eine noch nicht bestaetigte Haltung bleibt unberuehrt (409).
*/
async function anonymisieren(_ctx, frisch, _eingabe, now) {
if (frisch.status === 'gehalten') throw nichtMoeglich(MELDUNG_BETRIEB.nochOffen)
const behalteFrist = frisch.status === 'abgesagt' || frisch.status === 'abgelehnt'
const buchung = await aendere(frisch.$id, {
gastName: GELOESCHT,
gastEmail: '',
gastTelefon: '',
gastNachricht: '',
gastZusatz: '',
erinnerung: false,
verifyHash: '',
manageHash: '',
updatedAt: iso(behalteFrist && frisch.updatedAt ? frisch.updatedAt : now),
})
return { antwort: { status: buchung.status } }
}
const AKTION = { annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren }
/**
* Aktion des Betriebs auf eine Buchung der Funktion.
* - annehmen: `angefragt` => `bestaetigt`, angenommenAt; G02 (Block anfrage_angenommen) mit Einladung (REQUEST, Sequenz
* der Buchung) und Verwaltungslink. Nicht fuer eine Anfrage, deren Beginn vorbei ist (409 'vergangen').
* - ablehnen: `angefragt` => `abgelehnt`, Zellen frei, G04 (optional `nachricht`).
* - absagen: `bestaetigt`/`angefragt` => `abgesagt` von `betrieb`, Pflicht `grund`, optional `nachricht`, Zellen frei,
* G06 (absage_von = Betriebsname; ohne Kalenderabsage fuer eine Anfrage). Keine Absagefrist.
* - verschieben: `{ tag, zeit }`, wie beim Gast, aber ohne Frist, Vorlauf, Horizont und Zustimmungspruefung; G07.
* - nichterschienen: erst ab Beginn (409 'vor_beginn').
* - anonymisieren: Gastfelder leeren, Status und Zeiten bleiben.
* Kein K21 an den Betrieb: er hat es selbst getan.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {string} bookingId
* @param {{ aktion: string, grund?: string, nachricht?: string, tag?: string, zeit?: string }} roh
* @param {Date} now
* @returns {Promise<{ status: string, schon?: true, termin?: { datum: string, zeit: string } }>}
* @throws Error 400 (Eingabe; beim Verschieben auch dieselbe Zeit), 404 (unbekannt), 409 ('nicht_moeglich', 'vergangen',
* 'vor_beginn'; beim Verschieben auch 'SLOT_VERGEBEN' Feld zeit)
*/
export async function aktion(ctx, bookingId, roh, now) {
pruefeNow(now, 'aktion')
pruefeKontext(ctx, 'aktion')
const eingabe = pruefeAktion(roh)
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden()
const ergebnis = await withLocks([`termine-buchung:${bookingId}`], async () => {
const frisch = await hole(bookingId)
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle === 'sperre') throw nichtGefunden()
return AKTION[eingabe.aktion](ctx, frisch, eingabe, now)
})
if (ergebnis.mail) await ergebnis.mail()
return ergebnis.antwort
}
// ---------------------------------------------------------------- Termine von Hand
/** Eine gescheiterte Neuanlage (Zellen nicht belegbar) wieder entfernen; scheitert das, wird sie als verfallen vermerkt. */
async function nimmZurueck(bookingId, now) {
try {
await withLocks([`termine-buchung:${bookingId}`], () => loesche(bookingId))
} catch (err) {
protokolliere('Buchung nicht entfernt', bookingId, err)
try {
await withLocks([`termine-buchung:${bookingId}`], () => aendere(bookingId, { status: 'verfallen', heldUntil: iso(now), updatedAt: iso(now) }))
} catch (fehler) {
protokolliere('Buchung nicht als verfallen vermerkt', bookingId, fehler)
}
}
}
/**
* Legt eine bestaetigte Buchung (Handeintrag oder Sperre) und danach ihre Zellen an. Reihenfolge wie beim Halten
* (zellen.js): die Buchung steht VOR den Zellen in der Datenbank; scheitert das Belegen, wird sie wieder entfernt.
* @param {() => Error} belegt erzeugt den Fehler fuer SLOT_VERGEBEN (409); andere Fehler gehen unveraendert durch
*/
async function legeBelegungAn(ctx, id, daten, ressourceId, start, block, now, belegt) {
const buchung = await legeAn(daten, id)
try {
await belegeZellen({ featureId: ctx.feature.$id, ressourceId, bookingId: id, start, blockEnde: block, now })
} catch (err) {
await nimmZurueck(id, now)
throw err?.code === 'SLOT_VERGEBEN' ? belegt() : err
}
return buchung
}
/**
* Termin von Hand eintragen (Telefon, Laufkundschaft): `quelle 'manuell'`, sofort `bestaetigt`, `verbraucher false`
* (kein Widerruf, keine Belehrung durch das System: bei telefonischen Buchungen Sache des Betriebs). Eingabe und Pruefung
* wie beim Gast (Leistung, Person oder "egal", Tag, Uhrzeit, Name, Telefon, Nachricht, Zusatzfelder), aber ohne Zustimmungen
* und Erinnerung, die E-Mail-Adresse ist freiwillig. Die Zeit muss frei sein (freie Zeiten der Leistung), nur Vorlauf und
* Horizont gelten nicht (ohneRegeln; nie die Vergangenheit, nie belegte Zellen). G02 (Einladung, Verwaltungslink, ohne
* Widerrufsbelehrung) geht nur mit `bestaetigungSenden: true` und einer Adresse an den Gast. Kein K20: der Betrieb weiss es.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {object} roh { leistung, ressource, tag, zeit, name, email?, telefon?, nachricht?, zusatz?, bestaetigungSenden? }
* @param {Date} now
* @returns {Promise<{ id: string, nummer: string, status: 'bestaetigt' }>}
* @throws Error 400 (Feldfehler) oder 409 code SLOT_VERGEBEN (Feld zeit)
*/
export async function manuell(ctx, roh, now) {
pruefeNow(now, 'manuell')
pruefeKontext(ctx, 'manuell')
const e = ctx.einstellung
const g = pruefeEingabe(e, roh, now, { manuell: true })
if (roh.bestaetigungSenden !== undefined && roh.bestaetigungSenden !== null && typeof roh.bestaetigungSenden !== 'boolean') {
throw fehlerMit(400, { bestaetigungSenden: MELDUNG.ungueltig })
}
const senden = roh.bestaetigungSenden === true && g.email !== ''
const { start, ressourceId } = await pruefeSlot(ctx, g, now, { ohneRegeln: true })
const block = blockEnde(start, g.leistung)
if ((block.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) throw fehlerMit(400, { leistung: MELDUNG_BETRIEB.zuLang })
const id = randomUUID()
const buchung = await legeBelegungAn(
ctx,
id,
{
featureId: ctx.feature.$id,
customerId: ctx.feature.customerId,
projectId: ctx.feature.projectId,
leistungId: g.leistung.id,
leistungName: g.leistung.name,
ressourceId,
ressourceName: e.ressourcen.find((r) => r.id === ressourceId)?.name ?? '',
startsAt: iso(start),
endsAt: iso(new Date(start.getTime() + g.leistung.dauer * MIN_MS)),
blockEndsAt: iso(block),
status: 'bestaetigt',
quelle: 'manuell',
gastName: g.name,
gastEmail: g.email,
gastTelefon: g.telefon,
gastNachricht: g.nachricht,
gastZusatz: Object.keys(g.zusatz).length > 0 ? JSON.stringify(g.zusatz) : '',
erinnerung: false,
// der Verwaltungslink der Bestaetigung (und spaeterer Mails) findet die Buchung nur ueber diesen Hash
manageHash: hashToken(verwaltungsToken(id)),
bestaetigtAt: iso(now),
preisCent: g.leistung.preisCent,
preisArt: g.leistung.preisArt,
buchungsart: g.leistung.buchungsart,
verbraucher: false,
zahlart: 'vorort',
zahlStatus: 'keine',
icsSequenz: 0,
createdAt: iso(now),
updatedAt: iso(now),
},
ressourceId,
start,
block,
now,
zeitVergeben,
)
if (senden) await mailAnGast('G02', ctx, buchung, {}, now)
return { id, nummer: buchungsNummer(id), status: 'bestaetigt' }
}
// ---------------------------------------------------------------- Sperren
/** Prueft die Eingabe einer Sperre; => { ressource, tag, start, ende, titel }, sonst 400 mit Feldfehlern. */
function pruefeSperre(e, roh, now) {
if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular })
const { fehler, fehl } = fehlerSammler()
const ressource = typeof roh.ressource === 'string' ? (e.ressourcen || []).find((r) => r.id === roh.ressource) : undefined
if (!ressource) fehl('ressource', MELDUNG.ressource)
const tag = typeof roh.tag === 'string' && zuUtc(roh.tag, '12:00') !== null ? roh.tag : null
if (tag === null) fehl('tag', MELDUNG.tag)
const vonOk = typeof roh.von === 'string' && VON_RE.test(roh.von)
const bisOk = typeof roh.bis === 'string' && BIS_RE.test(roh.bis)
if (!vonOk) fehl('von', MELDUNG.zeit)
else if (Number(roh.von.slice(3)) % 5 !== 0) fehl('von', MELDUNG_BETRIEB.schritt)
if (!bisOk) fehl('bis', MELDUNG.zeit)
else if (Number(roh.bis.slice(3)) % 5 !== 0) fehl('bis', MELDUNG_BETRIEB.schritt)
let start = null
let ende = null
if (tag !== null && !Object.hasOwn(fehler, 'von')) {
start = zuUtc(tag, roh.von)
if (start === null) fehl('von', MELDUNG.zeit) // Luecke der Maerz-Umstellung
}
if (tag !== null && !Object.hasOwn(fehler, 'bis')) {
ende = zuUtc(tag, roh.bis)
if (ende === null) fehl('bis', MELDUNG.zeit)
}
if (start && ende) {
if (ende.getTime() <= start.getTime()) fehl('bis', MELDUNG_BETRIEB.endeVorBeginn)
else if ((ende.getTime() - start.getTime()) / MIN_MS > BELEGUNG_MAX_MIN) fehl('bis', MELDUNG_BETRIEB.sperreLang)
else if (ende.getTime() <= now.getTime()) fehl('bis', MELDUNG_BETRIEB.sperreVorbei)
}
const titel = bereinigterText(roh.titel, TITEL_MAX + 1)
if ([...titel].length > TITEL_MAX) fehl('titel', MELDUNG_BETRIEB.titelLang)
if (Object.keys(fehler).length > 0) throw fehlerMit(400, fehler)
return { ressource, start, ende, titel: titel === '' ? SPERRE_TITEL : titel }
}
/**
* Zeit sperren: eine bestaetigte Buchung ohne Gast (`quelle 'sperre'`, `leistungName` = Titel, Vorgabe "Gesperrt") auf einer
* Ressource an einem Berliner Tag von `von` bis `bis` ('HH:MM', Vielfache von 5 Minuten, `bis` auch '24:00'), mit Zellen
* wie jede Buchung. Hoechstens 500 Minuten (100 Zellen in einem Bulk-Create), sonst 400 Feld `bis`; laengere Zeiten sind
* Sache der Abwesenheiten. Das Ende muss in der Zukunft liegen. Liegt schon ein Termin oder eine Sperre in der Zeit,
* 409 (code SLOT_VERGEBEN, Feld von): erst den Termin absagen oder verschieben.
* @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js)
* @param {{ ressource: string, tag: string, von: string, bis: string, titel?: string }} roh
* @param {Date} now
* @returns {Promise<{ id: string, status: 'bestaetigt' }>}
*/
export async function sperre(ctx, roh, now) {
pruefeNow(now, 'sperre')
pruefeKontext(ctx, 'sperre')
const s = pruefeSperre(ctx.einstellung, roh, now)
const id = randomUUID()
await legeBelegungAn(
ctx,
id,
{
featureId: ctx.feature.$id,
customerId: ctx.feature.customerId,
projectId: ctx.feature.projectId,
leistungId: '',
leistungName: s.titel,
ressourceId: s.ressource.id,
ressourceName: s.ressource.name,
startsAt: iso(s.start),
endsAt: iso(s.ende),
blockEndsAt: iso(s.ende),
status: 'bestaetigt',
quelle: 'sperre',
erinnerung: false,
bestaetigtAt: iso(now),
preisCent: 0,
preisArt: 'keiner',
verbraucher: false,
icsSequenz: 0,
createdAt: iso(now),
updatedAt: iso(now),
},
s.ressource.id,
s.start,
s.ende,
now,
() => fehlerMit(409, { von: MELDUNG_BETRIEB.sperreBelegt }, { code: 'SLOT_VERGEBEN' }),
)
return { id, status: 'bestaetigt' }
}
/**
* Sperre aufheben: loescht die Buchung und danach ihre Zellen (nur `quelle 'sperre'` der eigenen Funktion, sonst 404).
* Wie bei einer Absage gilt eine Zelle ohne Buchung als frei; scheitert das Freigeben, steht ein Eintrag im Log und der
* Tagesjob raeumt auf.
* @returns {Promise<{ status: 'entsperrt' }>}
*/
export async function entsperre(ctx, bookingId) {
pruefeKontext(ctx, 'entsperre')
if (typeof bookingId !== 'string' || !ID_RE.test(bookingId)) throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
return withLocks([`termine-buchung:${bookingId}`], async () => {
const frisch = await hole(bookingId)
if (!frisch || frisch.featureId !== ctx.feature.$id || frisch.quelle !== 'sperre') throw nichtGefunden(MELDUNG_BETRIEB.keineSperre)
await loesche(bookingId)
await gibFreiSicher(bookingId)
return { status: 'entsperrt' }
})
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { config } from '../../config.js'
import { Query, createDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { istBelegend } from './slots.js'
import { buchungsNummer } from './token.js'
import { pruefeNow } from './util.js'
@@ -68,6 +68,15 @@ export async function aendere(id, felder) {
return updateDocument(col(), id, { ...aenderung, updatedAt: aenderung.updatedAt ?? new Date().toISOString() })
}
/** Loescht eine Buchung (nur Sperren und gescheiterte Handeintraege; 404 = schon weg). */
export async function loesche(id) {
try {
await deleteDocument(col(), id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
/**
* Alle Buchungen (jeder Status) einer Funktion, die den Zeitraum [von, bis] beruehren: Beginn vor bis und Blockende
* nach von (strikt). Findet damit auch eine Belegung vom Vortag, die in den Tag hineinreicht.
+18 -7
View File
@@ -1,11 +1,14 @@
import { randomBytes } from 'node:crypto'
import { config } from '../../config.js'
import { withdrawalDeadline } from '../deadlines.js'
import { BUNDESLAENDER } from './feiertage.js'
import { heute, zuUtc } from './zeit.js'
/**
* Terminplaner-Einstellungen (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 2, termineConfig.einstellung).
* Reine Funktionen ohne Appwrite und ohne config: Pruefen und Normalisieren der Betriebs-Einstellung, Texte fuer
* Knoepfe und Preise, die oeffentliche Sicht fuer GET /config. `now` kommt immer als Parameter.
* Reine Funktionen ohne Appwrite (config nur fuer die Haltezeit in brauchtZustimmungVorzeitig): Pruefen und
* Normalisieren der Betriebs-Einstellung, Texte fuer Knoepfe und Preise, die oeffentliche Sicht fuer GET /config.
* `now` kommt immer als Parameter.
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
*/
@@ -19,7 +22,7 @@ const RESERVIERTE_SCHLUESSEL = ['__proto__', 'constructor', 'prototype']
const VORLAGE_RE = /^[a-z]{1,20}$/
const FEED_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const VORZEITIG_TAGE = 14
const MIN_MS = 60000
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const idRe = (praefix) => new RegExp(`^${praefix}_[a-z0-9]{8}$`)
@@ -576,12 +579,20 @@ export function istVerbraucher(einstellung) {
}
/**
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): verbindlich + Preis + Verbraucher und
* Terminbeginn vor now + 14 Tagen. Ein unbrauchbarer Beginn verlangt die Zustimmung lieber.
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB): Preis + Verbraucher und
* - sofort buchbar: Terminbeginn bis zum Ende der Widerrufsfrist (withdrawalDeadline, Berliner Kalender, §§ 187, 188, 193
* BGB). Der Vertrag kommt spaetestens nach der Haltezeit (config.termine.holdMinutes) zustande, gerechnet wird mit
* `now` plus Haltezeit.
* - Anfrage: immer. Den Tag der Annahme kennt niemand im Voraus, die Frist beginnt erst dann; die Zustimmung zu viel zu
* verlangen schadet nicht.
* Ein unbrauchbarer Beginn verlangt die Zustimmung lieber.
*/
export function brauchtZustimmungVorzeitig(einstellung, leistung, start, now) {
if (leistung?.buchungsart !== 'sofort' || !hatPreis(leistung) || !istVerbraucher(einstellung)) return false
return !(start.getTime() >= now.getTime() + VORZEITIG_TAGE * 86400000)
if (!hatPreis(leistung) || !istVerbraucher(einstellung)) return false
if (leistung.buchungsart === 'anfrage') return true
if (leistung.buchungsart !== 'sofort') return false
const fristEnde = withdrawalDeadline(new Date(now.getTime() + config.termine.holdMinutes * MIN_MS)).endsAt.getTime()
return !(start.getTime() > fristEnde)
}
export function preisHinweis(einstellung) {
+3 -2
View File
@@ -150,8 +150,9 @@ export function formularHinweis(einstellung) {
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
/**
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, § 357a Abs. 2 BGB): nur bei verbindlicher Leistung
* mit Preis, Verbrauchern und Terminbeginn vor Ablauf der Widerrufsfrist. Sie-Form auch bei Du-Betrieben.
* Pflicht-Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, § 357a Abs. 2 BGB): nur bei Leistung mit Preis und
* Verbrauchern, bei sofort buchbaren Leistungen mit Terminbeginn vor Ablauf der Widerrufsfrist, bei Anfragen immer
* (brauchtZustimmungVorzeitig). Sie-Form auch bei Du-Betrieben.
*/
export const ZUSTIMMUNG_VORZEITIG = (name) =>
`Ich verlange ausdrücklich, dass ${name} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. `