import { config } from '../../config.js' import { aboLage } from '../aboStatus.js' import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { stateFields } from '../siteFeatures.js' import { gibFreiSicher, iso, vertragAm } from './ablauf.js' import { gleicheAboAb, istErstattungOffen } from './aboAbgleich.js' import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js' import { abgelaufeneHaltungen, aendere, erinnerungsKandidaten, ersteBuchungen, hole, loesche, loeschBezugsfeld, loeschKandidaten, vergangeneAnfragen, zuErledigen, } from './buchungen.js' import { loescheEntwuerfe } from './entwuerfe.js' import { LOESCHFRIST_TAGE } from './frist.js' import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' import { shopModus } from '../shop/schalter.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 zwei Teile, gemeinsam gestartet (Promise.all) und je Teil gegen Ueberlappung geschuetzt: * Verfall: abgelaufene Haltungen => verfallen (Zellen frei); * Mailversand: Mail-Warteschlange senden, zuletzt die Betriebspruefung (Budget, Warteschlange, * Anmeldepause => Log und Team-Mail, betrieb.js) * 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), Abo-Abgleich (aboAbgleich.js: der Terminplaner endet mit dem Wartungs-Abo), * abgeschaltete Funktionen, Entwuerfe (entwuerfe.js: Terminplaner im Zustand off, 90 Tage nach der * letzten Aenderung; nur im vollen Lauf, Entwuerfe sind nie bezahlt), festgefahrene und alte Mails, * verwaiste Zellen; der Abgleich laeuft VOR * der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt, * nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler, * Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js) * Abgeschaltete Funktionen: nach LOESCHFRIST_TAGE (30) Tagen Buchungen, Zellen, Einstellung loeschen * (Grund abo_ende: vor jedem unumkehrbaren Schritt frisch pruefen, ob das Wartungs-Abo wieder laeuft), * danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt; mit offenem Zahlungsfall, * zahlungMeldung nicht leer und nicht 'gemeldet', bleibt es disabled bis zur Meldung, R108) * * Gemeinsame Regeln: * - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige * Frage (Guard: gibt es ein vom Kauf freigeschaltetes Termine-Dokument, unlockedByPurchase true?); ohne bezahltes Dokument oder bei * einem Lesefehler (Warnung) kehrt auch er mit { aus: true } zurueck. Mit bezahltem Dokument laeuft ein eingeschraenkter Tagesjob * (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei * TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen * bleiben aus. * - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb * laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und * Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles * andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit * `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung * abgeschalteter Funktionen nicht. * - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im * Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der * naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind * (sonst steht verfallUebersprungen bzw. mailUebersprungen im Ergebnis). Die Warnung nennt den Teil (`minutenJob (Verfall)`), das * Fehlerlog behaelt `minutenJob` als Jobnamen. * - 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. * Das Funktionsdokument wird danach auf `off` gesetzt und fehlt damit in der Liste des naechsten Tages (loescheAbgeschaltete). * 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 /** Frist zwischen dem Abschalten (disabledAt) und der Loeschung: eine Zahl fuer Anzeige, Mails, Rechtstexte und diesen Job (frist.js). */ const ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE * 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 } * `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der * Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection). * `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht * `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf * das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) * und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre * gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen. */ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) { const nowMs = pruefeNow(now, job) let eingeschraenkt = false let freigabe = null if (termineAus()) { if (trotzAus === null) return { aus: true } try { freigabe = await trotzAus() if (!freigabe) return { aus: true } } catch (err) { console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`) return { aus: true } } eingeschraenkt = true } const sperre = teil === null ? job : `${job}:${teil}` if (laufend.has(sperre)) { if (!ueberlappGemeldet.has(sperre)) { ueberlappGemeldet.add(sperre) console.warn(`[termine-jobs] ${teil === null ? job : `${job} (${teil})`}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`) } return { uebersprungen: true } } laufend.add(sperre) try { return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe }) } finally { laufend.delete(sperre) ueberlappGemeldet.delete(sperre) } } /** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */ const shopMailsLaufen = async () => shopModus() !== 'off' /** 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, in zwei unabhaengig geschuetzten Teilen, die gemeinsam starten (Promise.all): * - Verfall: abgelaufene Haltungen verfallen lassen; * - Mailversand: Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und gehen als T20 ans Team; die Pruefung * wirft nie und aendert das Ergebnis des Laufs nicht). * Haengt ein Teil, wird nur dieser im naechsten Takt uebersprungen (verfallUebersprungen bzw. mailUebersprungen im Ergebnis), der * andere laeuft weiter. { uebersprungen: true } kommt erst, wenn beide Teile uebersprungen werden. * @param {Date} now * @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number, verfallUebersprungen?: true, mailUebersprungen?: true } | { aus: true } | { uebersprungen: true }>} */ export async function minutenJob(now) { const [verfall, mail] = await Promise.all([ fuehreAus( 'minutenJob', now, async (lauf) => { const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0) return { verfallen, fehler: lauf.fehler } }, { teil: 'Verfall' }, ), fuehreAus( 'minutenJob', now, async (lauf) => { const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 }) // Zuletzt: die Pruefung sieht den Stand nach dem Versand (ein eben gesendeter Eintrag staut nichts mehr) await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), []) return { faellig, gesendet, fehler: lauf.fehler } }, { teil: 'Mailversand', trotzAus: shopMailsLaufen }, ), ]) if (verfall.aus && mail.aus) return { aus: true } // Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief if (verfall.aus) { if (mail.uebersprungen) return { uebersprungen: true } return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 } } if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true } return { verfallen: verfall.verfallen ?? 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: (verfall.fehler ?? 0) + (mail.fehler ?? 0), ...(verfall.uebersprungen ? { verfallUebersprungen: true } : {}), ...(mail.uebersprungen ? { mailUebersprungen: true } : {}), } } // ---------------------------------------------------------------- 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 Schritt noch als abgeschaltet? Frisch gelesen, weil das Personal * sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate leert disabledAt) und ein Wartungs-Abo zurueckkommen * kann: featureKey termine, state disabled und disabledAt vor der Grenze. Bei Grund `abo_ende` (nur dort haengt die Loeschung am Abo) * zusaetzlich frisch aboLage (nie der Memo): laeuft das Wartungs-Abo wieder, gilt sie nicht als abgeschaltet. Fuer `team`, * `kunde_geloescht`, `abo_ende_vor_einrichtung` und Altbestand ohne Grund gibt es keine Abo-Abfrage. mitAbo false: nichts * Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst null mit Log, ohne Wurf – auch bei einem * Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht. * => das frisch gelesene Funktionsdokument, wenn es noch als abgeschaltet gilt, sonst null */ async function leseAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) { let frisch try { frisch = await getDocument(config.collections.portalFeatures, funktionId) } 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 null } if (!(frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze)) { console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`) return null } if (!mitAbo || frisch.disabledReason !== 'abo_ende') return frisch try { if (!frisch.customerId) throw new Error('Funktion ohne Kunde') if (!(await aboLage(frisch.customerId)).laeuft) return frisch console.warn(`[termine-jobs] ${lauf.job}: Abo läuft wieder, Löschung abgebrochen`) } catch (err) { melde(lauf, 'Abo vor dem Löschen nicht gelesen', err) } return null } /** Wie leseAbgeschaltet, nur als Ja/Nein: gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? */ const nochAbgeschaltet = async (lauf, funktionId, grenze, optionen) => (await leseAbgeschaltet(lauf, funktionId, grenze, optionen)) !== null /** * Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:. Ist sie noch da, kommt vor * dem unumkehrbaren Loeschen die letzte Pruefung (nochAbgeschaltet, bei Grund abo_ende auch das frische Abo); ist sie schon weg (404), * ist nichts Unumkehrbares mehr offen und die Abo-Pruefung entfaellt. Ein Lesefehler wirft (der Aufrufer meldet ihn). * => 'geloescht' (in diesem Lauf) | 'weg' (war schon weg) | 'bleibt' (die Funktion gilt nicht mehr als abgeschaltet) */ function loescheEinstellung(lauf, funktionId, grenze) { const schonWeg = (err) => err?.status === 404 && !fehlendeCollection(err) return withLocks([`termine-cfg:${funktionId}`], async () => { try { await getDocument(config.collections.termineConfig, funktionId) } catch (err) { if (schonWeg(err)) return 'weg' throw err } if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return 'bleibt' try { await deleteDocument(config.collections.termineConfig, funktionId) return 'geloescht' } catch (err) { if (schonWeg(err)) return 'weg' throw err } }) } /** * Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren * feature-admin: (wie Aktivieren und Abschalten im WOMS) und termine-cfg: frisch lesen und nochmals pruefen * (leseAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es. * R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet', oder C2: bezahlte * Einrichtung mit Grund abo_ende_vor_einrichtung und T19 nicht gemeldet, auch bei leerer zahlungMeldung), wird NICHT * zurueckgesetzt (Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf): das Dokument bleibt disabled samt unlockedByPurchase, ticketId * und zahlungMeldung, der taegliche Abo-Abgleich (aboAbgleich, Nachholen der Erstattungsmeldung T19) bleibt zustaendig und ein * spaeterer Lauf setzt zurueck, sobald 'gemeldet' steht. Buchungen und Einstellung sind dann schon geloescht (bleibt so). * purchaseStatus 'zurueckgesetzt' ist die dauerhafte Marke "hier wurde nach dem Abo-Ende geloescht" (weder paid noch Meldezustand; * Block C6 loescht ein so markiertes Funktionsdokument nie, damit der siteKey besteht). siteKey sowie Bestell- und Rechnungsfelder * bleiben stehen bis zur naechsten Bestellung. * => true, wenn zurueckgesetzt */ function setzeFunktionZurueck(lauf, funktion, grenze) { return withLocks([`feature-admin:${funktion.projectId}`, `termine-cfg:${funktion.$id}`], async () => { const frisch = await leseAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false }) if (!frisch) return false // R108: ein offener Zahlungsfall (zahlungMeldung gesetzt, nicht 'gemeldet') bleibt sichtbar. Das Dokument bleibt disabled samt Markern // (unlockedByPurchase, ticketId, zahlungMeldung), damit der Abo-Abgleich die Meldung (T19) weiter nachholt; ein spaeterer Lauf setzt zurueck, // sobald 'gemeldet' steht. Ohne PII im Log, zaehlt in lauf.fehler (Tagesbericht), kein Wurf. // C2: dazu die bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist, auch // wenn zahlungMeldung noch leer ist (T19 scheiterte seit dem Abschalten): das Zuruecksetzen wuerde unlockedByPurchase, ticketId und // zahlungMeldung loeschen, und mit ihnen die Spur der geschuldeten Erstattung. Der Abo-Abgleich (Schritt 2b) meldet T19 weiter. if ((frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') || istErstattungOffen(frisch)) { lauf.fehler += 1 console.warn(`[termine-jobs] ${lauf.job}: eine Funktion hat einen offenen Zahlungsfall, sie wird nicht zurückgesetzt (der Abo-Abgleich meldet ihn weiter)`) return false } await updateDocument(config.collections.portalFeatures, funktion.$id, { ...stateFields('off'), unlockedByPurchase: false, purchaseStatus: 'zurueckgesetzt', zahlungMeldung: '', ticketId: '', activatedAt: null, disabledAt: null, disabledReason: null, aboEndeMailAt: null, adminNote: 'Daten gelöscht (Löschfrist abgelaufen)', }) return true }) } /** * 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, bei Grund abo_ende samt frischem Abo) 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 * (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:); sind alle Buchungen weg und die Einstellung geloescht * oder schon nicht mehr da, setzt der Lauf das Funktionsdokument auf `off` zurueck (setzeFunktionZurueck, fuer jeden Grund, ohne * Abo-Pruefung). Sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter; scheitert nur das * Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr); bei einem offenen * Zahlungsfall (R108) bleibt das Dokument disabled, bis zahlungMeldung 'gemeldet' ist. * => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht) */ async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) { const funktionId = funktion.$id 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) { const stand = await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => loescheEinstellung(lauf, funktionId, grenze), 'bleibt') einstellungWeg = stand === 'geloescht' if (stand !== 'bleibt') { await schritt( lauf, 'Funktion zurücksetzen', async () => { if (!funktion.projectId) throw new Error('Funktion ohne Projekt') // ohne Projekt gibt es die Sperre feature-admin: nicht: im Zweifel nichts schreiben return setzeFunktionZurueck(lauf, funktion, grenze) }, false, ) } } return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg } } /** * Funktionen im Zustand `disabled` seit mehr als LOESCHFRIST_TAGE (30) Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die * Einstellung loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion), danach das Funktionsdokument auf `off` * zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs; bei einem offenen Zahlungsfall erst nach der Meldung, R108). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch * geprueft, ob das Wartungs-Abo wieder laeuft (dann bleibt alles). 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; ein reines Zuruecksetzen zaehlt nicht), * 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, 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)) } /** Summe aller Loeschzaehler eines Tagesjobs: Buchungen aus den Loeschfristen und aus abgeschalteten Funktionen (Zellen, Outbox und Mails zaehlen nicht). */ const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loeschfristen).reduce((summe, n) => summe + n, 0) + abgeschaltet.buchungen /** Zaehler des Abo-Abgleichs, wenn der Schritt selbst scheitert (gleicheAboAb wirft nie; der Fehler steht dann schon in lauf.fehler). */ const aboErsatz = () => ({ geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }) /** Zaehler der Entwurfs-Loeschung, wenn der Schritt selbst scheitert (loescheEntwuerfe wirft nur bei ungueltiger Uhrzeit). */ const entwuerfeErsatz = () => ({ geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 }) /** * Entwuerfe (entwuerfe.js) als Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht. Die Einzelfehler der Loeschung (schon im Log) * kommen in lauf.fehler, damit der Tagesbericht und die Meldung ans Team sie nennen. => Zaehler { geprueft, geloescht, uebrig, fehler } */ async function entwuerfeSchritt(lauf, now) { const entwuerfe = await schritt(lauf, 'Entwürfe', () => loescheEntwuerfe(now), entwuerfeErsatz()) lauf.fehler += entwuerfe.fehler return entwuerfe } /** * Guard fuer den Modus off (fuehreAus: trotzAus): gibt es ein vom Kauf freigeschaltetes Termine-Dokument? Eine einzige Abfrage * (limit 1) ohne Kunden- oder Gastdaten; ein Lesefehler wirft und haelt den Tagesjob aus. */ async function gibtBezahlteTerminplaner() { const treffer = await listDocuments(config.collections.portalFeatures, [ Query.equal('featureKey', 'termine'), Query.equal('unlockedByPurchase', true), Query.limit(1), ]) return treffer.length > 0 } /** * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen). * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher. */ async function tagesjobFreigabe() { const mails = shopModus() !== 'off' let termine = false try { termine = await gibtBezahlteTerminplaner() } catch (err) { if (!mails) throw err console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`) } return termine || mails ? { termine, mails } : false } /** * Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des * Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs */ async function aboAbgleichSchritt(lauf, now) { const abgleich = await schritt(lauf, 'Abo-Abgleich', () => gleicheAboAb(now), aboErsatz()) lauf.fehler += abgleich.fehler return abgleich } /** * Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben), * Loeschfristen, Abo-Abgleich (der Terminplaner endet mit dem Wartungs-Abo; VOR der Loeschung, damit ein Betrieb, dessen Abo heute * zurueckkehrt, nicht mehr geloescht wird), abgeschaltete Funktionen, Entwuerfe (entwuerfe.js; die Einzelfehler zaehlen in `fehler`), * alte Warteschlangen-Eintraege, verwaiste Zellen; zuletzt die * aktiven Betriebe zaehlen (ein nicht lesbarer Zaehler ergibt null und einen Fehler) und den Lauf ans Team melden (meldeTagesjob: * Fehler, Loeschdeckel an 3 Tagen in Folge, ab 20 Betrieben; wirft nie). Bricht der Job unerwartet ab, erfaehrt das Team es ebenfalls, * und der Fehler geht unveraendert an den Aufrufer. * `geloescht` = Summe aller Loeschzaehler (Buchungen aus Loeschfristen und abgeschalteten Funktionen), `aktiveBetriebe` = Zahl oder null, * `aboAbgleich` = Zaehler des Abgleichs { geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }, * `entwuerfe` = Zaehler der Entwurfs-Loeschung { geprueft, geloescht, uebrig, fehler } (geloescht zaehlt Entwuerfe, nicht Buchungen). * Modus off: ohne vom Kauf freigeschaltetes Termine-Dokument { aus: true } nach genau einer Abfrage (Guard). Mit einem bezahlten * Dokument laeuft der eingeschraenkte Tagesjob nur mit Abo-Abgleich und Loeschung abgeschalteter Funktionen (Loeschfristen der * Buchungen, Entwuerfe, Warteschlange und Zellen bleiben aus; ob die uebrigen Schritte dort auch laufen sollen, ist offen). Sein Ergebnis traegt * dieselben Felder, die meldeTagesjob und die Statusroute lesen: { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt * { loeschfristen: 0, abgeschaltet, zellen: 0 }, geloescht, aktiveBetriebe: null, fehler }. Beide Laeufe teilen die Ueberlappungssperre. * @param {Date} now * @returns {Promise} */ export async function taeglicherJob(now) { let aktuellerLauf = null // gesetzt, sobald der Rahmen den Job startet (fuer die Meldung eines Abbruchs) try { return await fuehreAus( 'taeglicherJob', now, async (lauf) => { aktuellerLauf = lauf if (lauf.eingeschraenkt) return eingeschraenkterTagesjob(lauf, now) const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0) const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now) const aboAbgleich = await aboAbgleichSchritt(lauf, now) const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0, }) const entwuerfe = await entwuerfeSchritt(lauf, now) 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`) } const aktiveBetriebe = await schritt(lauf, 'Aktive Betriebe', () => zaehleAktiveBetriebe(), null) // 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 } const ergebnis = { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aboAbgleich, entwuerfe, aufgeraeumt, zellen: zellenErgebnis.geloescht, geloescht: buchungenGeloescht(loeschung, abgeschaltet), aktiveBetriebe, fehler: lauf.fehler, } // Zuletzt: die Meldung sieht den ganzen Lauf; sie wirft nie, ein Fehler dort stuende nur im Log await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) return ergebnis }, { trotzAus: tagesjobFreigabe }, ) } catch (err) { // Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter if (aktuellerLauf) await schritt(aktuellerLauf, 'Tagesjob-Meldung', () => meldeTagesjob({ abbruch: true, fehler: aktuellerLauf.fehler + 1 }, now), []) throw err } } /** * Der eingeschraenkte Tagesjob (Modus off, es gibt bezahlte Terminplaner): erst der Abo-Abgleich, dann die Loeschung abgeschalteter * Funktionen (gleiche Reihenfolge wie im vollen Lauf). Mehr nicht: keine Loeschfristen der Buchungen, keine Warteschlange, keine * Zellen, keine Zaehlung der aktiven Betriebe (`aktiveBetriebe: null`). Das Ergebnis traegt auch die Felder, die meldeTagesjob liest, * und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team). */ async function eingeschraenkterTagesjob(lauf, now) { // Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile const termine = lauf.freigabe?.termine !== false const mails = lauf.freigabe?.mails === true const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz() const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine ? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) : { funktionen: 0, buchungen: 0, gedeckelt: 0 } // Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen const mailErgebnis = mails ? { festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0), aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0), } : {} const ergebnis = { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 }, geloescht: abgeschaltet.buchungen, aktiveBetriebe: null, ...mailErgebnis, fehler: lauf.fehler, } await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) return ergebnis } /** Zahl fuer die Berichtszeile: ganze Zahl ab 0, alles andere (fehlt, Text) zaehlt als 0. */ const zahl = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0) /** * Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs * (Spur der unwiderruflichen Loeschungen). Reihenfolge der Segmente (verbindlich): `…, N Mails nicht zugestellt, [Löschläufe am * Deckel: n, Rest folgt an den nächsten Tagen, ]Entwürfe n gelöscht (m offen), Abo-Abgleich g geprüft / a abgeschaltet / w wieder ein / * m Mails / x ausgenommen, N Fehler, n aktive Betriebe`; „n aktive Betriebe“ steht ganz am Ende und fehlt, wenn die Zahl unbekannt ist, * „Abo-Abgleich“ und „Entwürfe“ fehlen, wenn das Ergebnis keine Zaehler dafuer traegt. Der eingeschraenkte Lauf (Modus off) hat eine * eigene Zeile „[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): …“ ohne Entwürfe, Zellen, Outbox, Mails und Betriebe. * null, wenn der Job nicht gelaufen ist (Modus off ohne bezahlte Terminplaner, uebersprungen). * @param {object} ergebnis Rueckgabe von taeglicherJob * @returns {string|null} */ export function tagesbericht(ergebnis) { if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null const a = ergebnis.abgeschaltet 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, ` : '' const e = ergebnis.entwuerfe const entwuerfeSegment = e === null || typeof e !== 'object' ? '' : `Entwürfe ${zahl(e.geloescht)} gelöscht (${zahl(e.uebrig)} offen), ` const abo = ergebnis.aboAbgleich const aboSegment = abo === null || typeof abo !== 'object' ? '' : `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, ` if (ergebnis.eingeschraenkt) { // Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen) const mitMails = Number.isInteger(ergebnis.aufgeraeumt) const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : '' return ( `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + `${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler` ) } const l = ergebnis.loeschfristen const gesamt = buchungenGeloescht(l, a) const betriebe = Number.isInteger(ergebnis.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? `, ${ergebnis.aktiveBetriebe} aktive Betriebe` : '' 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}${entwuerfeSegment}${aboSegment}${ergebnis.fehler} Fehler${betriebe}` ) } /** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */ export function resetJobsForTests() { laufend.clear() ueberlappGemeldet.clear() letzteWarnung.clear() }