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
|
||||
|
||||
Reference in new issue
Block a user