import { randomUUID } from 'node:crypto' import { config } from '../../config.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' /** * Buchungsspeicher des Terminplaners: nur Datenzugriff auf termineBookings (Spezifikation 2026-10-02-terminplaner- * design.md, Abschnitte 2 und 4), keine Geschaeftsregeln. Die Listen kommen seitenweise vollstaendig (listAllDocuments, * Appwrite liefert ohne limit nur 25), `now` ist immer ein Parameter. Datumswerte aus Appwrite sind "+00:00"-Texte * und werden vom Aufrufer mit new Date() gelesen. Abfragen ueber mehrere Funktionen (Jobs) haben kein featureId. */ const col = () => config.collections.termineBookings /** Status, die eine Buchung belegen koennen (gehalten nur, solange heldUntil > now: istBelegend prueft es). */ const BELEGEND = ['gehalten', 'angefragt', 'bestaetigt'] /** Hoechste Erinnerungsvorlaufzeit (einstellungen.js: 2, 12, 24 oder 48 Stunden) und der Mindestabstand zum Termin. */ const ERINNERUNG_MAX_MS = 48 * 60 * 60 * 1000 const ERINNERUNG_MIN_MS = 60 * 60 * 1000 const STUNDE_MS = 60 * 60 * 1000 const HASH_FELDER = new Set(['verifyHash', 'manageHash']) const HASH_RE = /^[0-9a-f]{64}$/ const NUMMER_RE = /^[A-Z0-9]{8}$/ /** Bezugszeit der Loeschfristen je Status (siehe loeschKandidaten). */ const LOESCH_BEZUG = { verfallen: 'heldUntil', abgesagt: 'updatedAt', abgelehnt: 'updatedAt', erledigt: 'endsAt', nichterschienen: 'endsAt', } const iso = (wert) => new Date(wert).toISOString() const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '') /** Ohne die von Appwrite gelieferten $-Felder ($id, $createdAt, ...), die nie zurueckgeschrieben werden duerfen. */ const nurFelder = (daten) => Object.fromEntries(Object.entries(daten).filter(([key]) => !key.startsWith('$'))) /** * Legt eine Buchung an. Die ID kommt vom Aufrufer (id oder daten.$id), sonst randomUUID(); der Aufrufer braucht sie * vorher, weil Verwaltungstoken und Buchungsnummer aus ihr entstehen. createdAt/updatedAt: jetzt, wenn nicht gegeben. * => das angelegte Dokument. */ export async function legeAn(daten, id) { const felder = nurFelder(daten) const jetzt = new Date().toISOString() return createDocument( col(), { ...felder, createdAt: felder.createdAt ?? jetzt, updatedAt: felder.updatedAt ?? jetzt }, id ?? daten.$id ?? randomUUID() ) } /** Eine Buchung per ID; null, wenn es sie nicht gibt. */ export async function hole(id) { try { return await getDocument(col(), id) } catch (err) { if (err?.status === 404) return null throw err } } /** Aendert Felder einer Buchung und setzt updatedAt (jetzt, wenn nicht gegeben). => das geaenderte Dokument. */ export async function aendere(id, felder) { const aenderung = nurFelder(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. */ export async function imZeitraum(featureId, von, bis) { return listAllDocuments(col(), [ Query.equal('featureId', featureId), Query.lessThan('startsAt', iso(bis)), Query.greaterThan('blockEndsAt', iso(von)), ]) } /** Buchung zu einem Token-Hash (feld 'verifyHash' oder 'manageHash'); null bei leerem, kaputtem oder unbekanntem Hash. */ export async function perHash(feld, hash) { if (!HASH_FELDER.has(feld)) throw new TypeError(`perHash: Feld ${feld} nicht erlaubt`) if (typeof hash !== 'string' || !HASH_RE.test(hash)) return null const [buchung] = await listDocuments(col(), [Query.equal(feld, hash), Query.limit(1)]) return buchung ?? null } /** * Offene Termine einer Adresse in dieser Funktion: belegend (gueltige Haltung, Anfrage, bestaetigt) und noch nicht * zu Ende. Die Adresse zaehlt klein und getrimmt. => Buchungen (die Grenze maxOffeneJeEmail prueft der Aufrufer). */ export async function offeneFuerEmail(featureId, email, now) { const nowMs = pruefeNow(now, 'offeneFuerEmail') const mail = kleinGetrimmt(email) if (!mail) return [] const kandidaten = await listAllDocuments(col(), [ Query.equal('featureId', featureId), Query.equal('gastEmail', mail), Query.greaterThan('endsAt', iso(now)), ]) return kandidaten.filter((buchung) => istBelegend(buchung, nowMs)) } /** Buchung zu Adresse und Buchungsnummer (Widerruf ohne Link); null, wenn beides zusammen nicht passt. */ export async function perEmailUndNummer(featureId, email, nummer) { const mail = kleinGetrimmt(email) const gesucht = typeof nummer === 'string' ? nummer.trim().toUpperCase() : '' if (!mail || !NUMMER_RE.test(gesucht)) return null const kandidaten = await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('gastEmail', mail)]) return kandidaten.find((buchung) => buchungsNummer(buchung.$id) === gesucht) ?? null } /** * Kuenftige belegende Termine (Ende nach jetzt) je Leistung und je Ressource, ohne Sperren (kein Gast, kein Termin): * { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } }, die Form von kuenftigeTermine in * normalisiereEinstellung. */ export async function zaehleKuenftige(featureId, now) { const nowMs = pruefeNow(now, 'zaehleKuenftige') const liste = await listAllDocuments(col(), [ Query.equal('featureId', featureId), Query.equal('status', BELEGEND), Query.greaterThan('endsAt', iso(now)), ]) const leistungen = new Map() const ressourcen = new Map() const zaehle = (karte, id) => { if (typeof id === 'string' && id !== '') karte.set(id, (karte.get(id) ?? 0) + 1) } for (const buchung of liste) { if (buchung.quelle === 'sperre' || !istBelegend(buchung, nowMs)) continue zaehle(leistungen, buchung.leistungId) zaehle(ressourcen, buchung.ressourceId) } return { leistungen: Object.fromEntries(leistungen), ressourcen: Object.fromEntries(ressourcen) } } /** Haltungen (alle Funktionen), die abgelaufen sind (heldUntil <= jetzt): der Minutenjob macht sie zu verfallen. */ export async function abgelaufeneHaltungen(now) { pruefeNow(now, 'abgelaufeneHaltungen') return listAllDocuments(col(), [Query.equal('status', 'gehalten'), Query.lessThanEqual('heldUntil', iso(now))]) } /** * Kandidaten fuer die Erinnerung (alle Funktionen): bestaetigt, Erinnerung gewuenscht, noch keine gesendet, Beginn in * (jetzt + 1 h, jetzt + 48 h]. Den Vorlauf der Funktion (stundenVorher) und den Vertragsschluss prueft der Job. */ export async function erinnerungsKandidaten(now) { const nowMs = pruefeNow(now, 'erinnerungsKandidaten') const liste = await listAllDocuments(col(), [ Query.equal('status', 'bestaetigt'), Query.equal('erinnerung', true), Query.greaterThan('startsAt', iso(nowMs + ERINNERUNG_MIN_MS)), Query.lessThanEqual('startsAt', iso(nowMs + ERINNERUNG_MAX_MS)), ]) return liste.filter((buchung) => !buchung.erinnerungGesendetAt) } /** Bestaetigte Buchungen (alle Funktionen, auch Sperren), deren Ende mehr als 1 h her ist: der Job macht sie erledigt. */ export async function zuErledigen(now) { const nowMs = pruefeNow(now, 'zuErledigen') return listAllDocuments(col(), [Query.equal('status', 'bestaetigt'), Query.lessThan('endsAt', iso(nowMs - STUNDE_MS))]) } /** Anfragen (alle Funktionen), deren Beginn vorbei ist: der Job lehnt sie vom System aus ab. */ export async function vergangeneAnfragen(now) { pruefeNow(now, 'vergangeneAnfragen') return listAllDocuments(col(), [Query.equal('status', 'angefragt'), Query.lessThan('startsAt', iso(now))]) } /** * Buchungen eines Status (alle Funktionen), deren Bezugszeit vor `vor` liegt. Bezugszeit je Status: verfallen = * heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte * Aenderung, also der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Die Frist je * Funktion (aufbewahrungTage) wendet der Aufrufer auf das Ergebnis an. Andere Status: TypeError. */ export async function loeschKandidaten(status, vor) { if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`) return listAllDocuments(col(), [Query.equal('status', status), Query.lessThan(LOESCH_BEZUG[status], iso(vor))]) } /** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */ export async function zaehleAnfragen(featureId) { return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length }