loescheAbgeschaltete liest je abgeschalteter Funktion (disabledAt vor 30 Tagen) Seiten zu 100
(neu buchungen.ersteBuchungen: nur $id, limit, nie Gastfelder) und loescht sie ueber arbeiteSeiten
samt Zellen, bis die Seite leer ist. Die Funktion wird nach jedem Lesen einer Seite und alle 50
Buchungen frisch geprueft (nochAbgeschaltet); wird sie wieder eingeschaltet, endet die Loeschung
fuer sie sofort. Deckel config.termine.loeschDeckel je Funktion: am Deckel bleibt die Funktion
disabled mit altem disabledAt, eine Warnung ohne Kennungen steht im Log, der naechste Tag macht
weiter, die uebrigen Funktionen kommen im selben Lauf dran. Die Einstellung (termineConfig) wird
nur nach leerer Seite ohne Fehler und mit letzter Pruefung unter termine-cfg:<id> geloescht.
loescheAbgeschalteteFunktionen liefert { funktionen, buchungen, gedeckelt }; taeglicherJob traegt
gedeckelt.abgeschaltet getrennt vom Loeschzaehler ein (Tagesbericht: Loeschlaeufe am Deckel).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
229 lines
11 KiB
JavaScript
229 lines
11 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'])])
|
|
}
|
|
|
|
/** 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
|
|
}
|