Files
Webklar-Kundenbereich/server/services/termine/buchungen.js
T
ClaudeandClaude Sonnet 5.5 5c95dfc322 Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)
loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion)
ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen)
endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den
Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und
abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft.
taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und
idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:55:32 +00:00

217 lines
10 KiB
JavaScript

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',
}
/** Eine Seite von loeschKandidaten und die Felder, die sie liest (nie Gastfelder). */
const LOESCH_SEITE = 100
const LOESCH_FELDER = ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil']
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, deren Bezugszeit vor `vor` liegt (die Frist steht in der Abfrage, nie nachtraeglich im Code):
* hoechstens `limit` (Standard 100, eine Seite), die mit der aeltesten Bezugszeit zuerst. 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). Mit featureId nur diese Funktion, sonst alle.
* Ohne Cursor: wer loescht, liest die Liste danach neu (Geloeschtes fehlt). Die Treffer tragen nur $id, featureId, status,
* endsAt, updatedAt und heldUntil, nie Gastfelder (Name, Adresse). Die Frist je Funktion (aufbewahrungTage) wendet der
* Aufrufer auf das Ergebnis an. Andere Status: TypeError.
*/
export async function loeschKandidaten(status, vor, { featureId = null, limit = LOESCH_SEITE } = {}) {
if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`)
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`loeschKandidaten: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
const bezug = LOESCH_BEZUG[status]
return listDocuments(col(), [
Query.equal('status', status),
Query.lessThan(bezug, iso(vor)),
...(featureId === null ? [] : [Query.equal('featureId', featureId)]),
Query.orderAsc(bezug),
Query.limit(limit),
Query.select(LOESCH_FELDER),
])
}
/** Feld der Bezugszeit einer Loeschfrist (siehe loeschKandidaten); undefined fuer Status, die nicht automatisch geloescht werden. */
export const loeschBezugsfeld = (status) => (Object.hasOwn(LOESCH_BEZUG, status) ? LOESCH_BEZUG[status] : undefined)
/** 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
}