import { config } from '../../config.js' import { Query, countDocuments } from '../appwriteAdmin.js' import { sendAdminNotice } from '../mailer.js' import { mailBudget, pausiertBis, verworfeneG01, warteschlangeAlter } from './outbox.js' import { pruefeNow, pruefeText } from './util.js' import { datumKurz, uhrzeit } from './zeit.js' /** * Betriebspruefung des Terminplaners (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 8, Punkt 5 * „Sichtbarkeit“): Das Team soll es erfahren, bevor Gaeste es merken. Der Minutenjob ruft pruefeBetrieb am Ende jedes Laufs auf * (Modus off: gar nicht), der Tagesjob meldeTagesjob. * * Vorfaelle dieses Bausteins (Schluessel): * budget mindestens 70 % von config.termine.mailsProStunde im Stundenfenster belegt (mailBudget) * queue der aelteste faellige Eintrag der Warteschlange ist mehr als 10 Minuten ueberfaellig (warteschlangeAlter); * eine Wiederholung mit naechsterVersuch in der Zukunft ist nicht faellig und zaehlt nicht * pause die Anmeldepause des Mailservers laeuft (pausiertBis) * * Vorfaelle des Tagesjobs (meldeTagesjob, am Ende von taeglicherJob): * tagesjob der Tagesjob endete mit Fehlern (fehler > 0) oder brach ab * deckel die Loeschlaeufe endeten an 3 Tagesjobs in Folge am Deckel (irgendein Bereich: Fristen, abgeschaltete Funktionen, Zellen) * betriebe mindestens 20 aktive Betriebe (Entscheidung D8: ab 25 wird der Umstieg auf einen EU-Versanddienst geplant) * Daneben merkt meldeTagesjob den Stand des letzten Tagesjobs fuer die Statusroute (letzterTagesjob: nur Zaehlungen). * * Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle): je Schluessel * - Warnlog `[termine-betrieb] ...` hoechstens stuendlich (nur Zahlen, nie Adresse oder Name eines Gastes), * - Team-Mail T20 (sendAdminNotice) beim Beginn und danach fruehestens nach 6 Stunden, auch wenn der Vorfall zwischendurch * endete. Die Abkuehlzeit beginnt mit dem Versuch, nicht erst mit dem Erfolg: faellt der Mailserver aus (bei der Anmeldepause * ist er es), scheitert auch die Team-Mail, und das Log bleibt der Rueckfall, statt dass jede Minute ein neuer Versuch folgt. * Zustand (Zeit der letzten Warnung und der letzten Mail je Schluessel) lebt im Speicher dieses Prozesses wie das Mailbudget. * * Lesefehler (Appwrite) ergeben keinen Vorfall und keine Mail, nur ein Log (ebenfalls hoechstens stuendlich je Pruefung). * `now` ist immer ein Parameter (feste Uhr in Tests). * * Statusroute (statusDaten, GET /api/team/termine/status): derselbe Stand auf Abruf fuers Team, ohne Vorfall, Mail oder * Abkuehlzeit; Lesefehler ergeben null-Werte und ein Log, nie einen Fehler. */ const LOG = '[termine-betrieb]' const MIN_MS = 60 * 1000 const STUNDE_MS = 60 * MIN_MS const LOG_ABSTAND_MS = STUNDE_MS const MAIL_ABKUEHLZEIT_MS = 6 * STUNDE_MS /** Die Warteschlange gilt als gestaut, wenn der aelteste faellige Eintrag laenger als so viele Minuten ueberfaellig ist. */ const QUEUE_ALTER_MIN = 10 /** Das Budget gilt als fast aufgebraucht ab 7/10 (ganzzahlig gerechnet: benutzt x 10 >= max x 7, kein Fliesskommafehler). */ const budgetErreicht = (benutzt, max) => max > 0 && benutzt * 10 >= max * 7 /** Der Loeschdeckel gilt als Dauerzustand, wenn so viele Tagesjobs in Folge daran endeten. */ const DECKEL_FOLGE_AB = 3 /** Ab so vielen aktiven Betrieben (D8) gehen Warnlog und Team-Mail; ab BETRIEBE_UMSTIEG wird der EU-Versanddienst geplant. */ const BETRIEBE_AB = 20 const BETRIEBE_UMSTIEG = 25 /** Kurze Ueberschrift der Team-Mail (Betreff `[Terminplaner] `); ohne Eintrag gilt die allgemeine. */ const UEBERSCHRIFTEN = new Map([ ['budget', 'Mailbudget fast aufgebraucht'], ['queue', 'Mail-Warteschlange staut sich'], ['pause', 'Mailversand pausiert'], ['tagesjob', 'Tagesjob mit Fehlern'], ['deckel', 'Löschläufe am Deckel'], ['betriebe', 'Viele aktive Betriebe'], ]) const ALLGEMEINE_UEBERSCHRIFT = 'Betriebsmeldung' /** schluessel => { logMs, mailMs } (null = noch nie). */ const gemerkt = new Map() /** Stand des letzten Tagesjobs ({ at, fehler, gedeckelt, geloescht }, nur Zaehlungen) oder null (nach einem Neustart bis zum Tagesjob). */ let letzter = null /** Zahl der Tagesjobs in Folge, die in irgendeinem Bereich am Loeschdeckel endeten. */ let deckelFolge = 0 function merkzettel(schluessel) { let zettel = gemerkt.get(schluessel) if (!zettel) { zettel = { logMs: null, mailMs: null } gemerkt.set(schluessel, zettel) } return zettel } /** Ist seit dem letzten Mal (ms oder null = nie) mindestens abstandMs vergangen? */ const abgelaufen = (letzteMs, nowMs, abstandMs) => letzteMs === null || nowMs - letzteMs >= abstandMs /** '05.10.2026, 10:15' (Berliner Wanduhr), so steht die Zeit in T20 und in den Meldungen. */ const zeitText = (date) => `${datumKurz(date)}, ${uhrzeit(date)}` const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(/\s+/g, ' ').slice(0, 200)}` /** * Meldet einen Vorfall: Warnlog (hoechstens stuendlich je Schluessel) und Team-Mail T20 (beim Beginn und danach fruehestens nach * 6 Stunden je Schluessel). Wirft nur bei ungueltigen Eingaben (TypeError); ein Mailfehler steht im Log. * @param {string} schluessel Kennung des Vorfalls ('budget', 'queue', 'pause', ...); die Abkuehlzeit gilt je Schluessel * @param {string} text die Meldung (Log und Mail-Details): nur Zahlen, nie Adressen oder Namen * @param {Date} now * @returns {Promise} */ export async function meldeVorfall(schluessel, text, now) { pruefeText(schluessel, 'schluessel') pruefeText(text, 'text') const nowMs = pruefeNow(now, 'meldeVorfall') const zettel = merkzettel(schluessel) if (abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) { zettel.logMs = nowMs console.warn(`${LOG} ${text}`) } if (!abgelaufen(zettel.mailMs, nowMs, MAIL_ABKUEHLZEIT_MS)) return // Vor dem Senden gemerkt: ein zweiter Aufruf, waehrend die Mail noch unterwegs ist, sendet nicht noch einmal zettel.mailMs = nowMs const ergebnis = await sendAdminNotice({ template: 'T20', values: { vorfall: UEBERSCHRIFTEN.get(schluessel) ?? ALLGEMEINE_UEBERSCHRIFT, details: text, zeit: zeitText(now) }, }) if (!ergebnis?.sent) { console.error(`${LOG} Team-Mail zu „${schluessel}“ nicht gesendet: ${String(ergebnis?.reason ?? 'unbekannt').slice(0, 200)}`) } } /** Lesefehler einer Pruefung ins Log: hoechstens stuendlich je Pruefung, damit ein langer Ausfall das Log nicht fuellt. */ function meldeFehler(schluessel, err, nowMs) { const zettel = merkzettel(`fehler:${schluessel}`) if (!abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) return zettel.logMs = nowMs console.error(`${LOG} Prüfung „${schluessel}“ nicht möglich: ${fehlerText(err)}`) } // Die Pruefungen: Text des Vorfalls oder null (kein Vorfall); werfen duerfen sie (Lesefehler), pruefeBetrieb faengt das ab. function budgetVorfall(now) { const { benutzt, max } = mailBudget(now).stunde if (!budgetErreicht(benutzt, max)) return null return `Mailbudget ${benutzt}/${max} in der letzten Stunde (Hinweis ab 70 %); ist es voll, warten weitere Mails bis zur nächsten freien Stunde` } async function queueVorfall(now) { const { wartet, aeltesteMin } = await warteschlangeAlter(now) if (!(aeltesteMin > QUEUE_ALTER_MIN)) return null const anzahl = wartet === null ? '' : `, ${wartet} wartend` return `Mail-Warteschlange: der älteste fällige Eintrag ist seit ${aeltesteMin} Min überfällig${anzahl}` } function pauseVorfall(now) { const bis = pausiertBis() if (!bis || !(bis.getTime() > now.getTime())) return null return `Mailversand pausiert bis ${zeitText(bis)} Uhr (SMTP-Anmeldung fehlgeschlagen), die Mails warten in der Warteschlange` } /** In dieser Reihenfolge werden Vorfaelle geprueft und gemeldet. */ const PRUEFUNGEN = [ ['budget', budgetVorfall], ['queue', queueVorfall], ['pause', pauseVorfall], ] /** * Prueft Mailbudget, Warteschlange und Anmeldepause und meldet jeden aktiven Vorfall ueber meldeVorfall. Wirft nie (ausser mit * TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein Lesefehler oder ein fehlgeschlagener Mailversand steht im Log * und laesst die uebrigen Pruefungen laufen. Eine Pruefung, die nicht lesen konnte, meldet keinen Vorfall. * @param {Date} now * @returns {Promise} die Schluessel der aktiven Vorfaelle ('budget', 'queue', 'pause') */ export async function pruefeBetrieb(now) { const nowMs = pruefeNow(now, 'pruefeBetrieb') const aktiv = [] for (const [schluessel, pruefe] of PRUEFUNGEN) { try { const text = await pruefe(now) if (text === null) continue aktiv.push(schluessel) await meldeVorfall(schluessel, text, now) } catch (err) { meldeFehler(schluessel, err, nowMs) } } return aktiv } // ---------------------------------------------------------------- Tagesjob /** Eine Zaehlung: ganze Zahl ab 0, alles andere (fehlt, Text, negativ, Bruch) zaehlt als 0. */ const zaehlung = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0) /** gedeckelt = { loeschfristen, abgeschaltet, zellen } (Schritte, die am Deckel endeten) => ihre Summe. */ const summeGedeckelt = (gedeckelt) => gedeckelt !== null && typeof gedeckelt === 'object' ? Object.values(gedeckelt).reduce((summe, n) => summe + zaehlung(n), 0) : 0 /** * Anzahl der aktiven Terminplaner-Betriebe: portalFeatures featureKey termine im Zustand active (ein Aufruf mit limit(1), gelesen wird * total). Entwurf (off), bestellt, im Einbau und abgeschaltet zaehlen nicht. * @returns {Promise} null, wenn Appwrite keine Anzahl liefert; wirft bei einem Lesefehler */ export function zaehleAktiveBetriebe() { return countDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'active')]) } /** Stand des letzten Tagesjobs als Kopie ({ at (ISO), fehler, gedeckelt, geloescht }) oder null, solange seit dem Start keiner gemeldet hat. */ export function letzterTagesjob() { return letzter === null ? null : { ...letzter } } /** * Meldung am Ende des Tagesjobs (taeglicherJob ruft sie mit seinem Ergebnis): merkt den Stand fuer letzterTagesjob() und meldet ueber * meldeVorfall die Vorfaelle tagesjob (fehler > 0 oder abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und betriebe (ab 20 * aktiven Betrieben). Die Texte tragen nur Zahlen. Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein fehlgeschlagener Mailversand steht * im Log, die uebrigen Vorfaelle werden trotzdem gemeldet. Zustand (letzter Lauf, Deckel-Folge) lebt im Speicher dieses Prozesses. * @param {{ fehler?: number, gedeckelt?: { loeschfristen?: number, abgeschaltet?: number, zellen?: number }, geloescht?: number, aktiveBetriebe?: number|null, abbruch?: boolean }} ergebnis * fehler = Zahl der Fehler des Laufs; gedeckelt = Schritte je Bereich, die am Deckel endeten; geloescht = Summe aller Loeschzaehler; * aktiveBetriebe = Zahl oder null (unbekannt); abbruch = der Job brach unerwartet ab (dann fehlen die uebrigen Zahlen und die * Deckel-Folge bleibt unveraendert) * @param {Date} now * @returns {Promise} die Schluessel der Vorfaelle dieses Laufs ('tagesjob', 'deckel', 'betriebe') */ export async function meldeTagesjob(ergebnis, now) { const nowMs = pruefeNow(now, 'meldeTagesjob') const gemeldet = [] try { const abbruch = ergebnis?.abbruch === true const fehler = zaehlung(ergebnis?.fehler) const gedeckelt = summeGedeckelt(ergebnis?.gedeckelt) const betriebe = Number.isInteger(ergebnis?.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? ergebnis.aktiveBetriebe : null letzter = { at: new Date(nowMs).toISOString(), fehler, gedeckelt, geloescht: zaehlung(ergebnis?.geloescht) } if (!abbruch) deckelFolge = gedeckelt > 0 ? deckelFolge + 1 : 0 const vorfaelle = [] if (abbruch || fehler > 0) { vorfaelle.push([ 'tagesjob', abbruch ? `Tagesjob abgebrochen (unerwarteter Fehler), ${fehler} Fehler gezählt; Einzelheiten stehen im Log` : `Tagesjob mit ${fehler} Fehler${fehler === 1 ? '' : 'n'}; Einzelheiten stehen im Log`, ]) } if (deckelFolge >= DECKEL_FOLGE_AB) { vorfaelle.push([ 'deckel', `Löschläufe endeten an ${deckelFolge} Tagesjobs in Folge am Deckel (im letzten Lauf ${gedeckelt} Schritte); der Rest folgt an den nächsten Tagen, hält der Rückstand an, sind die Löschdeckel zu prüfen`, ]) } if (betriebe !== null && betriebe >= BETRIEBE_AB) { vorfaelle.push([ 'betriebe', `${betriebe} aktive Betriebe (Hinweis ab ${BETRIEBE_AB}); ab ${BETRIEBE_UMSTIEG} aktiven Betrieben ist der Umstieg auf einen EU-Versanddienst zu planen`, ]) } for (const [schluessel, text] of vorfaelle) { gemeldet.push(schluessel) try { await meldeVorfall(schluessel, text, now) } catch (err) { meldeFehler(`meldung:${schluessel}`, err, nowMs) } } } catch (err) { meldeFehler('meldung', err, nowMs) } return gemeldet } // ---------------------------------------------------------------- Statusroute /** * Liest einen Wert fuer die Statusroute: ein Lesefehler ergibt `ersatz` und ein Log (hoechstens stuendlich je Wert, eigener * Merkzettel `status-`), nie einen Fehler. */ async function leseOderErsatz(wert, nowMs, lese, ersatz) { try { return await lese() } catch (err) { meldeFehler(`status-${wert}`, err, nowMs) return ersatz } } /** * Stand des Terminplaner-Betriebs fuer die Team-Route GET /api/team/termine/status: nur Zahlen, Wahrheitswerte, Zeitpunkte und * Funktions-IDs, nie eine Adresse oder ein Name eines Gastes. * budget mailBudget(now) plus verworfeneG01 (veraltete G01, die dieser Prozess seit dem Start verworfen hat) * queue { wartet, aeltesteMin } wie warteschlangeAlter; bei einem Lesefehler beide null * pause { aktiv, bis }: aktiv, solange das Ende der Anmeldepause nach `now` liegt, dann ist bis dessen Zeitpunkt (ISO), sonst null * letzterTagesjob null (seit dem Start lief noch kein Tagesjob) oder { at (ISO), fehler, gedeckelt, geloescht } * aktiveBetriebe Zahl der aktiven Termine-Dokumente (zaehleAktiveBetriebe); null bei einem Lesefehler * Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): was nicht lesbar war, ist null und steht im Log. * @param {Date} now * @returns {Promise<{ budget: object, queue: { wartet: number|null, aeltesteMin: number|null }, pause: { aktiv: boolean, bis: string|null }, * letzterTagesjob: { at: string, fehler: number, gedeckelt: number, geloescht: number }|null, aktiveBetriebe: number|null }>} */ export async function statusDaten(now) { const nowMs = pruefeNow(now, 'statusDaten') const [queue, aktiveBetriebe] = await Promise.all([ leseOderErsatz('queue', nowMs, () => warteschlangeAlter(now), { wartet: null, aeltesteMin: null }), leseOderErsatz('betriebe', nowMs, zaehleAktiveBetriebe, null), ]) const bis = pausiertBis() const pauseAktiv = bis !== null && bis.getTime() > nowMs return { budget: { ...mailBudget(now), verworfeneG01: verworfeneG01() }, queue: { wartet: queue.wartet, aeltesteMin: queue.aeltesteMin }, pause: { aktiv: pauseAktiv, bis: pauseAktiv ? bis.toISOString() : null }, letzterTagesjob: letzterTagesjob(), aktiveBetriebe, } } /** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel, letzter Tagesjob, Deckel-Folge) zuruecksetzen. */ export function resetBetriebForTests() { gemerkt.clear() letzter = null deckelFolge = 0 }