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 { verwerfeWartende } from './outbox.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:` 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` = { : 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), absageGrund leer (er kann den vom Gast getippten Namen einer Widerrufserklaerung oder * einen frei getippten Grund des Betriebs enthalten). Noch nicht versandte Mails der Buchung (Outbox, Status wartet) * werden geloescht, sie halten Adresse und Inhalt; zuerst, damit ein Fehler dort noch nichts geaendert hat und ein * zweiter Klick alles nachholt. 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' await verwerfeWartende(frisch.$id) const buchung = await aendere(frisch.$id, { gastName: GELOESCHT, gastEmail: '', gastTelefon: '', gastNachricht: '', gastZusatz: '', absageGrund: '', 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). Pflicht ist nur der * Name; E-Mail, Telefon, Nachricht und Zusatzfelder sind freiwillig, egal was gastFelder fuer Gaeste vorgibt, werden aber, * wenn angegeben, wie beim Gast geprueft. Leistung, Person oder "egal", Tag und Uhrzeit wie beim Gast, ohne Zustimmungen * und Erinnerung. 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` an den Gast; dann ist die E-Mail-Adresse Pflicht (400 Feld email). 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 senden = roh?.bestaetigungSenden === true const g = pruefeEingabe(e, roh, now, { manuell: true, bestaetigung: senden }) if (roh.bestaetigungSenden !== undefined && roh.bestaetigungSenden !== null && typeof roh.bestaetigungSenden !== 'boolean') { throw fehlerMit(400, { bestaetigungSenden: MELDUNG.ungueltig }) } 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' } }) }