import { sendAdminNotice } from '../mailer.js' import { mailBudget, pausiertBis, 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). * * 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) * * Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle von A10a3): 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). */ 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 /** 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'], ]) const ALLGEMEINE_UEBERSCHRIFT = 'Betriebsmeldung' /** schluessel => { logMs, mailMs } (null = noch nie). */ const gemerkt = new Map() 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 } /** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel) zuruecksetzen. */ export function resetBetriebForTests() { gemerkt.clear() }