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' /** * 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 * (verwaisteZellen). * * 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 /** 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 /** Zeitpunkt als ms: Date, ISO-Text (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */ function zeitMs(wert) { if (wert instanceof Date) return wert.getTime() if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime() if (typeof wert === 'number') return wert return Number.NaN } function pruefeText(wert, name) { if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) } function pruefeJetzt(now, name) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) return now.getTime() } 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 = pruefeJetzt(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( [`termine-res:${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) und loescht alle; => Zahl geloeschter Zellen. */ export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) { pruefeText(bookingId, 'bookingId') const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite)) const zellen = await listAllDocuments(claims(), [Query.equal('bookingId', bookingId)]) const weg = zellen.filter((zelle) => !(behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId))) return loesche(claims(), weg.map((zelle) => zelle.$id)) } /** * 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). */ export async function verwaisteZellen(now) { const nowMs = pruefeJetzt(now, 'verwaisteZellen') const grenze = nowMs - TAG_MS const alle = await listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX }) const verwaist = [] const pruefen = [] for (const zelle of alle) { if (zeitMs(zelle.bis) < grenze) verwaist.push(zelle.$id) else pruefen.push(zelle) } for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id) return verwaist }