Files
Webklar-Kundenbereich/server/services/termine/buchungen.js
T
2026-10-04 09:32:14 +00:00

251 lines
12 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)
/**
* Eine Seite Buchungen einer Funktion (jeder Status, auch Sperren): hoechstens `limit` (Standard 100), nur mit der Kennung ($id),
* nie mit Gastfeldern. Fuer die Loeschung abgeschalteter Funktionen: wer loescht, liest die Liste danach neu (Geloeschtes fehlt).
* Ohne Funktion oder mit ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter oder ohne Grenze, die Treffer werden
* unwiderruflich geloescht).
*/
export async function ersteBuchungen(featureId, limit = LOESCH_SEITE) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('ersteBuchungen: featureId muss ein nicht leerer Text sein')
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`ersteBuchungen: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
return listDocuments(col(), [Query.equal('featureId', featureId), Query.limit(limit), Query.select(['$id'])])
}
/** Zeilen je Seite des CSV-Exports (exportSeite). */
export const EXPORT_SEITE = 500
/**
* Eine Seite Buchungen einer Funktion fuer den CSV-Export (jeder Status, auch Sperren; die filtert der Aufrufer je Seite
* heraus): hoechstens `limit` (Standard 500), nach Beginn (startsAt) aufsteigend, ab dem Dokument `nach` (dessen ID, null =
* erste Seite). Der Cursor ist immer die letzte Zeile der ungefilterten Seite. Sortiert wird nur nach startsAt (Index
* idx_feature_start; Appwrite haengt die interne Reihenfolge fuer gleiche Beginne selbst an), nie nach $id.
* Ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter: Gastdaten).
*/
export async function exportSeite(featureId, { nach = null, limit = EXPORT_SEITE } = {}) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('exportSeite: featureId muss ein nicht leerer Text sein')
if (nach !== null && (typeof nach !== 'string' || nach === '')) throw new TypeError('exportSeite: nach muss null oder eine Dokument-ID sein')
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`exportSeite: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
return listDocuments(col(), [
Query.equal('featureId', featureId),
Query.orderAsc('startsAt'),
Query.limit(limit),
...(nach === null ? [] : [Query.cursorAfter(nach)]),
])
}
/** 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
}