Terminplaner: Zellen nur unter der Ressourcensperre freigeben, gemeinsame Hilfsfunktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e281db1b2b
commit
9ad8515d60
6 files changed
+284
-71
No files matched your search
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
|
||||
import { Query, createDocument, 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-
|
||||
@@ -33,11 +34,6 @@ const LOESCH_BEZUG = {
|
||||
const iso = (wert) => new Date(wert).toISOString()
|
||||
const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '')
|
||||
|
||||
function jetztMs(now, name) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
|
||||
return now.getTime()
|
||||
}
|
||||
|
||||
/** 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('$')))
|
||||
|
||||
@@ -97,7 +93,7 @@ export async function perHash(feld, hash) {
|
||||
* zu Ende. Die Adresse zaehlt klein und getrimmt. => Buchungen (die Grenze maxOffeneJeEmail prueft der Aufrufer).
|
||||
*/
|
||||
export async function offeneFuerEmail(featureId, email, now) {
|
||||
const nowMs = jetztMs(now, 'offeneFuerEmail')
|
||||
const nowMs = pruefeNow(now, 'offeneFuerEmail')
|
||||
const mail = kleinGetrimmt(email)
|
||||
if (!mail) return []
|
||||
const kandidaten = await listAllDocuments(col(), [
|
||||
@@ -123,7 +119,7 @@ export async function perEmailUndNummer(featureId, email, nummer) {
|
||||
* normalisiereEinstellung.
|
||||
*/
|
||||
export async function zaehleKuenftige(featureId, now) {
|
||||
const nowMs = jetztMs(now, 'zaehleKuenftige')
|
||||
const nowMs = pruefeNow(now, 'zaehleKuenftige')
|
||||
const liste = await listAllDocuments(col(), [
|
||||
Query.equal('featureId', featureId),
|
||||
Query.equal('status', BELEGEND),
|
||||
@@ -144,7 +140,7 @@ export async function zaehleKuenftige(featureId, now) {
|
||||
|
||||
/** Haltungen (alle Funktionen), die abgelaufen sind (heldUntil <= jetzt): der Minutenjob macht sie zu verfallen. */
|
||||
export async function abgelaufeneHaltungen(now) {
|
||||
jetztMs(now, 'abgelaufeneHaltungen')
|
||||
pruefeNow(now, 'abgelaufeneHaltungen')
|
||||
return listAllDocuments(col(), [Query.equal('status', 'gehalten'), Query.lessThanEqual('heldUntil', iso(now))])
|
||||
}
|
||||
|
||||
@@ -153,7 +149,7 @@ export async function abgelaufeneHaltungen(now) {
|
||||
* (jetzt + 1 h, jetzt + 48 h]. Den Vorlauf der Funktion (stundenVorher) und den Vertragsschluss prueft der Job.
|
||||
*/
|
||||
export async function erinnerungsKandidaten(now) {
|
||||
const nowMs = jetztMs(now, 'erinnerungsKandidaten')
|
||||
const nowMs = pruefeNow(now, 'erinnerungsKandidaten')
|
||||
const liste = await listAllDocuments(col(), [
|
||||
Query.equal('status', 'bestaetigt'),
|
||||
Query.equal('erinnerung', true),
|
||||
@@ -165,13 +161,13 @@ export async function erinnerungsKandidaten(now) {
|
||||
|
||||
/** 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 = jetztMs(now, 'zuErledigen')
|
||||
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) {
|
||||
jetztMs(now, 'vergangeneAnfragen')
|
||||
pruefeNow(now, 'vergangeneAnfragen')
|
||||
return listAllDocuments(col(), [Query.equal('status', 'angefragt'), Query.lessThan('startsAt', iso(now))])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { istFeiertag } from './feiertage.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js'
|
||||
|
||||
/**
|
||||
@@ -22,18 +23,6 @@ const NICHT_BELEGT = new Set(['abgesagt', 'abgelehnt', 'verfallen'])
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const hhmm = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`
|
||||
|
||||
function pruefeNow(now, name) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
|
||||
}
|
||||
|
||||
/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00) oder Zahl; sonst NaN. */
|
||||
function zeitMs(wert) {
|
||||
if (wert instanceof Date) return wert.getTime()
|
||||
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
|
||||
if (typeof wert === 'number') return wert
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
/** 'HH:MM' -> Minuten seit Mitternacht ('24:00' = 1440), sonst null. */
|
||||
function minuten(wert) {
|
||||
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
|
||||
/**
|
||||
* Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8).
|
||||
@@ -33,16 +34,6 @@ function schluessel(zweck) {
|
||||
return createHmac('sha256', geheimnis).update(zweck).digest()
|
||||
}
|
||||
|
||||
function pruefeText(wert, name) {
|
||||
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
|
||||
}
|
||||
|
||||
function zeitMs(now, name) {
|
||||
const ms = now instanceof Date ? now.getTime() : now
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) throw new TypeError(`${name}: now fehlt`)
|
||||
return ms
|
||||
}
|
||||
|
||||
/** Neues Bestaetigungstoken: token geht in den Link, hash (SHA-256-Hex) in die Buchung. */
|
||||
export function neuesToken() {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
@@ -74,7 +65,7 @@ function formMac(siteKey, zeit) {
|
||||
/** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */
|
||||
export function formToken(siteKey, now = new Date()) {
|
||||
pruefeText(siteKey, 'siteKey')
|
||||
const zeit = zeitMs(now, 'formToken').toString(36)
|
||||
const zeit = pruefeNow(now, 'formToken').toString(36)
|
||||
return `${zeit}.${formMac(siteKey, zeit)}`
|
||||
}
|
||||
|
||||
@@ -90,7 +81,7 @@ export function pruefeFormToken(siteKey, token, now = new Date()) {
|
||||
const gegeben = Buffer.from(mac)
|
||||
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
|
||||
|
||||
const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36)
|
||||
const alter = pruefeNow(now, 'pruefeFormToken') - parseInt(zeit, 36)
|
||||
if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen'
|
||||
if (alter < config.termine.formMinSekunden * 1000) return 'zu_schnell'
|
||||
return 'ok'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Kleine Hilfen, die mehrere Terminplaner-Module teilen (Zeitpunkte lesen, Pflichtparameter pruefen).
|
||||
* Reine Funktionen ohne Appwrite und ohne config.
|
||||
*/
|
||||
|
||||
/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */
|
||||
export function zeitMs(wert) {
|
||||
if (wert instanceof Date) return wert.getTime()
|
||||
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
|
||||
if (typeof wert === 'number') return wert
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
/** `now` ist immer ein gueltiges Date (feste Uhr per Parameter, nie die Wanduhr), sonst TypeError. => Millisekunden. */
|
||||
export function pruefeNow(now, name) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
|
||||
return now.getTime()
|
||||
}
|
||||
|
||||
/** Pflichttext: nichtleerer String, sonst TypeError. */
|
||||
export function pruefeText(wert, name) {
|
||||
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
|
||||
import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { istBelegend } from './slots.js'
|
||||
import { pruefeNow, pruefeText, zeitMs } from './util.js'
|
||||
|
||||
/**
|
||||
* Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5).
|
||||
@@ -15,7 +16,13 @@ import { istBelegend } from './slots.js'
|
||||
* Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe
|
||||
* istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst,
|
||||
* wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest
|
||||
* (verwaisteZellen).
|
||||
* (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur).
|
||||
*
|
||||
* Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine
|
||||
* neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer
|
||||
* frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen
|
||||
* gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur,
|
||||
* was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist.
|
||||
*
|
||||
* Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in
|
||||
* der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen
|
||||
@@ -27,6 +34,11 @@ import { istBelegend } from './slots.js'
|
||||
const MAX_ZELLEN = 100
|
||||
const TAG_MS = 24 * 60 * 60 * 1000
|
||||
const SPERRE_WARTE_MS = 5000
|
||||
/** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */
|
||||
const AUFRAEUM_WARTE_MS = 15000
|
||||
/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */
|
||||
const RAEUM_BLOCK = 100
|
||||
const RAEUM_RUNDEN = 3
|
||||
/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */
|
||||
const ID_BLOCK = 50
|
||||
const LOESCH_PARALLEL = 10
|
||||
@@ -35,23 +47,7 @@ const ALLE_ZELLEN_MAX = 100000
|
||||
|
||||
const claims = () => config.collections.termineClaims
|
||||
const buchungen = () => config.collections.termineBookings
|
||||
|
||||
/** Zeitpunkt als ms: Date, ISO-Text (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */
|
||||
function zeitMs(wert) {
|
||||
if (wert instanceof Date) return wert.getTime()
|
||||
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
|
||||
if (typeof wert === 'number') return wert
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
function pruefeText(wert, name) {
|
||||
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
|
||||
}
|
||||
|
||||
function pruefeJetzt(now, name) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
|
||||
return now.getTime()
|
||||
}
|
||||
const sperrSchluessel = (featureId, ressourceId) => `termine-res:${featureId}:${ressourceId}`
|
||||
|
||||
const slotVergeben = () =>
|
||||
Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), {
|
||||
@@ -155,7 +151,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
|
||||
pruefeText(ressourceId, 'ressourceId')
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
if (ausser !== null) pruefeText(ausser, 'ausser')
|
||||
const nowMs = pruefeJetzt(now, 'belegeZellen')
|
||||
const nowMs = pruefeNow(now, 'belegeZellen')
|
||||
const zellen = zellenFuer(start, blockEnde)
|
||||
const bis = new Date(zeitMs(blockEnde)).toISOString()
|
||||
const dokumente = zellen.map((zelle) => ({
|
||||
@@ -168,7 +164,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
|
||||
}))
|
||||
|
||||
await withLocks(
|
||||
[`termine-res:${featureId}:${ressourceId}`],
|
||||
[sperrSchluessel(featureId, ressourceId)],
|
||||
async () => {
|
||||
let neu = dokumente
|
||||
if (ausser !== null) {
|
||||
@@ -187,30 +183,88 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
|
||||
/**
|
||||
* Loescht die Zellen einer Buchung, ausser denen mit den Zeiten aus behalte (Date oder ISO-Text, z. B. das Ergebnis
|
||||
* von zellenFuer). Mit ressourceId bleiben nur Zellen dieser Ressource stehen (Verschieben auf eine andere Person zur
|
||||
* selben Zeit). Liest seitenweise (mehr als 25 Zellen) und loescht alle; => Zahl geloeschter Zellen.
|
||||
* selben Zeit). Liest seitenweise (mehr als 25 Zellen). Geloescht wird unter der Sperre jeder betroffenen Ressource
|
||||
* nach erneutem Lesen: nur Zellen, die dort jetzt noch zu bookingId gehoeren (siehe Kopfkommentar). => Zahl geloeschter.
|
||||
*/
|
||||
export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) {
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite))
|
||||
const zellen = await listAllDocuments(claims(), [Query.equal('bookingId', bookingId)])
|
||||
const weg = zellen.filter((zelle) => !(behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId)))
|
||||
return loesche(claims(), weg.map((zelle) => zelle.$id))
|
||||
const bleibt = (zelle) => behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId)
|
||||
const suche = [Query.equal('bookingId', bookingId)]
|
||||
let geloescht = 0
|
||||
for (let runde = 0; runde < RAEUM_RUNDEN; runde += 1) {
|
||||
const gelesen = await listAllDocuments(claims(), suche) // nur um die Ressourcen zu kennen, geloescht wird nach neuem Lesen
|
||||
const schluessel = [...new Set(gelesen.map((zelle) => sperrSchluessel(zelle.featureId, zelle.ressourceId)))].sort()
|
||||
if (schluessel.length === 0) break
|
||||
const gesperrt = new Set(schluessel)
|
||||
const vollstaendig = await withLocks(
|
||||
schluessel,
|
||||
async () => {
|
||||
const aktuell = await listAllDocuments(claims(), suche)
|
||||
const hier = aktuell.filter((zelle) => gesperrt.has(sperrSchluessel(zelle.featureId, zelle.ressourceId)))
|
||||
geloescht += await loesche(claims(), hier.filter((zelle) => !bleibt(zelle)).map((zelle) => zelle.$id))
|
||||
return hier.length === aktuell.length
|
||||
},
|
||||
{ waitMs: AUFRAEUM_WARTE_MS }
|
||||
)
|
||||
if (vollstaendig) break
|
||||
}
|
||||
return geloescht
|
||||
}
|
||||
|
||||
/** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */
|
||||
async function verwaisteAus(zellen, nowMs) {
|
||||
const grenze = nowMs - TAG_MS
|
||||
const alt = []
|
||||
const pruefen = []
|
||||
for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle)
|
||||
return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))]
|
||||
}
|
||||
|
||||
const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX })
|
||||
|
||||
/**
|
||||
* IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw.
|
||||
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung).
|
||||
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht:
|
||||
* zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen.
|
||||
*/
|
||||
export async function verwaisteZellen(now) {
|
||||
const nowMs = pruefeJetzt(now, 'verwaisteZellen')
|
||||
const grenze = nowMs - TAG_MS
|
||||
const alle = await listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX })
|
||||
const verwaist = []
|
||||
const pruefen = []
|
||||
for (const zelle of alle) {
|
||||
if (zeitMs(zelle.bis) < grenze) verwaist.push(zelle.$id)
|
||||
else pruefen.push(zelle)
|
||||
const nowMs = pruefeNow(now, 'verwaisteZellen')
|
||||
return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren
|
||||
* Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert
|
||||
* oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen.
|
||||
* => Zahl geloeschter Zellen.
|
||||
*/
|
||||
export async function raeumeVerwaisteZellen(now) {
|
||||
const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen')
|
||||
const nachSperre = new Map()
|
||||
for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) {
|
||||
const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId)
|
||||
nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle])
|
||||
}
|
||||
for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id)
|
||||
return verwaist
|
||||
let geloescht = 0
|
||||
let fehler = null
|
||||
for (const [schluessel, liste] of nachSperre) {
|
||||
for (let i = 0; i < liste.length; i += RAEUM_BLOCK) {
|
||||
const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId]))
|
||||
try {
|
||||
geloescht += await withLocks(
|
||||
[schluessel],
|
||||
async () => {
|
||||
const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId)
|
||||
return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id))
|
||||
},
|
||||
{ waitMs: AUFRAEUM_WARTE_MS }
|
||||
)
|
||||
} catch (err) {
|
||||
fehler ??= err
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fehler) throw fehler
|
||||
return geloescht
|
||||
}
|
||||
Reference in new issue
Block a user