Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail
pruefeBetrieb (neu: termine/betrieb.js) prueft am Ende jedes Minutenjobs Mailbudget (ab 70 %), Warteschlange (aeltester faelliger Eintrag > 10 Min ueberfaellig, neu outbox.warteschlangeAlter) und Anmeldepause (pausiertBis). Der gemeinsame Melder meldeVorfall schreibt je Schluessel hoechstens stuendlich ein Warnlog und schickt die Team-Mail T20 beim Beginn und danach fruehestens nach 6 Stunden; Lesefehler und Mailfehler stehen nur im Log. T20 steht jetzt auch im Test der Team-Fusszeile. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a3319ec14
commit
689a21f9ac
5 files changed
+691
-4
No files matched your search
@@ -0,0 +1,157 @@
|
||||
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] <Ueberschrift>`); 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<void>}
|
||||
*/
|
||||
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<string[]>} 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()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 { pruefeBetrieb } from './betrieb.js'
|
||||
import {
|
||||
abgelaufeneHaltungen,
|
||||
aendere,
|
||||
@@ -26,7 +27,8 @@ 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
|
||||
* minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); 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), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen
|
||||
@@ -210,7 +212,8 @@ async function verfalleHaltungen(lauf, now) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden.
|
||||
* Jede Minute: abgelaufene Haltungen verfallen lassen, 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).
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
|
||||
*/
|
||||
@@ -218,6 +221,8 @@ 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 })
|
||||
// Zuletzt: die Pruefung sieht den Stand nach Verfall und Versand (ein eben gesendeter Eintrag staut nichts mehr)
|
||||
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
|
||||
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { config } from '../../config.js'
|
||||
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { Query, countDocuments, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendGuestMail } from '../mailer.js'
|
||||
import { renderMail } from '../mailTemplates.js'
|
||||
@@ -457,6 +457,31 @@ export async function verarbeite(now) {
|
||||
return { faellig: lauf.faellig, gesendet: lauf.gesendet }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand der Warteschlange fuer die Betriebspruefung und die Statusroute: nur Zahlen, nie ein Mailtext. `wartet` = alle Eintraege mit
|
||||
* Status `wartet` (auch Wiederholungen, die erst spaeter faellig werden), `aeltesteMin` = ganze Minuten (abgerundet), die der aelteste
|
||||
* faellige Eintrag (naechsterVersuch <= jetzt) schon ueberfaellig ist, 0 ohne faelligen Eintrag. Zwei kurze Abfragen ueber den Index
|
||||
* idx_status_next, parallel: die Anzahl und der aelteste faellige Eintrag (limit 1, nur $id und naechsterVersuch). Scheitert eine,
|
||||
* wirft die Funktion; der Aufrufer meldet den Lesefehler.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ wartet: number|null, aeltesteMin: number }>} wartet ist null, wenn Appwrite keine Anzahl liefert
|
||||
*/
|
||||
export async function warteschlangeAlter(now) {
|
||||
const nowMs = pruefeNow(now, 'warteschlangeAlter')
|
||||
const [wartet, aelteste] = await Promise.all([
|
||||
countDocuments(col(), [Query.equal('status', 'wartet')]),
|
||||
listDocuments(col(), [
|
||||
Query.equal('status', 'wartet'),
|
||||
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
|
||||
Query.orderAsc('naechsterVersuch'),
|
||||
Query.limit(1),
|
||||
Query.select(['$id', 'naechsterVersuch']),
|
||||
]),
|
||||
])
|
||||
const faelligSeit = aelteste.length > 0 ? zeitMs(aelteste[0].naechsterVersuch) : Number.NaN
|
||||
return { wartet, aeltesteMin: Number.isNaN(faelligSeit) ? 0 : Math.max(0, Math.floor((nowMs - faelligSeit) / MIN_MS)) }
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */
|
||||
export async function warteAufVersand() {
|
||||
while (laufend.size > 0) await Promise.allSettled([...laufend])
|
||||
|
||||
Reference in new issue
Block a user