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:
1 parent
c4ec24448e
commit
be49d38e0c
8 files changed
+1563
-79
No files matched your search
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. `
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Betrieb im Portal: Aktionen auf Buchungen (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren),
|
||||
// Termine von Hand und Sperren. Aufbau wie test/termineAblaufVerwalten.test.js: Nachbau von Appwrite mit Schema
|
||||
// (Standard-Limit 25), Mails in der Senke, feste Zeiten per Parameter.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
termineBookings: schemaKeys('termineBookings'),
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
|
||||
const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
||||
const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js')
|
||||
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||||
const { halte, vorschau, bestaetige, kannWiderrufen, verschiebeGast } = await import('../server/services/termine/ablauf.js')
|
||||
const { aktion, manuell, sperre, entsperre } = await import('../server/services/termine/ablaufBetrieb.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const MIN = 60000
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
|
||||
const TAG_NAH = '2026-10-12' // Montag, 7 Tage spaeter: Beginn innerhalb der Widerrufsfrist
|
||||
const TAG_FERN = '2026-10-26' // Montag, 21 Tage spaeter (MEZ)
|
||||
const KUNDE = 'c_kunde01'
|
||||
const PROJEKT = 'p_projekt01'
|
||||
const FEATURE = 'f_p_projekt01_termine'
|
||||
const R1 = 'r_aaaaaaa1'
|
||||
const R2 = 'r_aaaaaaa2'
|
||||
const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort
|
||||
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
|
||||
const ABSENDER_VORHER = config.smtp.from
|
||||
const MODUS_VORHER = config.termine.mode
|
||||
const BASIS_VORHER = config.publicBaseUrl
|
||||
|
||||
const iso = (date) => new Date(date).toISOString()
|
||||
const plus = (date, ms) => new Date(new Date(date).getTime() + ms)
|
||||
|
||||
const feature = (id = FEATURE) => ({ $id: id, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
|
||||
|
||||
const eingabeEinstellung = (over = {}) => ({
|
||||
betrieb: {
|
||||
name: 'Salon Müller',
|
||||
email: 'info@salon.de',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
websiteUrl: 'https://salon.de',
|
||||
impressumUrl: 'https://salon.de/impressum',
|
||||
datenschutzUrl: 'https://salon.de/datenschutz',
|
||||
},
|
||||
anrede: 'sie',
|
||||
bundesland: 'RP',
|
||||
ressourcen: [
|
||||
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } },
|
||||
{ id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } },
|
||||
],
|
||||
leistungen: [
|
||||
{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] },
|
||||
{ id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] },
|
||||
],
|
||||
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
|
||||
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [{ key: 'marke', label: 'Marke', typ: 'text', pflicht: false }] },
|
||||
erinnerung: { anbieten: true, stundenVorher: 24 },
|
||||
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
|
||||
recht: { gaesteSind: 'verbraucher', freizeit: false },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
|
||||
...over,
|
||||
})
|
||||
const einstellung = (over) => {
|
||||
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
|
||||
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
|
||||
return erg.einstellung
|
||||
}
|
||||
const ctxMit = (e = einstellung(), id = FEATURE) => ({ feature: feature(id), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 })
|
||||
|
||||
/** Gastformular wie in den Gast-Tests: Montag in 3 Wochen, 10:00, Verbraucher, Stornohaken gesetzt. */
|
||||
const eingabe = (over = {}) => ({
|
||||
leistung: S_SOFORT,
|
||||
ressource: R1,
|
||||
tag: TAG_FERN,
|
||||
zeit: '10:00',
|
||||
name: 'Max Mustermann',
|
||||
email: 'max@example.com',
|
||||
telefon: '0171 1234567',
|
||||
nachricht: '',
|
||||
zusatz: {},
|
||||
erinnerung: false,
|
||||
zustimmungen: { storno: true, vorzeitig: false },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Eingabe fuer einen Termin von Hand (keine Zustimmungen). */
|
||||
const handEingabe = (over = {}) => ({
|
||||
leistung: S_SOFORT,
|
||||
ressource: R1,
|
||||
tag: TAG_FERN,
|
||||
zeit: '10:00',
|
||||
name: 'Paula Telefon',
|
||||
email: '',
|
||||
telefon: '06301 555',
|
||||
nachricht: '',
|
||||
zusatz: {},
|
||||
bestaetigungSenden: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
let sink
|
||||
let log
|
||||
/** Die Einstellung wird gespeichert, damit die Token-Funktionen (kontextPerFeature) sie finden. */
|
||||
async function speichere(e = einstellung()) {
|
||||
resetKontextForTests()
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT })
|
||||
return ctxMit(e)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
||||
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
||||
}
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
resetTermineLimitsForTests()
|
||||
config.termine.mode = 'pilot'
|
||||
config.smtp.from = 'noreply@webklar.com'
|
||||
config.publicBaseUrl = 'https://project.webklar.com'
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
|
||||
aw.seed('portalFeatures', feature())
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await warteAufVersand()
|
||||
mock.restoreAll()
|
||||
config.termine.mode = MODUS_VORHER
|
||||
config.smtp.from = ABSENDER_VORHER
|
||||
config.publicBaseUrl = BASIS_VORHER
|
||||
})
|
||||
|
||||
const buchungen = () => [...aw.docs('termineBookings').values()]
|
||||
const buchung = (id) => aw.docs('termineBookings').get(id)
|
||||
const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId)
|
||||
const eintraege = () => [...aw.docs('termineOutbox').values()]
|
||||
const arten = () => eintraege().map((d) => d.art).sort()
|
||||
const entfalte = (ics) => ics.replace(/\r\n /g, '')
|
||||
const claimsStand = () => JSON.stringify([...aw.docs('termineClaims').entries()])
|
||||
const an = (liste, adresse) => liste.filter((m) => m.to.some((t) => t.address === adresse))
|
||||
const zeitenIso = (zellen) => zellen.map((c) => iso(c.zelle)).sort()
|
||||
const linkIn = (mail) => /termin\.html#m=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1]
|
||||
|
||||
/** Fehler eines Aufrufs (status, fehler, code); schlaegt fehl, wenn nichts geworfen wird. */
|
||||
async function fehlerVon(fn) {
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
return err
|
||||
}
|
||||
assert.fail('es wurde kein Fehler geworfen')
|
||||
}
|
||||
|
||||
/** Haelt und bestaetigt einen Termin; => { id, buchung (frisch gelesen), verwaltung (Verwaltungstoken), token, mailStart }. */
|
||||
async function gebucht(ctx, over = {}, now = JETZT) {
|
||||
await warteAufVersand() // Mails frueherer Aufrufe sind raus, bevor der Index der neuen gilt
|
||||
const vorher = sink.sent.length
|
||||
const erg = await halte(ctx, eingabe(over), now)
|
||||
await warteAufVersand()
|
||||
assert.equal(erg.status, 'gehalten')
|
||||
const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(sink.sent[vorher].text)[1]
|
||||
const gehalten = buchungen().find((b) => b.verifyHash === hashToken(token))
|
||||
assert.ok(gehalten, 'Buchung zum Token')
|
||||
const mailStart = sink.sent.length
|
||||
await bestaetige(token, now)
|
||||
await warteAufVersand()
|
||||
return { id: gehalten.$id, buchung: buchung(gehalten.$id), verwaltung: verwaltungsToken(gehalten.$id), token, mailStart }
|
||||
}
|
||||
|
||||
/** Fehler im Postausgang simulieren: jede neue Mail scheitert beim Einreihen (Appwrite 500). */
|
||||
async function mitDefektemPostausgang(fn) {
|
||||
const echt = globalThis.fetch
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (/\/collections\/termineOutbox\/documents/.test(String(url)) && optionen?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ message: 'Server error', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
return echt(url, optionen)
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
globalThis.fetch = echt
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- annehmen
|
||||
|
||||
test('annehmen: angefragt => bestaetigt mit angenommenAt, G02 (Block anfrage_angenommen) mit Einladung und demselben Verwaltungslink wie G03', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
assert.equal(buchung(id).status, 'angefragt')
|
||||
const g03 = an(sink.sent, 'max@example.com').find((m) => /Terminanfrage/.test(m.subject))
|
||||
assert.equal(linkIn(g03), verwaltung)
|
||||
const standZellen = claimsStand()
|
||||
const vorher = sink.sent.length
|
||||
|
||||
const jetzt = plus(JETZT, 60 * MIN)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, jetzt), { status: 'bestaetigt' })
|
||||
await warteAufVersand()
|
||||
|
||||
const nachher = buchung(id)
|
||||
assert.equal(nachher.status, 'bestaetigt')
|
||||
assert.equal(iso(nachher.angenommenAt), iso(jetzt))
|
||||
assert.equal(nachher.bestaetigtAt, undefined, 'der Vertrag kommt bei einer Anfrage erst mit der Annahme zustande')
|
||||
assert.equal(iso(nachher.updatedAt), iso(jetzt))
|
||||
assert.equal(claimsStand(), standZellen, 'die Zellen bleiben')
|
||||
assert.equal(kannWiderrufen(nachher, plus(jetzt, 2 * 24 * 60 * MIN)), true, 'die Widerrufsfrist laeuft ab der Annahme')
|
||||
|
||||
const neu = sink.sent.slice(vorher)
|
||||
assert.equal(neu.length, 1, 'nur G02, kein K20/K21')
|
||||
const g02 = neu[0]
|
||||
assert.equal(g02.to[0].address, 'max@example.com')
|
||||
assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026')
|
||||
assert.match(g02.text, /Salon Müller hat Ihre Terminanfrage angenommen/)
|
||||
assert.equal(linkIn(g02), verwaltung, 'derselbe Verwaltungslink wie in G03')
|
||||
assert.equal(g02.icalEvent.method, 'REQUEST')
|
||||
const ics = entfalte(g02.icalEvent.content)
|
||||
assert.match(ics, /SEQUENCE:0\r\n/)
|
||||
assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`))
|
||||
assert.deepEqual(arten(), ['G01', 'G02', 'G03', 'K20'])
|
||||
|
||||
// der Link aus der Mail funktioniert: Verwaltung zeigt den bestaetigten Termin
|
||||
assert.equal((await vorschau(verwaltung, jetzt)).status, 'bestaetigt')
|
||||
})
|
||||
|
||||
test('annehmen: zweiter Klick meldet schon; nur Anfragen; eine Anfrage mit vergangenem Beginn nicht; nichts wird gesendet', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
await aktion(ctx, id, { aktion: 'annehmen' }, JETZT)
|
||||
await warteAufVersand()
|
||||
const mails = sink.sent.length
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, plus(JETZT, MIN)), { status: 'bestaetigt', schon: true })
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, mails)
|
||||
|
||||
// eine sofort gebuchte Leistung ist keine Anfrage
|
||||
const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
|
||||
const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'annehmen' }, JETZT))
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'nicht_moeglich')
|
||||
assert.deepEqual(err.fehler, { _: 'Diese Anfrage ist nicht mehr offen.' })
|
||||
|
||||
// Beginn schon vorbei (der Minutenjob hat die Anfrage noch nicht abgelehnt)
|
||||
const alt = await gebucht(ctx, { leistung: S_ANFRAGE, email: 'drei@example.com', zeit: '14:00' })
|
||||
const spaet = plus(new Date(alt.buchung.startsAt), MIN)
|
||||
const vorbei = await fehlerVon(() => aktion(ctx, alt.id, { aktion: 'annehmen' }, spaet))
|
||||
assert.equal(vorbei.status, 409)
|
||||
assert.equal(vorbei.code, 'vergangen')
|
||||
assert.equal(buchung(alt.id).status, 'angefragt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- ablehnen
|
||||
|
||||
test('ablehnen: abgelehnt, Zellen frei, G04 mit der Nachricht des Betriebs; die Zeit ist wieder buchbar', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
assert.equal(claims(id).length, 12)
|
||||
const vorher = sink.sent.length
|
||||
|
||||
const jetzt = plus(JETZT, 30 * MIN)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, jetzt), { status: 'abgelehnt' })
|
||||
await warteAufVersand()
|
||||
|
||||
assert.equal(buchung(id).status, 'abgelehnt')
|
||||
assert.equal(iso(buchung(id).updatedAt), iso(jetzt))
|
||||
assert.equal(claims(id).length, 0)
|
||||
const neu = sink.sent.slice(vorher)
|
||||
assert.equal(neu.length, 1)
|
||||
assert.equal(neu[0].to[0].address, 'max@example.com')
|
||||
assert.equal(neu[0].subject, 'Ihre Terminanfrage bei Salon Müller')
|
||||
assert.match(neu[0].text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/)
|
||||
assert.match(neu[0].text, /Am Montag sind wir im Urlaub\./)
|
||||
assert.equal('icalEvent' in neu[0], false)
|
||||
|
||||
assert.equal((await halte(ctx, eingabe({ leistung: S_ANFRAGE, email: 'zwei@example.com' }), jetzt)).status, 'gehalten')
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen' }, plus(jetzt, MIN)), { status: 'abgelehnt', schon: true })
|
||||
})
|
||||
|
||||
test('ablehnen: ohne Nachricht kein Nachrichtenblock; bestaetigte Termine lassen sich nicht ablehnen', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
const vorher = sink.sent.length
|
||||
await aktion(ctx, id, { aktion: 'ablehnen' }, JETZT)
|
||||
await warteAufVersand()
|
||||
assert.doesNotMatch(sink.sent[vorher].text, /Nachricht von/i)
|
||||
|
||||
const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
|
||||
const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'ablehnen' }, JETZT))
|
||||
assert.equal(err.code, 'nicht_moeglich')
|
||||
assert.equal(buchung(sofort.id).status, 'bestaetigt')
|
||||
assert.equal(claims(sofort.id).length, 10)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- absagen
|
||||
|
||||
test('absagen: abgesagt von betrieb mit Grund, Zellen frei, G06 mit Betriebsname, Grund, Nachricht und Kalenderabsage; kein K21; keine Frist', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, buchung: b, mailStart } = await gebucht(ctx)
|
||||
assert.equal(claims(id).length, 10)
|
||||
|
||||
// eine Stunde vor dem Termin: der Gast koennte nicht mehr absagen, der Betrieb schon
|
||||
const jetzt = plus(new Date(b.startsAt), -60 * MIN)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, jetzt), { status: 'abgesagt' })
|
||||
await warteAufVersand()
|
||||
|
||||
const nachher = buchung(id)
|
||||
assert.equal(nachher.status, 'abgesagt')
|
||||
assert.equal(nachher.abgesagtVon, 'betrieb')
|
||||
assert.equal(nachher.absageGrund, 'Krankheit')
|
||||
assert.equal(iso(nachher.abgesagtAt), iso(jetzt))
|
||||
assert.equal(nachher.widerrufenAt, undefined)
|
||||
assert.equal(claims(id).length, 0)
|
||||
|
||||
const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen
|
||||
assert.equal(neu.length, 1, 'nur G06 an den Gast, kein K21')
|
||||
const g06 = neu[0]
|
||||
assert.equal(g06.to[0].address, 'max@example.com')
|
||||
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
|
||||
assert.match(g06.text, /Salon Müller/)
|
||||
assert.doesNotMatch(g06.text, /Sie selbst/)
|
||||
assert.match(g06.text, /Krankheit/)
|
||||
assert.match(g06.text, /Gute Besserung wuenschen wir\./)
|
||||
assert.equal(g06.icalEvent.method, 'CANCEL')
|
||||
assert.match(entfalte(g06.icalEvent.content), /SEQUENCE:1\r\n/)
|
||||
assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20'])
|
||||
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, MIN)), { status: 'abgesagt', schon: true })
|
||||
})
|
||||
|
||||
test('absagen: Grund ist Pflicht (400 Feld grund), zu lange Texte werden abgelehnt, nichts aendert sich', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx)
|
||||
const standZellen = claimsStand()
|
||||
const vorher = sink.sent.length
|
||||
for (const [feld, roh] of [
|
||||
['grund', { aktion: 'absagen' }],
|
||||
['grund', { aktion: 'absagen', grund: ' ' }],
|
||||
['grund', { aktion: 'absagen', grund: 7 }],
|
||||
['grund', { aktion: 'absagen', grund: 'x'.repeat(101) }],
|
||||
['nachricht', { aktion: 'absagen', grund: 'Krankheit', nachricht: 'y'.repeat(501) }],
|
||||
]) {
|
||||
const err = await fehlerVon(() => aktion(ctx, id, roh, JETZT))
|
||||
assert.equal(err.status, 400, JSON.stringify(roh).slice(0, 60))
|
||||
assert.deepEqual(Object.keys(err.fehler), [feld])
|
||||
}
|
||||
assert.equal(buchung(id).status, 'bestaetigt')
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, vorher)
|
||||
})
|
||||
|
||||
test('absagen: eine Anfrage (nie eine Einladung erhalten) bekommt G06 ohne Kalenderdatei (R21); erledigte Termine lassen sich nicht absagen', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
const vorher = sink.sent.length
|
||||
await aktion(ctx, id, { aktion: 'absagen', grund: 'Betriebsstoerung' }, JETZT)
|
||||
await warteAufVersand()
|
||||
const g06 = sink.sent.slice(vorher)[0]
|
||||
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
|
||||
assert.equal('icalEvent' in g06, false)
|
||||
assert.equal(buchung(id).status, 'abgesagt')
|
||||
assert.equal(claims(id).length, 0)
|
||||
|
||||
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
|
||||
aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' })
|
||||
const err = await fehlerVon(() => aktion(ctx, zwei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'nicht_moeglich')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- verschieben
|
||||
|
||||
test('verschieben: wie beim Gast (10:00 -> 10:30 ueberlappend), Sequenz + 1, neue Zellen, G07 mit Einladung, kein K21', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, buchung: alt, mailStart } = await gebucht(ctx)
|
||||
const altZellen = zeitenIso(claims(id))
|
||||
|
||||
const jetzt = plus(JETZT, 60 * MIN)
|
||||
const erg = await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:30' }, jetzt)
|
||||
await warteAufVersand()
|
||||
assert.equal(erg.status, 'bestaetigt')
|
||||
assert.match(erg.termin.datum, /26\.10\.2026/)
|
||||
assert.equal(erg.termin.zeit, '10:30')
|
||||
|
||||
const nachher = buchung(id)
|
||||
assert.equal(iso(nachher.startsAt), '2026-10-26T09:30:00.000Z')
|
||||
assert.equal(iso(nachher.endsAt), '2026-10-26T10:15:00.000Z')
|
||||
assert.equal(iso(nachher.blockEndsAt), '2026-10-26T10:20:00.000Z')
|
||||
assert.equal(nachher.icsSequenz, alt.icsSequenz + 1)
|
||||
assert.equal(nachher.status, 'bestaetigt')
|
||||
const neuZellen = zeitenIso(zellenFuer(nachher.startsAt, nachher.blockEndsAt).map((z) => ({ zelle: z })))
|
||||
assert.deepEqual(zeitenIso(claims(id)), neuZellen)
|
||||
assert.notDeepEqual(zeitenIso(claims(id)), altZellen)
|
||||
|
||||
const neu = sink.sent.slice(mailStart + 2)
|
||||
assert.equal(neu.length, 1)
|
||||
assert.equal(neu[0].subject, 'Ihr Termin wurde verschoben')
|
||||
assert.equal(neu[0].icalEvent.method, 'REQUEST')
|
||||
assert.match(entfalte(neu[0].icalEvent.content), /SEQUENCE:1\r\n/)
|
||||
assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20'])
|
||||
})
|
||||
|
||||
test('verschieben: der Betrieb darf in den Vorlauf, ueber den Horizont und in die Widerrufsfrist, der Gast nicht', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, verwaltung } = await gebucht(ctx) // TAG_FERN ohne Zustimmung zum vorzeitigen Beginn
|
||||
const zurueck = () => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:00' }, JETZT)
|
||||
|
||||
// Vorlauf (120 Minuten): heute 11:00
|
||||
const vorlauf = await fehlerVon(() => verschiebeGast(verwaltung, { tag: '2026-10-05', zeit: '11:00' }, JETZT))
|
||||
assert.equal(vorlauf.code, 'SLOT_VERGEBEN')
|
||||
await aktion(ctx, id, { aktion: 'verschieben', tag: '2026-10-05', zeit: '11:00' }, JETZT)
|
||||
assert.equal(iso(buchung(id).startsAt), '2026-10-05T09:00:00.000Z')
|
||||
await zurueck()
|
||||
|
||||
// Horizont (90 Tage)
|
||||
const weit = '2027-02-01'
|
||||
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: weit, zeit: '10:00' }, JETZT))).code, 'SLOT_VERGEBEN')
|
||||
await aktion(ctx, id, { aktion: 'verschieben', tag: weit, zeit: '10:00' }, JETZT)
|
||||
assert.equal(iso(buchung(id).startsAt), '2027-02-01T09:00:00.000Z')
|
||||
await zurueck()
|
||||
|
||||
// Termin innerhalb der Widerrufsfrist ohne Zustimmung zum vorzeitigen Beginn
|
||||
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_NAH, zeit: '10:00' }, JETZT))).code, 'zustimmung_noetig')
|
||||
await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_NAH, zeit: '10:00' }, JETZT)
|
||||
assert.equal(iso(buchung(id).startsAt), '2026-10-12T08:00:00.000Z')
|
||||
assert.equal(claims(id).length, 10)
|
||||
})
|
||||
|
||||
test('verschieben: nie in die Vergangenheit, nie auf belegte Zellen, nie auf dieselbe Zeit; alles bleibt wie es war', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx)
|
||||
await gebucht(ctx, { email: 'zwei@example.com', zeit: '14:00' }) // R1 14:00-14:50
|
||||
const standZellen = claimsStand()
|
||||
const stand = JSON.stringify(buchung(id))
|
||||
const vorher = sink.sent.length
|
||||
|
||||
for (const [roh, status, feld] of [
|
||||
[{ tag: '2026-10-05', zeit: '09:00' }, 409, 'zeit'], // heute, vor jetzt (10:00)
|
||||
[{ tag: '2026-10-04', zeit: '10:00' }, 409, 'zeit'], // gestern
|
||||
[{ tag: TAG_FERN, zeit: '14:15' }, 409, 'zeit'], // ueberlappt den Termin um 14:00
|
||||
[{ tag: TAG_FERN, zeit: '08:00' }, 409, 'zeit'], // vor der Oeffnung
|
||||
[{ tag: '2026-10-27', zeit: '10:00' }, 409, 'zeit'], // Dienstag, kein Plan
|
||||
[{ tag: TAG_FERN, zeit: '10:00' }, 400, 'zeit'], // dieselbe Zeit
|
||||
[{ tag: 'kaputt', zeit: '10:00' }, 400, 'tag'],
|
||||
[{ tag: TAG_FERN, zeit: '25:00' }, 400, 'zeit'],
|
||||
]) {
|
||||
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', ...roh }, JETZT))
|
||||
assert.equal(err.status, status, JSON.stringify(roh))
|
||||
assert.deepEqual(Object.keys(err.fehler), [feld], JSON.stringify(roh))
|
||||
}
|
||||
assert.equal(JSON.stringify(buchung(id)), stand)
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, vorher)
|
||||
})
|
||||
|
||||
test('verschieben: eine Anfrage bekommt G07 ohne Kalenderdatei (R21), ein abgesagter Termin laesst sich nicht verschieben', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
const vorher = sink.sent.length
|
||||
await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '11:00' }, JETZT)
|
||||
await warteAufVersand()
|
||||
const g07 = sink.sent.slice(vorher)[0]
|
||||
assert.equal(g07.subject, 'Ihr Termin wurde verschoben')
|
||||
assert.equal('icalEvent' in g07, false)
|
||||
assert.equal(buchung(id).status, 'angefragt')
|
||||
|
||||
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
|
||||
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '12:00' }, JETZT))
|
||||
assert.equal(err.code, 'nicht_moeglich')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- nichterschienen, anonymisieren
|
||||
|
||||
test('nichterschienen: vor Beginn 409, danach nichterschienen (Zellen frei); zweiter Klick meldet schon', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, buchung: b } = await gebucht(ctx)
|
||||
const standZellen = claimsStand()
|
||||
|
||||
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, JETZT))
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'vor_beginn')
|
||||
assert.deepEqual(err.fehler, { _: 'Das geht erst, wenn der Termin begonnen hat.' })
|
||||
assert.equal(buchung(id).status, 'bestaetigt')
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
assert.equal((await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, plus(new Date(b.startsAt), -1)))).code, 'vor_beginn')
|
||||
|
||||
const beginn = new Date(b.startsAt)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, beginn), { status: 'nichterschienen' })
|
||||
assert.equal(buchung(id).status, 'nichterschienen')
|
||||
assert.equal(claims(id).length, 0)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, plus(beginn, MIN)), { status: 'nichterschienen', schon: true })
|
||||
|
||||
// auch nach dem automatischen Erledigen noch moeglich; abgesagte Termine nicht
|
||||
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
|
||||
aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' })
|
||||
assert.deepEqual(await aktion(ctx, zwei.id, { aktion: 'nichterschienen' }, plus(new Date(zwei.buchung.endsAt), 90 * MIN)), { status: 'nichterschienen' })
|
||||
const drei = await gebucht(ctx, { email: 'drei@example.com', zeit: '14:00' })
|
||||
await aktion(ctx, drei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
|
||||
assert.equal((await fehlerVon(() => aktion(ctx, drei.id, { aktion: 'nichterschienen' }, plus(new Date(drei.buchung.startsAt), MIN)))).code, 'nicht_moeglich')
|
||||
})
|
||||
|
||||
test('anonymisieren: Gastfelder leer ("Gelöscht"), Status und Zeiten bleiben, Links ungueltig, keine Erinnerung, keine Mail', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true, nachricht: 'Bitte ohne Foehn.', zusatz: { marke: 'Wella' } })
|
||||
assert.equal(b.erinnerung, true)
|
||||
const vorher = sink.sent.length
|
||||
|
||||
const jetzt = plus(JETZT, 5 * 24 * 60 * MIN)
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, jetzt), { status: 'bestaetigt' })
|
||||
await warteAufVersand()
|
||||
const nachher = buchung(id)
|
||||
assert.equal(nachher.gastName, 'Gelöscht')
|
||||
for (const feld of ['gastEmail', 'gastTelefon', 'gastNachricht', 'gastZusatz', 'verifyHash', 'manageHash']) assert.equal(nachher[feld], '', feld)
|
||||
assert.equal(nachher.erinnerung, false)
|
||||
assert.equal(nachher.status, 'bestaetigt')
|
||||
assert.equal(iso(nachher.startsAt), iso(b.startsAt))
|
||||
assert.equal(iso(nachher.endsAt), iso(b.endsAt))
|
||||
assert.equal(claims(id).length, 10)
|
||||
assert.equal(sink.sent.length, vorher)
|
||||
assert.equal((await fehlerVon(() => vorschau(verwaltung, jetzt))).status, 404, 'der alte Link gilt nicht mehr')
|
||||
|
||||
// zweiter Aufruf aendert nichts; ohne Adresse gehen weitere Aktionen ohne Mail durch
|
||||
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(jetzt, MIN)), { status: 'bestaetigt' })
|
||||
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, 2 * MIN))
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, vorher, 'ohne Adresse keine G06')
|
||||
assert.equal(log.error.mock.callCount(), 0)
|
||||
})
|
||||
|
||||
test('anonymisieren: bei abgesagten Terminen bleibt updatedAt (die Loeschfrist laeuft weiter); eine Haltung bleibt unberuehrt', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx)
|
||||
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
|
||||
const abgesagtUm = iso(buchung(id).updatedAt)
|
||||
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 10 * 24 * 60 * MIN))
|
||||
assert.equal(buchung(id).status, 'abgesagt')
|
||||
assert.equal(buchung(id).gastName, 'Gelöscht')
|
||||
assert.equal(iso(buchung(id).updatedAt), abgesagtUm)
|
||||
|
||||
await halte(ctx, eingabe({ email: 'zwei@example.com', zeit: '12:00' }), JETZT)
|
||||
const gehalten = buchungen().find((b) => b.status === 'gehalten')
|
||||
const err = await fehlerVon(() => aktion(ctx, gehalten.$id, { aktion: 'anonymisieren' }, JETZT))
|
||||
assert.equal(err.code, 'nicht_moeglich')
|
||||
assert.equal(buchung(gehalten.$id).gastEmail, 'zwei@example.com')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- aktion allgemein
|
||||
|
||||
test('aktion: fremde Funktion, Sperren, unbekannte Nummern und kaputte Eingaben aendern nichts', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx)
|
||||
const standZellen = claimsStand()
|
||||
const stand = JSON.stringify(buchung(id))
|
||||
|
||||
const fremd = ctxMit(einstellung(), 'f_andere_funktion')
|
||||
const err = await fehlerVon(() => aktion(fremd, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))
|
||||
assert.equal(err.status, 404)
|
||||
assert.equal(err.code, 'unbekannt')
|
||||
assert.deepEqual(err.fehler, { _: 'Diesen Termin gibt es nicht.' })
|
||||
for (const falsch of ['gibt-es-nicht', '', null, undefined, 42, '../x', 'a'.repeat(40)]) {
|
||||
assert.equal((await fehlerVon(() => aktion(ctx, falsch, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, String(falsch))
|
||||
}
|
||||
|
||||
for (const roh of [undefined, null, 'absagen', [], {}, { aktion: 'loeschen' }, { aktion: 7 }]) {
|
||||
const e = await fehlerVon(() => aktion(ctx, id, roh, JETZT))
|
||||
assert.equal(e.status, 400, JSON.stringify(roh))
|
||||
}
|
||||
assert.equal(JSON.stringify(buchung(id)), stand)
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
|
||||
const s = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '10:00', titel: 'Pause' }, JETZT)
|
||||
assert.equal((await fehlerVon(() => aktion(ctx, s.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, 'eine Sperre ist keine Buchung')
|
||||
assert.equal(buchung(s.id).status, 'bestaetigt')
|
||||
|
||||
await assert.rejects(() => aktion(ctx, id, { aktion: 'anonymisieren' }, undefined), TypeError)
|
||||
await assert.rejects(() => aktion({ feature: ctx.feature }, id, { aktion: 'anonymisieren' }, JETZT), TypeError)
|
||||
})
|
||||
|
||||
test('aktion: scheitert die Mail beim Einreihen, bleibt die Aenderung bestehen und der Fehler steht im Log', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
const erg = await mitDefektemPostausgang(() => aktion(ctx, id, { aktion: 'annehmen' }, JETZT))
|
||||
assert.deepEqual(erg, { status: 'bestaetigt' })
|
||||
assert.equal(buchung(id).status, 'bestaetigt')
|
||||
assert.ok(log.error.mock.calls.some((c) => /G02 nicht eingereiht/.test(String(c.arguments[0]))))
|
||||
assert.ok(!log.error.mock.calls.some((c) => /max@example\.com/.test(String(c.arguments[0]))), 'keine Adresse im Log')
|
||||
})
|
||||
|
||||
test('aktion: parallele Aktionen auf dieselbe Buchung ergeben genau einen Endzustand', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
|
||||
const erg = await Promise.allSettled([
|
||||
aktion(ctx, id, { aktion: 'annehmen' }, JETZT),
|
||||
aktion(ctx, id, { aktion: 'ablehnen' }, JETZT),
|
||||
aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT),
|
||||
])
|
||||
await warteAufVersand()
|
||||
const gut = erg.filter((e) => e.status === 'fulfilled' && !e.value.schon)
|
||||
assert.equal(gut.length >= 1, true)
|
||||
const endzustand = buchung(id).status
|
||||
assert.ok(['bestaetigt', 'abgelehnt', 'abgesagt'].includes(endzustand))
|
||||
// genau die Mail zum Endzustand plus hoechstens die der Annahme, wenn danach abgesagt wurde
|
||||
const gast = arten().filter((a) => ['G02', 'G04', 'G06'].includes(a))
|
||||
assert.ok(gast.length >= 1 && gast.length <= 2, gast.join(','))
|
||||
if (endzustand === 'abgelehnt') assert.deepEqual(gast, ['G04'])
|
||||
if (endzustand !== 'bestaetigt') assert.equal(claims(id).length, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- manuell
|
||||
|
||||
test('manuell: quelle manuell, bestaetigt, kein Verbraucher, Zellen, ohne Haken keine Mail', async () => {
|
||||
const ctx = await speichere()
|
||||
const erg = await manuell(ctx, handEingabe({ email: 'paula@example.com', zusatz: { marke: 'Schwarzkopf' } }), JETZT)
|
||||
await warteAufVersand()
|
||||
assert.equal(erg.status, 'bestaetigt')
|
||||
assert.equal(erg.nummer, buchungsNummer(erg.id))
|
||||
|
||||
const b = buchung(erg.id)
|
||||
assert.equal(b.quelle, 'manuell')
|
||||
assert.equal(b.status, 'bestaetigt')
|
||||
assert.equal(b.verbraucher, false)
|
||||
assert.equal(b.featureId, FEATURE)
|
||||
assert.equal(b.customerId, KUNDE)
|
||||
assert.equal(b.projectId, PROJEKT)
|
||||
assert.equal(b.leistungId, S_SOFORT)
|
||||
assert.equal(b.leistungName, 'Damenschnitt')
|
||||
assert.equal(b.ressourceId, R1)
|
||||
assert.equal(b.ressourceName, 'Anna')
|
||||
assert.equal(iso(b.startsAt), '2026-10-26T09:00:00.000Z')
|
||||
assert.equal(iso(b.endsAt), '2026-10-26T09:45:00.000Z')
|
||||
assert.equal(iso(b.blockEndsAt), '2026-10-26T09:50:00.000Z')
|
||||
assert.equal(b.gastName, 'Paula Telefon')
|
||||
assert.equal(b.gastEmail, 'paula@example.com')
|
||||
assert.equal(b.gastTelefon, '06301 555')
|
||||
assert.deepEqual(JSON.parse(b.gastZusatz), { marke: 'Schwarzkopf' })
|
||||
assert.equal(b.erinnerung, false)
|
||||
assert.equal(iso(b.bestaetigtAt), iso(JETZT))
|
||||
assert.equal(b.manageHash, hashToken(verwaltungsToken(erg.id)))
|
||||
assert.equal(b.icsSequenz, 0)
|
||||
assert.equal(b.preisCent, 4500)
|
||||
assert.equal(claims(erg.id).length, 10)
|
||||
assert.equal(kannWiderrufen(b, JETZT), false, 'ein Handeintrag hat kein Widerrufsrecht')
|
||||
assert.equal(sink.sent.length, 0, 'ohne Haken keine Gastmail (und kein K20 an den Betrieb)')
|
||||
assert.equal(eintraege().length, 0)
|
||||
|
||||
// die Zeit ist belegt
|
||||
assert.equal((await fehlerVon(() => halte(ctx, eingabe({ email: 'zwei@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT))).code, 'SLOT_VERGEBEN')
|
||||
})
|
||||
|
||||
test('manuell: mit Haken und Adresse geht G02 mit Einladung und funktionierendem Verwaltungslink, ohne Widerrufsbelehrung', async () => {
|
||||
const ctx = await speichere()
|
||||
const erg = await manuell(ctx, handEingabe({ email: ' Paula@Example.com ', bestaetigungSenden: true }), JETZT)
|
||||
await warteAufVersand()
|
||||
assert.equal(buchung(erg.id).gastEmail, 'paula@example.com')
|
||||
assert.equal(sink.sent.length, 1)
|
||||
const g02 = sink.sent[0]
|
||||
assert.equal(g02.to[0].address, 'paula@example.com')
|
||||
assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026')
|
||||
assert.doesNotMatch(g02.text, /angenommen/)
|
||||
assert.doesNotMatch(g02.text, /Widerruf/)
|
||||
assert.equal(g02.icalEvent.method, 'REQUEST')
|
||||
assert.match(entfalte(g02.icalEvent.content), /SEQUENCE:0\r\n/)
|
||||
assert.deepEqual(arten(), ['G02'])
|
||||
const link = linkIn(g02)
|
||||
assert.equal(link, verwaltungsToken(erg.id))
|
||||
const v = await vorschau(link, JETZT)
|
||||
assert.equal(v.art, 'verwalten')
|
||||
assert.equal(v.aktionen.widerrufen, false)
|
||||
})
|
||||
|
||||
test('manuell: Haken ohne Adresse ist kein Fehler, es geht nur keine Mail raus', async () => {
|
||||
const ctx = await speichere()
|
||||
const erg = await manuell(ctx, handEingabe({ bestaetigungSenden: true }), JETZT)
|
||||
await warteAufVersand()
|
||||
assert.equal(buchung(erg.id).gastEmail, '')
|
||||
assert.equal(sink.sent.length, 0)
|
||||
assert.equal(log.error.mock.callCount(), 0)
|
||||
// ohne Adresse gehen auch spaetere Aktionen ohne Mail durch
|
||||
await aktion(ctx, erg.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
|
||||
await warteAufVersand()
|
||||
assert.equal(sink.sent.length, 0)
|
||||
assert.equal(buchung(erg.id).status, 'abgesagt')
|
||||
})
|
||||
|
||||
test('manuell: Pruefung wie beim Gast, aber ohne Zustimmungen; Adresse freiwillig, aber wenn angegeben gueltig', async () => {
|
||||
const ctx = await speichere()
|
||||
// Storno-Haken und Zustimmung zum vorzeitigen Beginn entfallen (Termin in 7 Tagen, mit Preis, Verbraucher-Betrieb)
|
||||
assert.equal((await manuell(ctx, handEingabe({ tag: TAG_NAH }), JETZT)).status, 'bestaetigt')
|
||||
|
||||
for (const [feld, over] of [
|
||||
['name', { name: ' ' }],
|
||||
['name', { name: 'N'.repeat(101) }],
|
||||
['email', { email: 'kein-postfach' }],
|
||||
['email', { email: 'a@example.com, b@example.com' }],
|
||||
['email', { email: 5 }],
|
||||
['leistung', { leistung: 's_gibtesnicht' }],
|
||||
['ressource', { ressource: 'r_gibtesnicht' }],
|
||||
['tag', { tag: '2026-02-30' }],
|
||||
['zeit', { zeit: '25:61' }],
|
||||
['telefon', { telefon: 'abc' }],
|
||||
['nachricht', { nachricht: 'n'.repeat(501) }],
|
||||
['zusatz', { zusatz: { unbekannt: 'x' } }],
|
||||
['bestaetigungSenden', { bestaetigungSenden: 'ja' }],
|
||||
]) {
|
||||
const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '15:00', ...over }), JETZT))
|
||||
assert.equal(err.status, 400, feld)
|
||||
assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`)
|
||||
}
|
||||
for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => manuell(ctx, roh, JETZT))).status, 400)
|
||||
assert.equal(buchungen().length, 1, 'nur der gueltige Eintrag')
|
||||
await assert.rejects(() => manuell(ctx, handEingabe(), undefined), TypeError)
|
||||
await assert.rejects(() => manuell({ feature: ctx.feature }, handEingabe(), JETZT), TypeError)
|
||||
})
|
||||
|
||||
test('manuell: ohne Vorlauf und Horizont, nie in der Vergangenheit, nie ausserhalb der Oeffnung, nie auf belegten Zellen', async () => {
|
||||
const ctx = await speichere()
|
||||
// Vorlauf (120 Minuten): heute 11:00 geht; Horizont (90 Tage): Februar geht
|
||||
assert.equal((await manuell(ctx, handEingabe({ tag: '2026-10-05', zeit: '11:00' }), JETZT)).status, 'bestaetigt')
|
||||
assert.equal((await manuell(ctx, handEingabe({ tag: '2027-02-01', zeit: '10:00' }), JETZT)).status, 'bestaetigt')
|
||||
|
||||
const vorher = buchungen().length
|
||||
const standZellen = claimsStand()
|
||||
for (const over of [
|
||||
{ tag: '2026-10-05', zeit: '09:00' }, // vergangen
|
||||
{ tag: '2026-10-04', zeit: '10:00' }, // gestern
|
||||
{ tag: '2026-10-27', zeit: '10:00' }, // Dienstag, kein Plan
|
||||
{ tag: TAG_FERN, zeit: '08:45' }, // vor der Oeffnung
|
||||
{ tag: TAG_FERN, zeit: '10:07' }, // nicht im Raster
|
||||
{ tag: '2026-10-05', zeit: '11:15' }, // ueberlappt den Termin um 11:00
|
||||
]) {
|
||||
const err = await fehlerVon(() => manuell(ctx, handEingabe(over), JETZT))
|
||||
assert.equal(err.status, 409, JSON.stringify(over))
|
||||
assert.equal(err.code, 'SLOT_VERGEBEN')
|
||||
assert.deepEqual(Object.keys(err.fehler), ['zeit'])
|
||||
}
|
||||
assert.equal(buchungen().length, vorher)
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
})
|
||||
|
||||
test('manuell: "egal" waehlt die Person mit den wenigsten Terminen des Tages', async () => {
|
||||
const ctx = await speichere()
|
||||
const a = await manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT) // R1
|
||||
assert.equal(buchung(a.id).ressourceId, R1)
|
||||
const b = await manuell(ctx, handEingabe({ ressource: 'egal', zeit: '10:00' }), JETZT)
|
||||
assert.equal(buchung(b.id).ressourceId, R2)
|
||||
assert.equal(buchung(b.id).ressourceName, 'Ben')
|
||||
})
|
||||
|
||||
test('manuell: ein Konflikt beim Belegen der Zellen (409) raeumt die Buchung wieder weg', async () => {
|
||||
const ctx = await speichere()
|
||||
// Zelle um 10:00 gehoert einer fremden, bestaetigten Buchung, die der Tagesabruf nicht sieht (anderer Tag in der Datenbank)
|
||||
aw.seed('termineBookings', {
|
||||
$id: 'fremd-0001',
|
||||
featureId: FEATURE,
|
||||
ressourceId: R1,
|
||||
status: 'bestaetigt',
|
||||
startsAt: '2026-12-01T09:00:00.000Z',
|
||||
endsAt: '2026-12-01T09:45:00.000Z',
|
||||
blockEndsAt: '2026-12-01T09:50:00.000Z',
|
||||
})
|
||||
aw.seed('termineClaims', {
|
||||
$id: zellenId(FEATURE, R1, '2026-10-26T09:00:00.000Z'),
|
||||
featureId: FEATURE,
|
||||
ressourceId: R1,
|
||||
bookingId: 'fremd-0001',
|
||||
zelle: '2026-10-26T09:00:00.000Z',
|
||||
bis: '2026-10-26T09:50:00.000Z',
|
||||
})
|
||||
const err = await fehlerVon(() => manuell(ctx, handEingabe(), JETZT))
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'SLOT_VERGEBEN')
|
||||
assert.deepEqual(buchungen().map((b) => b.$id), ['fremd-0001'], 'keine Reste der gescheiterten Buchung')
|
||||
assert.equal(aw.docs('termineClaims').size, 1)
|
||||
})
|
||||
|
||||
test('manuell: eine Belegung ueber 500 Minuten wird abgelehnt (400 Feld leistung), bevor etwas angelegt wird', async () => {
|
||||
const e = einstellung()
|
||||
const lang = { ...e, leistungen: e.leistungen.map((l) => (l.id === S_SOFORT ? { ...l, dauer: 480, pufferNach: 25 } : l)) }
|
||||
const ctx = ctxMit(lang)
|
||||
const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT))
|
||||
assert.equal(err.status, 400)
|
||||
assert.deepEqual(Object.keys(err.fehler), ['leistung'])
|
||||
assert.equal(buchungen().length, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Sperren
|
||||
|
||||
test('sperre: 09:00 bis 17:30 sind 510 Minuten => 400 Feld bis mit dem Hinweis auf Abwesenheiten; 500 Minuten gehen', async () => {
|
||||
const ctx = await speichere()
|
||||
const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:30', titel: 'Fortbildung' }, JETZT))
|
||||
assert.equal(err.status, 400)
|
||||
assert.deepEqual(err.fehler, { bis: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.' })
|
||||
assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:25' }, JETZT))).status, 400)
|
||||
assert.equal(buchungen().length, 0)
|
||||
assert.equal(aw.docs('termineClaims').size, 0)
|
||||
|
||||
const ok = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:20', titel: 'Fortbildung' }, JETZT)
|
||||
assert.equal(ok.status, 'bestaetigt')
|
||||
assert.equal(claims(ok.id).length, 100)
|
||||
})
|
||||
|
||||
test('sperre: 09:00 bis 13:00 ist eine bestaetigte Buchung ohne Gast und blockiert die Slots dieser Ressource', async () => {
|
||||
const ctx = await speichere()
|
||||
const erg = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT)
|
||||
await warteAufVersand()
|
||||
const b = buchung(erg.id)
|
||||
assert.equal(b.quelle, 'sperre')
|
||||
assert.equal(b.status, 'bestaetigt')
|
||||
assert.equal(b.leistungName, 'Fortbildung')
|
||||
assert.equal(b.ressourceId, R1)
|
||||
assert.equal(b.ressourceName, 'Anna')
|
||||
assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z')
|
||||
assert.equal(iso(b.endsAt), '2026-10-26T12:00:00.000Z')
|
||||
assert.equal(iso(b.blockEndsAt), '2026-10-26T12:00:00.000Z')
|
||||
assert.equal(b.gastName, undefined)
|
||||
assert.equal(b.gastEmail, undefined)
|
||||
assert.equal(b.verbraucher, false)
|
||||
assert.equal(claims(erg.id).length, 48)
|
||||
assert.equal(eintraege().length, 0, 'eine Sperre schickt keine Mail')
|
||||
|
||||
// R1 ist gesperrt, R2 nicht; um 13:00 ist R1 wieder frei
|
||||
for (const zeit of ['09:00', '10:00', '12:00']) {
|
||||
const err = await fehlerVon(() => halte(ctx, eingabe({ zeit, email: `x${zeit.slice(0, 2)}@example.com` }), JETZT))
|
||||
assert.equal(err.code, 'SLOT_VERGEBEN', zeit)
|
||||
}
|
||||
assert.equal((await halte(ctx, eingabe({ zeit: '10:00', ressource: R2, email: 'ben@example.com' }), JETZT)).status, 'gehalten')
|
||||
assert.equal((await halte(ctx, eingabe({ zeit: '13:00', email: 'dreizehn@example.com' }), JETZT)).status, 'gehalten')
|
||||
// "egal" weicht auf die freie Person aus
|
||||
assert.equal((await halte(ctx, eingabe({ zeit: '11:00', ressource: 'egal', email: 'egal@example.com' }), JETZT)).status, 'gehalten')
|
||||
assert.equal(buchungen().find((x) => x.gastEmail === 'egal@example.com').ressourceId, R2)
|
||||
})
|
||||
|
||||
test('sperre: Titel (Vorgabe "Gesperrt"), Ressource, Tag und Uhrzeiten in 5-Minuten-Schritten werden geprueft', async () => {
|
||||
const ctx = await speichere()
|
||||
const gut = { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '10:00' }
|
||||
const ohneTitel = await sperre(ctx, gut, JETZT)
|
||||
assert.equal(buchung(ohneTitel.id).leistungName, 'Gesperrt')
|
||||
const bis24 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '20:00', bis: '24:00', titel: ' Nachtruhe ' }, JETZT)
|
||||
assert.equal(buchung(bis24.id).leistungName, 'Nachtruhe')
|
||||
assert.equal(iso(buchung(bis24.id).endsAt), '2026-10-26T23:00:00.000Z')
|
||||
|
||||
const vorher = buchungen().length
|
||||
for (const [feld, over] of [
|
||||
['ressource', { ressource: 'r_gibtesnicht' }],
|
||||
['ressource', { ressource: undefined }],
|
||||
['tag', { tag: '2026-13-01' }],
|
||||
['von', { von: '09:03' }],
|
||||
['von', { von: '9:00' }],
|
||||
['von', { von: '24:00' }],
|
||||
['bis', { bis: '10:07' }],
|
||||
['bis', { bis: undefined }],
|
||||
['bis', { von: '10:00', bis: '10:00' }], // Ende nicht nach Beginn
|
||||
['bis', { von: '10:00', bis: '09:00' }],
|
||||
['bis', { tag: '2026-10-05', von: '08:00', bis: '09:00' }], // endet in der Vergangenheit
|
||||
['von', { tag: '2026-03-29', von: '02:30', bis: '04:00' }], // Luecke der Maerz-Umstellung
|
||||
['titel', { titel: 't'.repeat(121) }],
|
||||
]) {
|
||||
const err = await fehlerVon(() => sperre(ctx, { ...gut, ...over }, JETZT))
|
||||
assert.equal(err.status, 400, feld)
|
||||
assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`)
|
||||
}
|
||||
for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => sperre(ctx, roh, JETZT))).status, 400)
|
||||
assert.equal(buchungen().length, vorher)
|
||||
|
||||
// eine Sperre darf schon laufen (Beginn vor jetzt, Ende danach)
|
||||
assert.equal((await sperre(ctx, { ressource: R1, tag: '2026-10-05', von: '09:00', bis: '12:00' }, JETZT)).status, 'bestaetigt')
|
||||
await assert.rejects(() => sperre(ctx, gut, undefined), TypeError)
|
||||
await assert.rejects(() => sperre({ feature: ctx.feature }, gut, JETZT), TypeError)
|
||||
})
|
||||
|
||||
test('sperre: ueber einem Termin oder einer anderen Sperre 409 (Feld von), ohne Reste; erst absagen, dann sperren', async () => {
|
||||
const ctx = await speichere()
|
||||
const { id } = await gebucht(ctx) // R1 10:00-10:50
|
||||
const standZellen = claimsStand()
|
||||
const vorher = buchungen().length
|
||||
|
||||
const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT))
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'SLOT_VERGEBEN')
|
||||
assert.deepEqual(err.fehler, { von: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.' })
|
||||
assert.equal(buchungen().length, vorher)
|
||||
assert.equal(claimsStand(), standZellen)
|
||||
|
||||
// die andere Person ist frei
|
||||
const r2 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)
|
||||
assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '12:30', bis: '14:00' }, JETZT))).status, 409)
|
||||
assert.equal(claims(r2.id).length, 48)
|
||||
|
||||
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
|
||||
assert.equal((await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)).status, 'bestaetigt')
|
||||
})
|
||||
|
||||
test('entsperre: loescht die Sperre samt Zellen, die Zeit ist wieder buchbar; nur Sperren der eigenen Funktion', async () => {
|
||||
const ctx = await speichere()
|
||||
const s = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT)
|
||||
const { id } = await gebucht(ctx, { ressource: R2, email: 'ben@example.com' })
|
||||
assert.equal(claims(s.id).length, 48)
|
||||
|
||||
// fremde Funktion, normale Buchung, unbekannte und kaputte Nummern
|
||||
for (const [kontext, bid] of [[ctxMit(einstellung(), 'f_andere_funktion'), s.id], [ctx, id], [ctx, 'gibt-es-nicht'], [ctx, ''], [ctx, null], [ctx, '../x']]) {
|
||||
const err = await fehlerVon(() => entsperre(kontext, bid))
|
||||
assert.equal(err.status, 404, String(bid))
|
||||
assert.deepEqual(err.fehler, { _: 'Diese Sperre gibt es nicht.' })
|
||||
}
|
||||
assert.equal(buchung(s.id).status, 'bestaetigt')
|
||||
assert.equal(buchung(id).status, 'bestaetigt')
|
||||
|
||||
assert.deepEqual(await entsperre(ctx, s.id), { status: 'entsperrt' })
|
||||
assert.equal(buchung(s.id), undefined)
|
||||
assert.equal(claims(s.id).length, 0)
|
||||
assert.equal(claims(id).length, 10, 'andere Zellen bleiben')
|
||||
assert.equal((await halte(ctx, eingabe({ zeit: '10:00', email: 'neu@example.com' }), JETZT)).status, 'gehalten')
|
||||
assert.equal((await fehlerVon(() => entsperre(ctx, s.id))).status, 404)
|
||||
await assert.rejects(() => entsperre({ feature: ctx.feature }, s.id), TypeError)
|
||||
})
|
||||
@@ -426,6 +426,24 @@ test('halte: fehlt die Zustimmung zum vorzeitigen Beginn (verbindlich, Preis, Ve
|
||||
assert.equal((await halte(unternehmer, eingabe({ tag: TAG_NAH, zeit: '16:00', email: 'c@example.com' }), JETZT)).status, 'gehalten')
|
||||
})
|
||||
|
||||
test('halte: die Zustimmung zum vorzeitigen Beginn gilt bis zum Ende der Berliner Widerrufsfrist (R31), bei einer Anfrage mit Preis immer', async () => {
|
||||
const ctx = await speichere()
|
||||
// Buchung am Montag 05.10. (Haltezeit mitgerechnet) ⇒ Fristende Montag 19.10., 23:59:59 Uhr: 14 x 24 Stunden reichen nicht
|
||||
const err = await fehlerVon(() => halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00' }), JETZT))
|
||||
assert.deepEqual(Object.keys(err.fehler), ['vorzeitig'])
|
||||
assert.equal((await halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00', zustimmungen: { storno: true, vorzeitig: true } }), JETZT)).status, 'gehalten')
|
||||
// der naechste Tag liegt hinter der Frist
|
||||
assert.equal((await halte(ctx, eingabe({ tag: '2026-10-26', zeit: '10:00', email: 'a@example.com' }), JETZT)).status, 'gehalten')
|
||||
|
||||
const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] }
|
||||
const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] }))
|
||||
const fern = eingabe({ leistung: 's_bbbbbbb4', tag: TAG_FERN, zeit: '10:00', email: 'b@example.com' })
|
||||
assert.deepEqual(Object.keys((await fehlerVon(() => halte(mitAnfrage, fern, JETZT))).fehler), ['vorzeitig'])
|
||||
const ok = await halte(mitAnfrage, { ...fern, zustimmungen: { storno: true, vorzeitig: true } }, JETZT)
|
||||
assert.equal(ok.status, 'gehalten')
|
||||
assert.ok(JSON.parse(buchungen().find((b) => b.leistungId === 's_bbbbbbb4').nachweis).vorzeitig)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Slot und Doppelbuchung
|
||||
|
||||
test('halte: eine Zeit ausserhalb der freien Zeiten ist ein 409 mit Feld zeit und legt nichts an', async () => {
|
||||
|
||||
@@ -721,28 +721,56 @@ test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => {
|
||||
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false)
|
||||
})
|
||||
|
||||
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn in weniger als 14 Tagen', () => {
|
||||
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn bis zum Ende der Widerrufsfrist (Berliner Kalender, §§ 187, 188, 193 BGB)', () => {
|
||||
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
|
||||
const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } }
|
||||
const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } }
|
||||
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
|
||||
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
|
||||
// NOW = Freitag 02.10.2026, 12:00 Berlin; Vertragsschluss spaetestens nach der Haltezeit (30 Minuten): Fristende
|
||||
// Freitag 16.10.2026, 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC (MESZ)
|
||||
const FRISTENDE = Date.parse('2026-10-16T21:59:59.999Z')
|
||||
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(inTagen(14).getTime() - 1), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
|
||||
// Grenze: die letzte Millisekunde der Frist ja, die naechste nein
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE + 1), NOW), false)
|
||||
// 14 mal 24 Stunden reichen nicht: 14 Tage nach der Buchung um 12:00 liegen noch in der Frist (bis 24 Uhr des Tages)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true)
|
||||
// Unternehmer, Freizeit, kein Preis, Anfrage: keine Zustimmung noetig
|
||||
// Fristende auf einem Wochenende ⇒ naechster Werktag: Samstag 10.10.2026 + 14 = Samstag 24.10. ⇒ Montag 26.10., 23:59:59,999 Uhr Berlin (MEZ)
|
||||
const SAMSTAG = new Date('2026-10-10T10:00:00.000Z')
|
||||
const wochenendeEnde = Date.parse('2026-10-26T22:59:59.999Z')
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde), SAMSTAG), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde + 1), SAMSTAG), false)
|
||||
// die Haltezeit zaehlt mit: wer um 23:45 Uhr bucht, schliesst den Vertrag erst nach Mitternacht (Fristende einen Tag spaeter)
|
||||
// (Samstag 03.10. + 14 = Samstag 17.10. ⇒ Montag 19.10., 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC)
|
||||
const spaet = new Date('2026-10-02T21:45:00.000Z') // 23:45 Uhr Berlin, Freitag; + 30 Minuten = Samstag 00:15 Uhr
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-16T21:59:59.999Z') + 1), spaet), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-19T21:59:59.999Z') + 1), spaet), false)
|
||||
// Unternehmer, Freizeit, kein Preis: keine Zustimmung noetig
|
||||
assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, buchungsart: 'anfrage' }, inTagen(3), NOW), false)
|
||||
// ungueltiger Beginn: lieber die Zustimmung verlangen
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true)
|
||||
})
|
||||
|
||||
test('brauchtZustimmungVorzeitig: bei einer Anfrage mit Preis fuer Verbraucher immer (der Tag der Annahme ist unbekannt)', () => {
|
||||
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
|
||||
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
|
||||
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(3), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(60), NOW), true)
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, new Date('kaputt'), NOW), true)
|
||||
// ohne Preis, Unternehmer, Freizeit: nein
|
||||
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...anfrage, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'unternehmer', freizeit: false } }, anfrage, inTagen(3), NOW), false)
|
||||
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'verbraucher', freizeit: true } }, anfrage, inTagen(3), NOW), false)
|
||||
})
|
||||
|
||||
test('preisText, preisHinweis, zahlungText', () => {
|
||||
assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €')
|
||||
assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €')
|
||||
|
||||
Reference in new issue
Block a user