Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4def5c735d
commit
e281db1b2b
5 files changed
+1315
-1
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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() : '')
|
||||
|
||||
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('$')))
|
||||
|
||||
/**
|
||||
* 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() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = jetztMs(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 = jetztMs(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) {
|
||||
jetztMs(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 = jetztMs(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 = jetztMs(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')
|
||||
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
|
||||
}
|
||||
@@ -59,7 +59,8 @@ function feiertag(tag, land) {
|
||||
|
||||
// ---------------------------------------------------------------- Belegung
|
||||
|
||||
function istBelegend(buchung, jetztMs) {
|
||||
/** Belegt die Buchung zum Zeitpunkt jetztMs? gehalten nur solange heldUntil > jetzt, angefragt und bestaetigt immer. */
|
||||
export function istBelegend(buchung, jetztMs) {
|
||||
if (buchung.status === 'angefragt' || buchung.status === 'bestaetigt') return true
|
||||
if (buchung.status === 'gehalten') return zeitMs(buchung.heldUntil) > jetztMs // abgelaufen oder ohne Haltezeit = frei
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
/**
|
||||
* Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8).
|
||||
*
|
||||
* - Bestaetigungstoken: 32 Zufallsbytes (base64url), gespeichert wird nur der SHA-256-Hex (verifyHash).
|
||||
* - Verwaltungstoken: base64url(HMAC-SHA256(Schluessel, bookingId)); jederzeit neu berechenbar, damit spaetere Mails
|
||||
* (Erinnerung) denselben Link tragen, gespeichert wird nur sein Hash (manageHash). Wie lange er gilt (Terminende
|
||||
* + 1 Tag), entscheidet der Aufrufer anhand der Buchung.
|
||||
* - Buchungsnummer: 8 Zeichen A-Z0-9, nur aus der Buchungs-ID berechnet (kein eigenes Feld); steht in der Bestaetigung
|
||||
* und gehoert beim Widerruf ohne Link zusammen mit der E-Mail-Adresse.
|
||||
* - Formular-Token: "<Ausstellzeit base36>.<HMAC>" je siteKey, beweist nur Alter und Herkunft (kein Geheimnis fuer
|
||||
* den Besucher). Zu schnell abgeschickt (config.termine.formMinSekunden) = Bot, aelter als 2 Stunden = neu laden.
|
||||
*
|
||||
* Die HMAC-Schluessel werden je Zweck aus config.sessionSecret abgeleitet (Beschriftung 'termine-manage' bzw.
|
||||
* 'termine-form'), damit ein Token nie fuer einen anderen Zweck gilt. config wird bei jedem Aufruf gelesen.
|
||||
*/
|
||||
|
||||
const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000
|
||||
const FORM_TOKEN_MAX_LAENGE = 100
|
||||
const MAC_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
const ZEIT_RE = /^[0-9a-z]{1,13}$/
|
||||
|
||||
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
/** Schluessel fuer genau einen Zweck; ohne (ausreichend langes) Sitzungsgeheimnis gibt es keine Tokens. */
|
||||
function schluessel(zweck) {
|
||||
const geheimnis = config.sessionSecret
|
||||
if (typeof geheimnis !== 'string' || geheimnis.length < 32) {
|
||||
throw new Error('Terminplaner-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).')
|
||||
}
|
||||
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')
|
||||
return { token, hash: hashToken(token) }
|
||||
}
|
||||
|
||||
/** SHA-256-Hex eines Tokens. Fremde Eingaben (kein Text) ergeben den Hash des leeren Textes und treffen nichts. */
|
||||
export function hashToken(token) {
|
||||
return sha256Hex(typeof token === 'string' ? token : '')
|
||||
}
|
||||
|
||||
/** Verwaltungstoken einer Buchung (stabil; bei geaendertem Sitzungsgeheimnis ein anderer Wert). */
|
||||
export function verwaltungsToken(bookingId) {
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
return createHmac('sha256', schluessel('termine-manage')).update(bookingId).digest('base64url')
|
||||
}
|
||||
|
||||
/** Buchungsnummer aus der Buchungs-ID: 8 Zeichen A-Z0-9 (die ersten 64 Bit des SHA-256 zur Basis 36). */
|
||||
export function buchungsNummer(bookingId) {
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
const zahl = createHash('sha256').update(`termine-nummer|${bookingId}`).digest().readBigUInt64BE(0)
|
||||
return (zahl % 36n ** 8n).toString(36).toUpperCase().padStart(8, '0')
|
||||
}
|
||||
|
||||
function formMac(siteKey, zeit) {
|
||||
return createHmac('sha256', schluessel('termine-form')).update(`${siteKey}|${zeit}`).digest('base64url')
|
||||
}
|
||||
|
||||
/** 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)
|
||||
return `${zeit}.${formMac(siteKey, zeit)}`
|
||||
}
|
||||
|
||||
/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */
|
||||
export function pruefeFormToken(siteKey, token, now = new Date()) {
|
||||
if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig'
|
||||
if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig'
|
||||
const teile = token.split('.')
|
||||
if (teile.length !== 2) return 'ungueltig'
|
||||
const [zeit, mac] = teile
|
||||
if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig'
|
||||
const erwartet = Buffer.from(formMac(siteKey, zeit))
|
||||
const gegeben = Buffer.from(mac)
|
||||
if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig'
|
||||
|
||||
const alter = zeitMs(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,216 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { istBelegend } from './slots.js'
|
||||
|
||||
/**
|
||||
* Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5).
|
||||
*
|
||||
* Jede 5-Minuten-Zelle (config.termine.zelleMin) von Beginn bis Blockende ist ein termineClaims-Dokument mit
|
||||
* deterministischer ID (c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)). Alle neuen Zellen einer Belegung gehen
|
||||
* in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID
|
||||
* ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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
|
||||
* Zeit zwischen Zellen und Buchung wegraeumen. Scheitert belegeZellen (SLOT_VERGEBEN), raeumt der Aufrufer die
|
||||
* Buchung selbst wieder auf. Beim Verschieben ist die Buchung ohnehin da.
|
||||
*/
|
||||
|
||||
/** Hoechstens so viele Zellen in einem Bulk-Create (Appwrite: 100 Dokumente je Anfrage). */
|
||||
const MAX_ZELLEN = 100
|
||||
const TAG_MS = 24 * 60 * 60 * 1000
|
||||
const SPERRE_WARTE_MS = 5000
|
||||
/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */
|
||||
const ID_BLOCK = 50
|
||||
const LOESCH_PARALLEL = 10
|
||||
/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */
|
||||
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 slotVergeben = () =>
|
||||
Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), {
|
||||
status: 409,
|
||||
code: 'SLOT_VERGEBEN',
|
||||
})
|
||||
|
||||
/**
|
||||
* Alle Zellen von start bis blockEnde (ausgeschlossen) als Date, ausgerichtet am Raster seit 1970 (die Berliner
|
||||
* Zeitzone verschiebt um ganze Stunden, daher stimmt das Raster mit der Wanduhr ueberein). Krumme Zeiten werden zu
|
||||
* ganzen Zellen erweitert. Wirft bei ungueltigen Zeiten, Ende <= Beginn und mehr als 100 Zellen.
|
||||
*/
|
||||
export function zellenFuer(start, blockEnde) {
|
||||
const von = zeitMs(start)
|
||||
const bis = zeitMs(blockEnde)
|
||||
if (!Number.isFinite(von) || !Number.isFinite(bis) || bis <= von) throw new RangeError('zellenFuer: Beginn und Ende ungültig')
|
||||
const zelle = config.termine.zelleMin * 60000
|
||||
const erste = Math.floor(von / zelle)
|
||||
const letzte = Math.ceil(bis / zelle)
|
||||
if (letzte - erste > MAX_ZELLEN) throw new RangeError(`zellenFuer: mehr als ${MAX_ZELLEN} Zellen`)
|
||||
const zellen = []
|
||||
for (let i = erste; i < letzte; i += 1) zellen.push(new Date(i * zelle))
|
||||
return zellen
|
||||
}
|
||||
|
||||
/** Dokument-ID einer Zelle: c_ + 34 Hex (36 Zeichen, das Maximum fuer Appwrite-IDs). */
|
||||
export function zellenId(featureId, ressourceId, zelle) {
|
||||
const iso = new Date(zeitMs(zelle)).toISOString()
|
||||
return `c_${createHash('sha1').update(`${featureId}|${ressourceId}|${iso}`).digest('hex').slice(0, 34)}`
|
||||
}
|
||||
|
||||
/** Dokumente einer Collection nach ihren IDs (in Bloecken, damit die Abfrage kurz bleibt; fehlende fehlen im Ergebnis). */
|
||||
async function leseNachIds(collection, ids) {
|
||||
const gefunden = []
|
||||
for (let i = 0; i < ids.length; i += ID_BLOCK) {
|
||||
gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))])))
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
/** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */
|
||||
async function ungueltigeZellen(zellen, nowMs) {
|
||||
const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))]
|
||||
const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung]))
|
||||
return zellen.filter((zelle) => {
|
||||
const buchung = vorhanden.get(zelle.bookingId)
|
||||
return !buchung || !istBelegend(buchung, nowMs)
|
||||
})
|
||||
}
|
||||
|
||||
/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */
|
||||
async function loesche(collection, ids) {
|
||||
let geloescht = 0
|
||||
let fehler = null
|
||||
for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) {
|
||||
const ergebnisse = await Promise.allSettled(ids.slice(i, i + LOESCH_PARALLEL).map((id) => deleteDocument(collection, id)))
|
||||
for (const ergebnis of ergebnisse) {
|
||||
if (ergebnis.status === 'fulfilled') geloescht += 1
|
||||
else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason
|
||||
}
|
||||
}
|
||||
if (fehler) throw fehler
|
||||
return geloescht
|
||||
}
|
||||
|
||||
/** Ein Bulk-Create; false bei 409 (eine Zelle ist vergeben, nichts geschrieben), andere Fehler gehen durch. */
|
||||
async function legeZellenAn(dokumente) {
|
||||
try {
|
||||
await createDocuments(claims(), dokumente)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err?.status === 409) return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem 409: die blockierenden Zellen lesen und die ungueltigen loeschen. true = ein zweiter Versuch lohnt
|
||||
* (ungueltige geloescht oder die Blockierer sind inzwischen weg), false = alle Blockierer gelten.
|
||||
*/
|
||||
async function raeumeBlockierer(ids, nowMs) {
|
||||
const blockierer = await leseNachIds(claims(), ids)
|
||||
if (blockierer.length === 0) return true
|
||||
const ungueltig = await ungueltigeZellen(blockierer, nowMs)
|
||||
if (ungueltig.length === 0) return false
|
||||
await loesche(claims(), ungueltig.map((zelle) => zelle.$id))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Belegt die Zellen von start bis blockEnde fuer bookingId auf einer Ressource oder wirft { status: 409, code:
|
||||
* 'SLOT_VERGEBEN' } (dann ist nichts geschrieben). Unter der Sperre termine-res:<featureId>:<ressourceId> (hoechstens
|
||||
* 5 s Wartezeit; die Datenbank bleibt die eigentliche Sicherung).
|
||||
*
|
||||
* ausser = ID einer Buchung, deren Zellen als eigene zaehlen (Verschieben): angelegt wird nur, was von ihr noch fehlt;
|
||||
* ihre alten Zellen bleiben, bis der Aufrufer gibZellenFrei(id, { behalte }) aufruft. Bei einem Fehler aendert sich an
|
||||
* ihnen nichts.
|
||||
*/
|
||||
export async function belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null } = {}) {
|
||||
pruefeText(featureId, 'featureId')
|
||||
pruefeText(ressourceId, 'ressourceId')
|
||||
pruefeText(bookingId, 'bookingId')
|
||||
if (ausser !== null) pruefeText(ausser, 'ausser')
|
||||
const nowMs = pruefeJetzt(now, 'belegeZellen')
|
||||
const zellen = zellenFuer(start, blockEnde)
|
||||
const bis = new Date(zeitMs(blockEnde)).toISOString()
|
||||
const dokumente = zellen.map((zelle) => ({
|
||||
$id: zellenId(featureId, ressourceId, zelle),
|
||||
featureId,
|
||||
ressourceId,
|
||||
bookingId,
|
||||
zelle: zelle.toISOString(),
|
||||
bis,
|
||||
}))
|
||||
|
||||
await withLocks(
|
||||
[`termine-res:${featureId}:${ressourceId}`],
|
||||
async () => {
|
||||
let neu = dokumente
|
||||
if (ausser !== null) {
|
||||
const eigene = new Set((await listAllDocuments(claims(), [Query.equal('bookingId', ausser)])).map((zelle) => zelle.$id))
|
||||
neu = dokumente.filter((dokument) => !eigene.has(dokument.$id))
|
||||
}
|
||||
if (neu.length === 0) return
|
||||
if (await legeZellenAn(neu)) return
|
||||
if (!(await raeumeBlockierer(neu.map((dokument) => dokument.$id), nowMs))) throw slotVergeben()
|
||||
if (!(await legeZellenAn(neu))) throw slotVergeben()
|
||||
},
|
||||
{ waitMs: SPERRE_WARTE_MS }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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)
|
||||
}
|
||||
for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id)
|
||||
return verwaist
|
||||
}
|
||||
Reference in new issue
Block a user