Files
Webklar-Kundenbereich/server/services/termine/jobs.js
T
ClaudeandClaude Sonnet 5.5 978722023f Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel
raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:44:47 +00:00

643 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import {
abgelaufeneHaltungen,
aendere,
erinnerungsKandidaten,
ersteBuchungen,
hole,
loesche,
loeschBezugsfeld,
loeschKandidaten,
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
/**
* Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js
* ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden
* fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in
* der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail)
* taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen
*
* Gemeinsame Regeln:
* - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung).
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
* - Jede Zustandsaenderung einer Buchung laeuft unter `termine-buchung:<id>` mit frischem Lesen und erneuter Pruefung der
* Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job
* ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange.
* - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre).
* - Loeschlaeufe (Aufbewahrung) arbeiten seitenweise (arbeiteSeiten): eine Seite hoechstens 100 Buchungen ohne Gastfelder,
* Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf (erledigt
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt;
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
* Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens
* config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`.
*/
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
const AUFBEWAHRUNG_MAX = 365
const laufend = new Set()
const ueberlappGemeldet = new Set()
/** Zeitpunkt (ms) der letzten Warnung wegen fehlender Collection je Job. */
const letzteWarnung = new Map()
const fehlendeCollection = (err) => err?.status === 404 && (err.type === 'collection_not_found' || err.type === 'database_not_found')
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** Fehler eines Schritts ins Log (ohne Gastdaten) und in die Zaehlung; fehlende Collection hoechstens einmal je Stunde und Job. */
function melde(lauf, was, err, bookingId = null) {
lauf.fehler += 1
if (fehlendeCollection(err)) {
const zuletzt = letzteWarnung.get(lauf.job)
if (zuletzt !== undefined && lauf.nowMs - zuletzt < STUNDE_MS) return
letzteWarnung.set(lauf.job, lauf.nowMs)
console.warn(`[termine-jobs] ${lauf.job}, Schritt „${was}“: ${err.type}, die Termine-Einrichtung ist unvollständig (Warnung höchstens einmal je Stunde)`)
return
}
const nummer = bookingId ? ` (${buchungsNummer(bookingId)})` : ''
console.error(`[termine-jobs] ${lauf.job}: ${was}${nummer}: ${fehlerText(err)}`)
}
/** Fuehrt einen Schritt aus; ein Fehler wird gemeldet und ergibt `ersatz`. */
async function schritt(lauf, was, fn, ersatz) {
try {
return await fn()
} catch (err) {
melde(lauf, was, err)
return ersatz
}
}
/**
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
*/
async function fuehreAus(job, now, arbeit) {
const nowMs = pruefeNow(now, job)
if (termineAus()) return { aus: true }
if (laufend.has(job)) {
if (!ueberlappGemeldet.has(job)) {
ueberlappGemeldet.add(job)
console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(job)
try {
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0 })
} finally {
laufend.delete(job)
ueberlappGemeldet.delete(job)
}
}
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
/**
* Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt.
* => Ergebnis von fn | false (Buchung fehlt) | undefined (Fehler, im Log; die Liste laeuft weiter)
*/
async function mitFrischer(lauf, was, id, fn) {
try {
return await withLocks([`termine-buchung:${id}`], async () => {
const frisch = await hole(id)
return frisch ? fn(frisch) : false
})
} catch (err) {
melde(lauf, was, err, id)
return undefined
}
}
/**
* Seitenweise Arbeit ohne Cursor: Seite lesen (holeSeite), jeden Eintrag bearbeiten (bearbeite), Liste neu lesen – was bearbeitet
* ist, fehlt dann. Gedacht fuer Loeschlaeufe (Aufbewahrung, abgeschaltete Funktionen, Zellen), die wegen der Zahl der Betriebe nie
* alles auf einmal lesen duerfen. Der Lauf endet
* - bei einer leeren Seite (alles erledigt),
* - am Deckel: sind `deckel` Eintraege geloescht und es kommt noch einer, zaehlt lauf.gedeckelt, eine Warnung ohne Kennungen
* steht im Log und der Rest folgt im naechsten Lauf (`deckel` 0 = nur nachsehen, ob noch etwas ansteht: eine leere Seite endet
* ohne Zaehlung, sonst zaehlt es als Deckel); `deckelText` benennt den Deckel in der Warnung,
* - bei einer Seite ohne einen bearbeiteten Eintrag (alle gescheitert oder schon versucht): die Fehler stehen im Log; eine Menge
* merkt die versuchten IDs, damit ein Eintrag, der sich nicht bearbeiten laesst, keinen Endlosdurchlauf ausloest.
* holeSeite() => Eintraege mit $id (hoechstens eine Seite); bearbeite(eintrag) => true (geloescht, zaehlt zum Deckel) | false (nichts
* zu tun, z. B. inzwischen anders oder weg: kein Fehler) | undefined (Fehler, schon gemeldet). Wirft holeSeite, steht der Fehler im
* Log und der Lauf endet unvollstaendig.
* => { geloescht, gedeckelt (am Deckel beendet), vollstaendig (leere Seite erreicht und kein Fehler in diesem Lauf) }
*/
async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung', deckelText = `von ${deckel} je Lauf` }) {
if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`)
const versucht = new Set()
let geloescht = 0
let fehlerBeiEintrag = false
for (;;) {
let seite
try {
seite = await holeSeite()
} catch (err) {
melde(lauf, `${was}: Liste nicht gelesen`, err)
return { geloescht, gedeckelt: false, vollstaendig: false }
}
if (seite.length === 0) return { geloescht, gedeckelt: false, vollstaendig: !fehlerBeiEintrag }
let bearbeitet = 0
for (const eintrag of seite) {
if (versucht.has(eintrag.$id)) continue
if (geloescht >= deckel) {
lauf.gedeckelt += 1
console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel ${deckelText}, der Rest folgt an den nächsten Tagen`)
return { geloescht, gedeckelt: true, vollstaendig: false }
}
versucht.add(eintrag.$id)
let ergebnis
try {
ergebnis = await bearbeite(eintrag)
} catch (err) {
melde(lauf, was, err)
ergebnis = undefined
}
if (ergebnis === undefined) {
fehlerBeiEintrag = true
continue
}
bearbeitet += 1
if (ergebnis) geloescht += 1
}
if (bearbeitet === 0) return { geloescht, gedeckelt: false, vollstaendig: false }
}
}
// ---------------------------------------------------------------- Minutenjob
/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */
async function verfalleHaltungen(lauf, now) {
let verfallen = 0
for (const kandidat of await abgelaufeneHaltungen(now)) {
const ok = await mitFrischer(lauf, 'Haltung nicht verfallen', kandidat.$id, async (frisch) => {
if (frisch.status !== 'gehalten' || !(zeitMs(frisch.heldUntil) <= lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'verfallen', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) verfallen += 1
}
return verfallen
}
/**
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden.
* @param {Date} now
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
*/
export async function minutenJob(now) {
return fuehreAus('minutenJob', now, async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
})
}
// ---------------------------------------------------------------- Fuenf-Minuten-Job
/**
* Ist die Erinnerung jetzt zu senden? bestaetigt, Wunsch, kein Marker, Adresse da, Beginn in (jetzt + 1 h, jetzt + stundenVorher]
* und Vertragsschluss VOR Beginn - stundenVorher (wer erst danach gebucht hat, bekommt keine). Ohne Vertragsschluss nein.
*/
function erinnerungFaellig(buchung, stundenVorher, nowMs) {
if (buchung.status !== 'bestaetigt' || buchung.erinnerung !== true || buchung.erinnerungGesendetAt) return false
if (typeof buchung.gastEmail !== 'string' || buchung.gastEmail === '') return false
const start = zeitMs(buchung.startsAt)
const fruehestens = start - stundenVorher * STUNDE_MS
if (!(start > nowMs + STUNDE_MS && fruehestens <= nowMs)) return false
return vertragAm(buchung) < fruehestens
}
/**
* Erinnerungen: unter der Buchungssperre frisch lesen und pruefen, erst den Marker erinnerungGesendetAt setzen, dann (ausserhalb
* der Sperre) G05 einreihen. Scheitert das Einreihen, bleibt der Marker: eine Erinnerung geht hoechstens einmal raus.
* Der Kontext einer Funktion (Einstellung mit stundenVorher) wird je Lauf einmal gelesen. Ohne Funktion oder Einstellung: keine
* Erinnerung, kein Marker.
*/
async function sendeErinnerungen(lauf, now) {
const kontexte = new Map()
const kontext = (featureId) => {
if (!kontexte.has(featureId)) {
kontexte.set(
featureId,
kontextPerFeature(featureId).catch((err) => {
melde(lauf, 'Funktion nicht gelesen', err)
return null
}),
)
}
return kontexte.get(featureId)
}
let gesendet = 0
for (const kandidat of await erinnerungsKandidaten(now)) {
const id = kandidat.$id
const ctx = await kontext(kandidat.featureId)
const stundenVorher = ctx?.einstellung?.erinnerung?.stundenVorher
if (!Number.isInteger(stundenVorher) || stundenVorher < 1) continue
if (!erinnerungFaellig(kandidat, stundenVorher, lauf.nowMs)) continue
const markiert = await mitFrischer(lauf, 'Erinnerung nicht vermerkt', id, async (frisch) =>
erinnerungFaellig(frisch, stundenVorher, lauf.nowMs) ? aendere(id, { erinnerungGesendetAt: iso(now), updatedAt: iso(now) }) : false,
)
if (!markiert) continue
try {
await anGast('G05', ctx, markiert, {}, now)
gesendet += 1
} catch (err) {
melde(lauf, 'G05 nicht eingereiht', err, id)
}
}
return gesendet
}
/** bestaetigt mit Ende vor mehr als 1 Stunde (auch Sperren) => erledigt. Zellen bleiben (vergangen, der Tagesjob raeumt sie). */
async function erledige(lauf, now) {
let erledigt = 0
for (const kandidat of await zuErledigen(now)) {
const ok = await mitFrischer(lauf, 'Termin nicht erledigt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'bestaetigt' || !(zeitMs(frisch.endsAt) < lauf.nowMs - STUNDE_MS)) return false
await aendere(frisch.$id, { status: 'erledigt', updatedAt: iso(now) })
return true
})
if (ok) erledigt += 1
}
return erledigt
}
/** angefragt mit Beginn in der Vergangenheit => abgelehnt vom System, Zellen frei, keine Mail. */
async function lehneVergangeneAnfragenAb(lauf, now) {
let abgelehnt = 0
for (const kandidat of await vergangeneAnfragen(now)) {
const ok = await mitFrischer(lauf, 'Anfrage nicht abgelehnt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'angefragt' || !(zeitMs(frisch.startsAt) < lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'abgelehnt', abgesagtVon: 'system', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) abgelehnt += 1
}
return abgelehnt
}
/**
* Alle 5 Minuten: Erinnerungen, erledigte Termine, vergangene Anfragen.
* @param {Date} now
* @returns {Promise<{ erinnerungen: number, erledigt: number, abgelehnt: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
*/
export async function fuenfMinutenJob(now) {
return fuehreAus('fuenfMinutenJob', now, async (lauf) => {
const erinnerungen = await schritt(lauf, 'Erinnerungen', () => sendeErinnerungen(lauf, now), 0)
const erledigt = await schritt(lauf, 'Erledigt', () => erledige(lauf, now), 0)
const abgelehnt = await schritt(lauf, 'Vergangene Anfragen', () => lehneVergangeneAnfragenAb(lauf, now), 0)
return { erinnerungen, erledigt, abgelehnt, fehler: lauf.fehler }
})
}
// ---------------------------------------------------------------- Tagesjob
/**
* Aufbewahrung (Tage nach Terminende) einer Funktion. Nicht gespeichert oder unlesbar (null aus ladeEinstellung) oder ausserhalb
* 14-365: 90. Scheitert das Lesen selbst (Datenbank), null: die Buchungen dieser Funktion bleiben bis zum naechsten Lauf, es wird
* nie frueher geloescht als eingestellt.
*/
async function aufbewahrungTage(lauf, featureId) {
if (typeof featureId !== 'string' || featureId === '') return AUFBEWAHRUNG_VORGABE
try {
const tage = (await ladeEinstellung(featureId))?.einstellung?.aufbewahrungTage
return Number.isInteger(tage) && tage >= AUFBEWAHRUNG_MIN && tage <= AUFBEWAHRUNG_MAX ? tage : AUFBEWAHRUNG_VORGABE
} catch (err) {
melde(lauf, 'Aufbewahrung nicht lesbar, Funktion bleibt unberührt', err)
return null
}
}
/**
* Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). Mit `frist`
* ({ feld, vor } in ms) muss ausserdem die frisch gelesene Bezugszeit noch vor `vor` liegen (zweiter Boden gegen eine Liste, die
* seit dem Lesen veraltet ist). => true | false | undefined (Fehler)
*/
function loescheMitZellen(lauf, id, status, frist = null) {
return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => {
if (status !== null && frisch.status !== status) return false
if (frist !== null && !(zeitMs(frisch[frist.feld]) < frist.vor)) return false
await gibZellenFrei(id)
await loesche(id)
return true
})
}
/**
* Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen in drei Durchgaengen, jeweils seitenweise (arbeiteSeiten):
* (1) verfallen (48 Stunden nach heldUntil), abgesagt und abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen,
* (2) erledigt und nichterschienen je Funktion (portalFeatures featureKey termine, active oder disabled; nur die IDs werden
* gelesen) nach deren aufbewahrungTage (Vorgabe 90). Ist die Einstellung einer Funktion nicht lesbar, bleibt sie in diesem
* Lauf unberuehrt (nie frueher loeschen als eingestellt),
* (3) Sicherheitsnetz: erledigt und nichterschienen ohne Funktionsfilter nach 365 Tagen, der groessten erlaubten Aufbewahrung.
* Es faengt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spaetestens dann (Buchungen ohne
* Funktionsdokument bleiben bis dahin, Waisen gibt es in Produktion nicht).
* Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal frisch geprueft (zweiter Boden).
* Deckel: ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen (erledigt und nichterschienen einer Funktion
* zusammen), alle Schritte des Laufs zusammen hoechstens config.termine.loeschDeckelGesamt. Jeder Schritt, der daran endet, zaehlt
* in `gedeckelt` (Warnung ohne Kennungen im Log); der Rest folgt im naechsten Lauf. Endet ein Schritt am Gesamtdeckel, ist der Lauf
* zu Ende: die uebrigen Schritte folgen am naechsten Tag. Ist der Gesamtdeckel genau erreicht, sieht jeder weitere Schritt nur nach,
* ob noch etwas ansteht (leere Seite = nicht gedeckelt).
* => Loeschzaehler je Status und `gedeckelt` = Zahl der Schritte, die am Deckel endeten
*/
async function loeschfristen(lauf, now) {
const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }
const deckelJeSchritt = config.termine.loeschDeckel
const deckelGesamt = config.termine.loeschDeckelGesamt
let gedeckelt = 0
let rest = deckelGesamt // was vom Gesamtdeckel noch uebrig ist
let amGesamtdeckel = false // ein Schritt endete am Gesamtdeckel: keine weiteren Schritte in diesem Lauf
/**
* Ein Loeschschritt fuer Buchungen eines Status mit Bezugszeit vor `vor` (ms), optional nur einer Funktion, unter beiden Deckeln:
* hoechstens `eigener`, hoechstens der Rest des Gesamtdeckels. => { geloescht, gedeckelt }
*/
const loescheSeiten = async (was, status, eigener, eigenerText, vor, featureId = null) => {
if (amGesamtdeckel) return { geloescht: 0, gedeckelt: false }
const amGesamt = rest <= eigener
const frist = { feld: loeschBezugsfeld(status), vor }
const erg = await schritt(
lauf,
was,
() =>
arbeiteSeiten(lauf, {
was,
deckel: amGesamt ? rest : eigener,
deckelText: amGesamt ? `von ${deckelGesamt} für alle Schritte des Laufs zusammen (Gesamtdeckel)` : eigenerText,
holeSeite: () => loeschKandidaten(status, new Date(vor), featureId === null ? {} : { featureId }),
bearbeite: async (kandidat) => {
const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist)
if (weg) geloescht[status] += 1
return weg
},
}),
{ geloescht: 0, gedeckelt: false },
)
rest -= erg.geloescht
if (erg.gedeckelt) {
gedeckelt += 1
if (amGesamt) amGesamtdeckel = true
}
return erg
}
// Durchgang (1): verfallen, abgesagt, abgelehnt
for (const [status, behalten] of [
['verfallen', VERFALLEN_BEHALTEN_MS],
['abgesagt', ABGESAGT_BEHALTEN_MS],
['abgelehnt', ABGESAGT_BEHALTEN_MS],
]) {
await loescheSeiten(`Löschfrist ${status}`, status, deckelJeSchritt, undefined, lauf.nowMs - behalten)
}
// Durchgang (2): erledigt/nichterschienen je Funktion nach deren Aufbewahrung, die beiden Status teilen sich den Deckel der Funktion
const funktionen = amGesamtdeckel
? []
: await schritt(
lauf,
'Löschfrist: Funktionen nicht gelesen',
() => listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', ['active', 'disabled']), Query.select(['$id'])]),
[],
)
for (const { $id: featureId } of funktionen) {
if (amGesamtdeckel) break
const tage = await aufbewahrungTage(lauf, featureId)
if (tage === null) continue
const vor = lauf.nowMs - tage * TAG_MS
let geloeschtJeFunktion = 0
for (const status of ['erledigt', 'nichterschienen']) {
const erg = await loescheSeiten(`Löschfrist ${status} (je Funktion)`, status, deckelJeSchritt - geloeschtJeFunktion, `von ${deckelJeSchritt} je Funktion`, vor, featureId)
geloeschtJeFunktion += erg.geloescht
if (erg.gedeckelt) break // die Funktion endete am Deckel, einmal gezaehlt
}
}
// Durchgang (3): Sicherheitsnetz, die groesste erlaubte Aufbewahrung gilt fuer jede Buchung, auch ohne lesbare Funktion
for (const status of ['erledigt', 'nichterschienen']) {
await loescheSeiten(`Löschfrist ${status} (Sicherheitsnetz)`, status, deckelJeSchritt, undefined, lauf.nowMs - AUFBEWAHRUNG_MAX * TAG_MS)
}
return { ...geloescht, gedeckelt }
}
/** So viele Buchungen einer abgeschalteten Funktion hoechstens, bevor ihr Zustand erneut gelesen wird. */
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
}
return false
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie nicht mehr als abgeschaltet, endet die
* Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der Lauf mit Warnung (der Rest folgt am
* naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht, mit letzter Pruefung unter der Sperre
* termine-cfg:<id>; sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter.
* => { geloescht, gedeckelt, einstellungWeg }
*/
async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
let geprueftBei = 0 // Stand von `begonnen` bei der letzten Pruefung
const pruefe = async () => {
geprueftBei = begonnen
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) abgebrochen = true
return !abgebrochen
}
const seiten = await schritt(
lauf,
'Buchungen abgeschalteter Funktion',
() =>
arbeiteSeiten(lauf, {
was: 'Löschung abgeschalteter Funktion',
deckel,
deckelText: `von ${deckel} je Funktion`,
holeSeite: async () => {
if (abgebrochen) return []
const seite = await ersteBuchungen(funktionId)
// vor dem Loeschen der Seite frisch pruefen: das Personal kann die Funktion zwischen Lesen und Loeschen wieder einschalten
return seite.length > 0 && !(await pruefe()) ? [] : seite
},
bearbeite: async (buchung) => {
if (abgebrochen) return false
if (begonnen - geprueftBei >= ABGESCHALTET_NEU_PRUEFEN_ALLE && !(await pruefe())) return false
begonnen += 1
return loescheMitZellen(lauf, buchung.$id, null)
},
}),
{ geloescht: 0, gedeckelt: false, vollstaendig: false },
)
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktionId}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion). Endet eine Funktion am Deckel oder an einem Fehler,
* bleibt sie `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung), gedeckelt die Funktionen, die am
* Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 0 }
const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const weg = await loescheAbgeschalteteFunktion(lauf, funktion.$id, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1
}
return erg
}
/**
* Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende, spaetestens nach 365 Tagen
* (Sicherheitsnetz); jeweils samt Zellen, je Schritt hoechstens config.termine.loeschDeckel und je Lauf hoechstens
* config.termine.loeschDeckelGesamt (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Schritte, die am Deckel endeten
*/
export function wendeLoeschfristenAn(now) {
return loeschfristen(einzelLauf('loeschfristen', now), now)
}
/**
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen, seitenweise und je Funktion bis config.termine.
* loeschDeckel Buchungen je Lauf. Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ funktionen: number, buchungen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Funktionen, die am Deckel endeten
*/
export function loescheAbgeschalteteFunktionen(now) {
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
}
/**
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
* Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen.
* @param {Date} now
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
*/
export async function taeglicherJob(now) {
return fuehreAus('taeglicherJob', now, async (lauf) => {
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false })
if (zellenErgebnis.gedeckelt) {
console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`)
}
// gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 }
return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen: zellenErgebnis.geloescht, fehler: lauf.fehler }
})
}
/**
* Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs
* (Spur der unwiderruflichen Loeschungen). null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen).
* @param {object} ergebnis Rueckgabe von taeglicherJob
* @returns {string|null}
*/
export function tagesbericht(ergebnis) {
if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null
const l = ergebnis.loeschfristen
const a = ergebnis.abgeschaltet
const gesamt = Object.values(l).reduce((summe, n) => summe + n, 0) + a.buchungen
const amDeckel = Object.values(ergebnis.gedeckelt ?? {}).reduce((summe, n) => summe + n, 0)
const deckel = amDeckel > 0 ? `Löschläufe am Deckel: ${amDeckel}, Rest folgt an den nächsten Tagen, ` : ''
return (
`[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` +
`erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` +
`${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${ergebnis.fehler} Fehler`
)
}
/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */
export function resetJobsForTests() {
laufend.clear()
ueberlappGemeldet.clear()
letzteWarnung.clear()
}