import { createHash } from 'node:crypto' import { config } from '../../config.js' import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { istBelegend } from './slots.js' import { pruefeNow, pruefeText, zeitMs } from './util.js' /** * Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5). * * Jede 5-Minuten-Zelle (config.termine.zelleMin) von Beginn bis Blockende ist ein termineClaims-Dokument mit * deterministischer ID (c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)). Alle neuen Zellen einer Belegung gehen * in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID * ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses. * * Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe * istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst, * wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest * (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur). * * Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine * neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer * frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen * gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur, * was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist. * * Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in * der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen * Zeit zwischen Zellen und Buchung wegraeumen. Scheitert belegeZellen (SLOT_VERGEBEN), raeumt der Aufrufer die * Buchung selbst wieder auf. Beim Verschieben ist die Buchung ohnehin da. */ /** Hoechstens so viele Zellen in einem Bulk-Create (Appwrite: 100 Dokumente je Anfrage). */ const MAX_ZELLEN = 100 const TAG_MS = 24 * 60 * 60 * 1000 const SPERRE_WARTE_MS = 5000 /** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */ const AUFRAEUM_WARTE_MS = 15000 /** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */ const RAEUM_BLOCK = 100 const RAEUM_RUNDEN = 3 /** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */ const ID_BLOCK = 50 const LOESCH_PARALLEL = 10 /** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */ const ALLE_ZELLEN_MAX = 100000 const claims = () => config.collections.termineClaims const buchungen = () => config.collections.termineBookings const sperrSchluessel = (featureId, ressourceId) => `termine-res:${featureId}:${ressourceId}` const slotVergeben = () => Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), { status: 409, code: 'SLOT_VERGEBEN', }) /** * Alle Zellen von start bis blockEnde (ausgeschlossen) als Date, ausgerichtet am Raster seit 1970 (die Berliner * Zeitzone verschiebt um ganze Stunden, daher stimmt das Raster mit der Wanduhr ueberein). Krumme Zeiten werden zu * ganzen Zellen erweitert. Wirft bei ungueltigen Zeiten, Ende <= Beginn und mehr als 100 Zellen. */ export function zellenFuer(start, blockEnde) { const von = zeitMs(start) const bis = zeitMs(blockEnde) if (!Number.isFinite(von) || !Number.isFinite(bis) || bis <= von) throw new RangeError('zellenFuer: Beginn und Ende ungültig') const zelle = config.termine.zelleMin * 60000 const erste = Math.floor(von / zelle) const letzte = Math.ceil(bis / zelle) if (letzte - erste > MAX_ZELLEN) throw new RangeError(`zellenFuer: mehr als ${MAX_ZELLEN} Zellen`) const zellen = [] for (let i = erste; i < letzte; i += 1) zellen.push(new Date(i * zelle)) return zellen } /** Dokument-ID einer Zelle: c_ + 34 Hex (36 Zeichen, das Maximum fuer Appwrite-IDs). */ export function zellenId(featureId, ressourceId, zelle) { const iso = new Date(zeitMs(zelle)).toISOString() return `c_${createHash('sha1').update(`${featureId}|${ressourceId}|${iso}`).digest('hex').slice(0, 34)}` } /** Dokumente einer Collection nach ihren IDs (in Bloecken, damit die Abfrage kurz bleibt; fehlende fehlen im Ergebnis). */ async function leseNachIds(collection, ids) { const gefunden = [] for (let i = 0; i < ids.length; i += ID_BLOCK) { gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))]))) } return gefunden } /** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */ async function ungueltigeZellen(zellen, nowMs) { const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))] const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung])) return zellen.filter((zelle) => { const buchung = vorhanden.get(zelle.bookingId) return !buchung || !istBelegend(buchung, nowMs) }) } /** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */ async function loesche(collection, ids) { let geloescht = 0 let fehler = null for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) { const ergebnisse = await Promise.allSettled(ids.slice(i, i + LOESCH_PARALLEL).map((id) => deleteDocument(collection, id))) for (const ergebnis of ergebnisse) { if (ergebnis.status === 'fulfilled') geloescht += 1 else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason } } if (fehler) throw fehler return geloescht } /** Ein Bulk-Create; false bei 409 (eine Zelle ist vergeben, nichts geschrieben), andere Fehler gehen durch. */ async function legeZellenAn(dokumente) { try { await createDocuments(claims(), dokumente) return true } catch (err) { if (err?.status === 409) return false throw err } } /** * Nach einem 409: die blockierenden Zellen lesen und die ungueltigen loeschen. true = ein zweiter Versuch lohnt * (ungueltige geloescht oder die Blockierer sind inzwischen weg), false = alle Blockierer gelten. */ async function raeumeBlockierer(ids, nowMs) { const blockierer = await leseNachIds(claims(), ids) if (blockierer.length === 0) return true const ungueltig = await ungueltigeZellen(blockierer, nowMs) if (ungueltig.length === 0) return false await loesche(claims(), ungueltig.map((zelle) => zelle.$id)) return true } /** * Belegt die Zellen von start bis blockEnde fuer bookingId auf einer Ressource oder wirft { status: 409, code: * 'SLOT_VERGEBEN' } (dann ist nichts geschrieben). Unter der Sperre termine-res:: (hoechstens * 5 s Wartezeit; die Datenbank bleibt die eigentliche Sicherung). * * ausser = ID einer Buchung, deren Zellen als eigene zaehlen (Verschieben): angelegt wird nur, was von ihr noch fehlt; * ihre alten Zellen bleiben, bis der Aufrufer gibZellenFrei(id, { behalte }) aufruft. Bei einem Fehler aendert sich an * ihnen nichts. */ export async function belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null } = {}) { pruefeText(featureId, 'featureId') pruefeText(ressourceId, 'ressourceId') pruefeText(bookingId, 'bookingId') if (ausser !== null) pruefeText(ausser, 'ausser') const nowMs = pruefeNow(now, 'belegeZellen') const zellen = zellenFuer(start, blockEnde) const bis = new Date(zeitMs(blockEnde)).toISOString() const dokumente = zellen.map((zelle) => ({ $id: zellenId(featureId, ressourceId, zelle), featureId, ressourceId, bookingId, zelle: zelle.toISOString(), bis, })) await withLocks( [sperrSchluessel(featureId, ressourceId)], async () => { let neu = dokumente if (ausser !== null) { const eigene = new Set((await listAllDocuments(claims(), [Query.equal('bookingId', ausser)])).map((zelle) => zelle.$id)) neu = dokumente.filter((dokument) => !eigene.has(dokument.$id)) } if (neu.length === 0) return if (await legeZellenAn(neu)) return if (!(await raeumeBlockierer(neu.map((dokument) => dokument.$id), nowMs))) throw slotVergeben() if (!(await legeZellenAn(neu))) throw slotVergeben() }, { waitMs: SPERRE_WARTE_MS } ) } /** * Loescht die Zellen einer Buchung, ausser denen mit den Zeiten aus behalte (Date oder ISO-Text, z. B. das Ergebnis * von zellenFuer). Mit ressourceId bleiben nur Zellen dieser Ressource stehen (Verschieben auf eine andere Person zur * selben Zeit). Liest seitenweise (mehr als 25 Zellen). Geloescht wird unter der Sperre jeder betroffenen Ressource * nach erneutem Lesen: nur Zellen, die dort jetzt noch zu bookingId gehoeren (siehe Kopfkommentar). => Zahl geloeschter. */ export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) { pruefeText(bookingId, 'bookingId') const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite)) const bleibt = (zelle) => behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId) const suche = [Query.equal('bookingId', bookingId)] let geloescht = 0 for (let runde = 0; runde < RAEUM_RUNDEN; runde += 1) { const gelesen = await listAllDocuments(claims(), suche) // nur um die Ressourcen zu kennen, geloescht wird nach neuem Lesen const schluessel = [...new Set(gelesen.map((zelle) => sperrSchluessel(zelle.featureId, zelle.ressourceId)))].sort() if (schluessel.length === 0) break const gesperrt = new Set(schluessel) const vollstaendig = await withLocks( schluessel, async () => { const aktuell = await listAllDocuments(claims(), suche) const hier = aktuell.filter((zelle) => gesperrt.has(sperrSchluessel(zelle.featureId, zelle.ressourceId))) geloescht += await loesche(claims(), hier.filter((zelle) => !bleibt(zelle)).map((zelle) => zelle.$id)) return hier.length === aktuell.length }, { waitMs: AUFRAEUM_WARTE_MS } ) if (vollstaendig) break } return geloescht } /** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */ async function verwaisteAus(zellen, nowMs) { const grenze = nowMs - TAG_MS const alt = [] const pruefen = [] for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle) return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))] } const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX }) /** * IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. * belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht: * zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen. */ export async function verwaisteZellen(now) { const nowMs = pruefeNow(now, 'verwaisteZellen') return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id) } /** * Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren * Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert * oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen. * => Zahl geloeschter Zellen. */ export async function raeumeVerwaisteZellen(now) { const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen') const nachSperre = new Map() for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) { const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle]) } let geloescht = 0 let fehler = null for (const [schluessel, liste] of nachSperre) { for (let i = 0; i < liste.length; i += RAEUM_BLOCK) { const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId])) try { geloescht += await withLocks( [schluessel], async () => { const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId) return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id)) }, { waitMs: AUFRAEUM_WARTE_MS } ) } catch (err) { fehler ??= err } } } if (fehler) throw fehler return geloescht }