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:` 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:; 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} */ 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() }