Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot, verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist (withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
198 lines
8.9 KiB
JavaScript
198 lines
8.9 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',
|
|
}
|
|
|
|
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
|
|
}
|