Seit 2c7cd26 kostet jede Art in VORRANG/NACHRANGIG ein notEqual in der Abfrage der Klasse normal; live stößt die URL
bei nginx an (414). G09–G15 laufen deshalb in der Klasse normal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
629 lines
29 KiB
JavaScript
629 lines
29 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { Query, countDocuments, createDocument, deleteDocument, getDocument, keinerVon, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { sendGuestMail } from '../mailer.js'
|
|
import { renderMail } from '../mailTemplates.js'
|
|
import { pruefeNow, pruefeText, zeitMs } from './util.js'
|
|
|
|
/**
|
|
* Mail-Warteschlange des Terminplaners (termineOutbox; Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6).
|
|
* Alle Termine-Mails laufen hier durch: erst speichern (einreihen), dann senden (sendeEintrag, der einzige Sendepfad).
|
|
*
|
|
* Sendepfad (sendeEintrag): Pause und Stundenbudget pruefen, Sperre `termine-mail:<id>` (je Eintrag, parallele Laeufe
|
|
* senden nie doppelt), Eintrag neu lesen, senden, Ergebnis speichern. Fehlerklassen:
|
|
* - TypeError (ungueltige Adresse, fehlender Betreff ...) => sofort `fehler`, kein Versuch am Server
|
|
* - EAUTH / SMTP 535 (Anmeldung) => 15 Minuten Pause fuer ALLE Eintraege; der Eintrag bleibt
|
|
* `wartet`, auch `versuche` bleibt (kein Fehlversuch der Mail)
|
|
* - sonstige 5xx => sofort `fehler`
|
|
* - 4xx, Verbindungsfehler, nicht konfiguriertes SMTP => erneut nach 1, 5 und 30 Minuten, nach dem vierten
|
|
* Fehlschlag `fehler`
|
|
* Nach `gesendet` werden an, html, text und ics geleert (personenbezogene Daten).
|
|
*
|
|
* Veraltete Bestaetigungsmail (G01 mit Link): Wird eine G01 erst nach Ablauf des Halte-Fensters faellig (Warteschlange voll,
|
|
* Pause, Wiederholungen), waere ihr Bestaetigungslink schon verfallen. Sie geht dann nicht mehr raus, sondern wird `verworfen`
|
|
* (siehe sende): kein Transportaufruf, kein Budgetplatz, an/html/text/ics geleert. Das ist kein Zustellfehler (zustellFehler
|
|
* zaehlt nur `fehler`); raeumeAuf loescht `verworfen` wie `fehler` 48 Stunden spaeter. G01 ohne Link (bookingId '-', zu_viele)
|
|
* und alle anderen Arten verfallen nie. Ein Eintrag, der schon rausging, aber nicht verbucht werden konnte (`unverbucht`),
|
|
* wird nie verworfen und nie doppelt gesendet.
|
|
*
|
|
* `naechsterVersuch` ist die Faelligkeit des Eintrags: bei `wartet` der naechste Sendeversuch, bei `gesendet` der
|
|
* Loeschtermin (Versand + 7 Tage), bei `fehler` ebenfalls (Fehlschlag + 48 Stunden). So findet raeumeAuf alles ueber
|
|
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
|
|
*
|
|
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
|
|
* belegten Plaetze { t, f, n } (Zeit, Funktion, nachrangig) bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt
|
|
* (gleichzeitige Versuche koennen das Budget nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist
|
|
* immer ein Parameter (feste Uhr in Tests); ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
|
|
*
|
|
* Fairness (pruefePlatz, in dieser Reihenfolge; der Eintrag bleibt dann unveraendert `wartet`, kein Versuch):
|
|
* pause Anmeldepause laeuft
|
|
* budget Plaetze im Fenster >= mailsProStunde
|
|
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
|
|
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12, G15) und den normalen Mails vorbehalten
|
|
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
|
|
*
|
|
* Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage.
|
|
* Sonst je Klasse (mailKlasse) in der Reihenfolge vorrang, normal, nachrangig eine begrenzte Liste (config.termine.mailListe,
|
|
* nur die Felder $id, featureId, art, naechsterVersuch, nie ein Mailtext), die faelligsten zuerst. Eine Funktion mit
|
|
* ausgeschoepftem Kontingent kommt in die Ausschlussliste; ist die Seite voll und hat sich etwas bewegt, folgen hoechstens
|
|
* vier weitere Runden mit der neuen Ausschlussliste, damit hinter einer vollen Funktion keine andere verhungert.
|
|
*/
|
|
|
|
const col = () => config.collections.termineOutbox
|
|
|
|
const MIN_MS = 60 * 1000
|
|
const STUNDE_MS = 60 * MIN_MS
|
|
const TAG_MS = 24 * STUNDE_MS
|
|
/** Wartezeit nach dem 1., 2. und 3. Fehlschlag; der vierte ist endgueltig. */
|
|
const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS]
|
|
const ANMELDE_PAUSE_MS = 15 * MIN_MS
|
|
const GESENDET_BEHALTEN_MS = 7 * TAG_MS
|
|
const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS
|
|
const VERWORFEN_BEHALTEN_MS = 48 * STUNDE_MS
|
|
/** bookingId der Mails ohne Buchung (G01 zu_viele): sie haben keinen Bestaetigungslink und verfallen nie. */
|
|
const OHNE_BUCHUNG = '-'
|
|
const VERWORFEN_GRUND = 'Verworfen: Bestätigungslink abgelaufen'
|
|
/** Laenger als das in `wartet` (ab createdAt) ist ein Eintrag festgefahren (beendeFestgefahrene). */
|
|
const WARTEN_MAX_MS = 48 * STUNDE_MS
|
|
const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
|
|
/** Buchungs-IDs je Abfrage in zustellFehler (haelt die Adresse kurz). */
|
|
const ID_BLOCK = 50
|
|
|
|
const iso = (wertMs) => new Date(wertMs).toISOString()
|
|
|
|
/**
|
|
* Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Die Shop-Mails
|
|
* G09 bis G15 sind bewusst 'normal' (jede Minute bedient): jede Art in VORRANG/NACHRANGIG kostet in der Abfrage der Klasse
|
|
* 'normal' ein notEqual mehr, und die URL stoesst live bei nginx an (414, Messung G5 im TP2-Livegang).
|
|
*/
|
|
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
|
|
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
|
|
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
|
|
|
|
/** 'vorrang' | 'normal' | 'nachrangig' nach der Art (Vorlagen-ID) der Mail. */
|
|
export function mailKlasse(art) {
|
|
if (MAIL_VORRANG.includes(art)) return 'vorrang'
|
|
if (MAIL_NACHRANGIG.includes(art)) return 'nachrangig'
|
|
return 'normal'
|
|
}
|
|
|
|
// Zustand des Prozesses
|
|
let pauseBisMs = 0
|
|
/** Belegte Plaetze im letzten Stundenfenster: { t: Sendezeit (ms), f: featureId, n: nachrangig }. */
|
|
let plaetze = []
|
|
/** Veraltete G01, die dieser Prozess verworfen hat (verworfeneG01). */
|
|
let verworfenG01 = 0
|
|
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
|
|
const laufend = new Set()
|
|
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
|
|
const unverbucht = new Map()
|
|
|
|
/** Die Plaetze, die zum Zeitpunkt nowMs noch im Stundenfenster stehen (nur lesen; ein Platz laeuft 60 Minuten nach dem Versand ab). */
|
|
const imFenster = (nowMs) => plaetze.filter((platz) => platz.t > nowMs - STUNDE_MS)
|
|
|
|
/** Hoechstens so viele nachrangige Mails (G05, K20, K21) im Stundenfenster; die Toleranz faengt Fliesskommafehler wie 100 x 0.58 ab. */
|
|
const nachrangigMax = () => Math.max(1, Math.floor(config.termine.mailsProStunde * (1 - config.termine.mailsReserve) + 1e-9))
|
|
|
|
/** Sind die nachrangigen Plaetze (G05, K20, K21) im uebergebenen Fenster aufgebraucht? */
|
|
const reserveErreicht = (fenster) => fenster.filter((platz) => platz.n).length >= nachrangigMax()
|
|
|
|
/** 'pause' | 'budget' | null: die Bremsen, die fuer jede Mail gelten (nur lesen). */
|
|
function gesamtBremse(nowMs) {
|
|
if (pauseBisMs > nowMs) return 'pause'
|
|
if (imFenster(nowMs).length >= config.termine.mailsProStunde) return 'budget'
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Darf eine Mail dieser Funktion und Art zum Zeitpunkt nowMs raus? null = ja, sonst der erste Grund (siehe Kopfkommentar):
|
|
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
|
|
* @param {number} nowMs
|
|
* @param {string} featureId
|
|
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G15')
|
|
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
|
|
*/
|
|
export function pruefePlatz(nowMs, featureId, art) {
|
|
const bremse = gesamtBremse(nowMs)
|
|
if (bremse) return bremse
|
|
const fenster = imFenster(nowMs)
|
|
if (mailKlasse(art) === 'nachrangig' && reserveErreicht(fenster)) return 'reserve'
|
|
if (fenster.filter((platz) => platz.f === featureId).length >= config.termine.mailsProFunktion) return 'kontingent'
|
|
return null
|
|
}
|
|
|
|
/** Belegt einen Platz (synchron, ohne Zwischenschritt); der Platz oder null, wenn pruefePlatz einen Grund nennt. */
|
|
function belegePlatz(nowMs, featureId, art) {
|
|
if (pruefePlatz(nowMs, featureId, art) !== null) return null
|
|
const platz = { t: nowMs, f: featureId, n: mailKlasse(art) === 'nachrangig' }
|
|
plaetze = [...imFenster(nowMs), platz]
|
|
return platz
|
|
}
|
|
|
|
function gibPlatzFrei(platz) {
|
|
plaetze = plaetze.filter((belegt) => belegt !== platz)
|
|
}
|
|
|
|
/**
|
|
* Stand des Mailbudgets zum Zeitpunkt now (fuer die Betriebspruefung und die Statusroute): nur Zahlen und Funktions-IDs,
|
|
* nie eine Adresse. `spitzen` sind die bis zu drei Funktionen mit den meisten Mails im Fenster, die groesste zuerst.
|
|
* @param {Date} now
|
|
* @returns {{ stunde: { benutzt: number, max: number }, nachrangig: { benutzt: number, max: number }, jeFunktionMax: number,
|
|
* spitzen: Array<{ featureId: string, benutzt: number }> }}
|
|
*/
|
|
export function mailBudget(now) {
|
|
const nowMs = pruefeNow(now, 'mailBudget')
|
|
const fenster = imFenster(nowMs)
|
|
const jeFunktion = new Map()
|
|
for (const platz of fenster) jeFunktion.set(platz.f, (jeFunktion.get(platz.f) ?? 0) + 1)
|
|
const spitzen = [...jeFunktion]
|
|
.map(([featureId, benutzt]) => ({ featureId, benutzt }))
|
|
.sort((a, b) => b.benutzt - a.benutzt || (a.featureId < b.featureId ? -1 : 1))
|
|
.slice(0, 3)
|
|
return {
|
|
stunde: { benutzt: fenster.length, max: config.termine.mailsProStunde },
|
|
nachrangig: { benutzt: fenster.filter((platz) => platz.n).length, max: nachrangigMax() },
|
|
jeFunktionMax: config.termine.mailsProFunktion,
|
|
spitzen,
|
|
}
|
|
}
|
|
|
|
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
|
|
function fehlerArt(err) {
|
|
if (err instanceof TypeError) return 'dauerhaft'
|
|
const antwort = Number(err?.responseCode)
|
|
if (err?.code === 'EAUTH' || antwort === 535) return 'anmeldung'
|
|
if (antwort >= 500 && antwort <= 599) return 'dauerhaft'
|
|
return 'vorlaeufig'
|
|
}
|
|
|
|
/** Kurzer Fehlertext fuers Feld `fehler` (hoechstens 300 Zeichen). */
|
|
const fehlerText = (err) =>
|
|
[err?.code, err?.responseCode, err?.message]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.replace(/\s+/g, ' ')
|
|
.slice(0, 300)
|
|
|
|
/**
|
|
* Mail einreihen: rendert die Vorlage sofort, speichert den Eintrag als `wartet` und stoesst den ersten Sendeversuch
|
|
* an, ohne auf ihn zu warten (Fehler dabei stehen im Log, der Eintrag bleibt fuer verarbeite). Wirft bei ungueltigen
|
|
* Eingaben (TypeError) oder wenn das Speichern scheitert; dann ist nichts gespeichert und nichts gesendet.
|
|
* `ics` und `icsMethod` gehoeren zusammen: die METHOD-Zeile der Kalenderdatei muss icsMethod entsprechen.
|
|
* @param {{ featureId: string, bookingId: string, art: string, an: string, fromName: string, replyTo?: string|null,
|
|
* template: string, values?: object, blocks?: object, ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} daten
|
|
* @param {Date} [now]
|
|
* @returns {Promise<object>} das gespeicherte Dokument (Status wartet)
|
|
*/
|
|
export async function einreihen(
|
|
{ featureId, bookingId, art, an, fromName, replyTo = null, template, values = {}, blocks = {}, ics = null, icsMethod = 'REQUEST' },
|
|
now = new Date(),
|
|
) {
|
|
for (const [wert, name] of [[featureId, 'featureId'], [bookingId, 'bookingId'], [art, 'art'], [an, 'an'], [fromName, 'fromName'], [template, 'template']]) {
|
|
pruefeText(wert, name)
|
|
}
|
|
const nowMs = pruefeNow(now, 'einreihen')
|
|
if (ics) {
|
|
if (!ICS_METHODEN.includes(icsMethod)) throw new TypeError(`icsMethod muss ${ICS_METHODEN.join(', ')} sein`)
|
|
if (!new RegExp(`^METHOD:${icsMethod}\\r?$`, 'm').test(ics)) throw new TypeError(`ics: METHOD passt nicht zu icsMethod ${icsMethod}`)
|
|
}
|
|
|
|
const mail = renderMail(template, values, blocks)
|
|
for (const problem of mail.problems) console.warn(`[termine-outbox] Vorlage ${template}: ${problem}`)
|
|
|
|
const doc = await createDocument(col(), {
|
|
featureId,
|
|
bookingId,
|
|
art,
|
|
an,
|
|
fromName,
|
|
replyTo: replyTo || '',
|
|
subject: mail.subject,
|
|
html: mail.html,
|
|
text: mail.text,
|
|
ics: ics || '',
|
|
icsMethod: ics ? icsMethod : '',
|
|
status: 'wartet',
|
|
versuche: 0,
|
|
naechsterVersuch: iso(nowMs),
|
|
fehler: '',
|
|
createdAt: iso(nowMs),
|
|
})
|
|
sendeEintrag(doc.$id, now).catch((err) => console.error(`[termine-outbox] Eintrag ${doc.$id}: ${err.message}`))
|
|
return doc
|
|
}
|
|
|
|
/**
|
|
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
|
|
* laeuft und Stundenbudget, Vorrang-Reserve und Kontingent der Funktion reichen (pruefePlatz).
|
|
* @param {string} id
|
|
* @param {Date} [now]
|
|
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'verworfen'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
|
|
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, Reserve, Kontingent, noch nicht faellig, vorlaeufiger
|
|
* Fehler); fehler = dieser Aufruf hat ihn endgueltig scheitern lassen; verworfen = veraltete G01 (Bestaetigungslink
|
|
* abgelaufen), nicht gesendet; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen/verworfen
|
|
*/
|
|
export function sendeEintrag(id, now = new Date()) {
|
|
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
|
|
laufend.add(lauf)
|
|
return lauf
|
|
}
|
|
|
|
async function sende(id, now) {
|
|
const nowMs = pruefeNow(now, 'sendeEintrag')
|
|
pruefeText(id, 'id')
|
|
if (gesamtBremse(nowMs)) return 'wartet'
|
|
|
|
return withLocks([`termine-mail:${id}`], async () => {
|
|
let doc
|
|
try {
|
|
doc = await getDocument(col(), id)
|
|
} catch (err) {
|
|
if (err.status === 404) return 'uebersprungen'
|
|
throw err
|
|
}
|
|
if (doc.status !== 'wartet') {
|
|
unverbucht.delete(id)
|
|
return 'uebersprungen'
|
|
}
|
|
if (unverbucht.has(id)) {
|
|
await speichereGesendet(doc, unverbucht.get(id))
|
|
return 'gesendet'
|
|
}
|
|
// Vor dem Budget: eine veraltete G01 braucht keinen Platz und keinen Sendeversuch
|
|
if (istVeraltetG01(doc, nowMs)) {
|
|
await speichereVerworfen(doc, nowMs)
|
|
return 'verworfen'
|
|
}
|
|
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
|
|
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen Budget und Kontingent nicht
|
|
// ueberziehen. Ein gebremster Eintrag bleibt unveraendert `wartet` (kein Versuch).
|
|
const platz = belegePlatz(nowMs, doc.featureId, doc.art)
|
|
if (!platz) return 'wartet'
|
|
|
|
try {
|
|
await sendGuestMail({
|
|
to: doc.an,
|
|
fromName: doc.fromName,
|
|
replyTo: doc.replyTo || null,
|
|
subject: doc.subject,
|
|
html: doc.html,
|
|
text: doc.text,
|
|
ics: doc.ics || null,
|
|
icsMethod: doc.icsMethod || 'REQUEST',
|
|
})
|
|
} catch (err) {
|
|
gibPlatzFrei(platz)
|
|
return speichereFehlschlag(doc, err, nowMs)
|
|
}
|
|
await speichereGesendet(doc, nowMs)
|
|
return 'gesendet'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf
|
|
* gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat
|
|
* keinen Link, andere Arten verfallen nie.
|
|
*/
|
|
function istVeraltetG01(doc, nowMs) {
|
|
if (doc.art !== 'G01' || doc.bookingId === OHNE_BUCHUNG) return false
|
|
return nowMs - zeitMs(doc.createdAt) > (config.termine.holdMinutes + 1) * MIN_MS
|
|
}
|
|
|
|
/** Veraltete G01 verwerfen: Status `verworfen`, personenbezogene Felder leeren, Loeschtermin jetzt + 48 Stunden (raeumeAuf). */
|
|
async function speichereVerworfen(doc, nowMs) {
|
|
await updateDocument(col(), doc.$id, {
|
|
status: 'verworfen',
|
|
an: '',
|
|
html: '',
|
|
text: '',
|
|
ics: '',
|
|
fehler: VERWORFEN_GRUND,
|
|
naechsterVersuch: iso(nowMs + VERWORFEN_BEHALTEN_MS),
|
|
})
|
|
verworfenG01 += 1
|
|
}
|
|
|
|
/**
|
|
* Nach dem Versand: Status, Zeiten setzen und personenbezogene Felder leeren. Scheitert das Speichern (auch beim
|
|
* zweiten Versuch), merkt sich der Prozess den Versand, damit der Eintrag nicht noch einmal gesendet wird.
|
|
*/
|
|
async function speichereGesendet(doc, gesendetMs) {
|
|
const felder = {
|
|
status: 'gesendet',
|
|
versuche: (Number(doc.versuche) || 0) + 1,
|
|
gesendetAt: iso(gesendetMs),
|
|
naechsterVersuch: iso(gesendetMs + GESENDET_BEHALTEN_MS),
|
|
fehler: '',
|
|
an: '',
|
|
html: '',
|
|
text: '',
|
|
ics: '',
|
|
}
|
|
let letzterFehler
|
|
for (let versuch = 0; versuch < 2; versuch++) {
|
|
try {
|
|
await updateDocument(col(), doc.$id, felder)
|
|
unverbucht.delete(doc.$id)
|
|
return
|
|
} catch (err) {
|
|
letzterFehler = err
|
|
}
|
|
}
|
|
unverbucht.set(doc.$id, gesendetMs)
|
|
console.error(`[termine-outbox] Eintrag ${doc.$id} gesendet, Speichern gescheitert: ${letzterFehler.message}`)
|
|
}
|
|
|
|
async function speichereFehlschlag(doc, err, nowMs) {
|
|
const art = fehlerArt(err)
|
|
if (art === 'anmeldung') {
|
|
pauseBisMs = Math.max(pauseBisMs, nowMs + ANMELDE_PAUSE_MS)
|
|
console.error(`[termine-outbox] SMTP-Anmeldung fehlgeschlagen (${err.code || err.responseCode}), Warteschlange pausiert bis ${iso(pauseBisMs)}`)
|
|
return 'wartet'
|
|
}
|
|
const versuche = (Number(doc.versuche) || 0) + 1
|
|
const wartezeit = WIEDERHOLUNG_MS[versuche - 1]
|
|
if (art === 'vorlaeufig' && wartezeit !== undefined) {
|
|
await updateDocument(col(), doc.$id, { versuche, naechsterVersuch: iso(nowMs + wartezeit), fehler: fehlerText(err) })
|
|
return 'wartet'
|
|
}
|
|
await updateDocument(col(), doc.$id, { status: 'fehler', versuche, naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS), fehler: fehlerText(err) })
|
|
console.error(`[termine-outbox] Eintrag ${doc.$id} endgueltig gescheitert nach ${versuche} Versuch(en): ${err.code || ''} ${err.responseCode || ''}`.trim())
|
|
return 'fehler'
|
|
}
|
|
|
|
/** Klassen in der Reihenfolge, in der verarbeite sie bedient. */
|
|
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
|
|
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
|
|
const RUNDEN_MAX = 5
|
|
/**
|
|
* Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage. Jede ist ein eigenes notEqual in der URL (Appwrite 1.8.1
|
|
* nimmt je notEqual nur einen Wert, M1). Gemessen am Produktionsserver (M4, 2026-10-05): in dieser Abfrageform antwortet der nginx
|
|
* ab 46 Funktions-IDs mit 36 Zeichen mit 414 (URL zu lang); 30 lassen Luft fuer laengere IDs. Eine blockierte Funktion ausserhalb
|
|
* der Liste wird gelesen und uebersprungen (verarbeiteKlasse), das kostet nur Platz in der Liste.
|
|
*/
|
|
export const AUSSCHLUSS_MAX = 30
|
|
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
|
|
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
|
|
|
|
/**
|
|
* Filter auf die Art der Mail, immer eine Liste von Abfragen: die Arten der Klasse (equal mit Liste, das erlaubt Appwrite) bzw.
|
|
* alle anderen (die Klasse normal: je Art ein notEqual, Appwrite schneidet sie; art ist an jedem Eintrag gesetzt, einreihen prueft es).
|
|
*/
|
|
function klassenFilter(klasse) {
|
|
if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])]
|
|
if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])]
|
|
return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
|
|
}
|
|
|
|
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
|
|
function listenAbfrage(klasse, nowMs, blockiert) {
|
|
return [
|
|
Query.equal('status', 'wartet'),
|
|
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
|
|
...klassenFilter(klasse),
|
|
...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)),
|
|
Query.orderAsc('naechsterVersuch'),
|
|
Query.limit(config.termine.mailListe),
|
|
Query.select(LISTENFELDER),
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Eine Klasse bedienen (siehe Kopfkommentar). Zaehler und Ausschlussliste des Laufs stehen in `lauf`.
|
|
* @returns {Promise<boolean>} false, wenn Pause oder Gesamtbudget den ganzen Lauf beenden
|
|
*/
|
|
async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
|
|
for (let runde = 0; runde < RUNDEN_MAX; runde++) {
|
|
if (gesamtBremse(nowMs)) return false
|
|
if (klasse === 'nachrangig' && reserveErreicht(imFenster(nowMs))) return true
|
|
const seite = await listDocuments(col(), listenAbfrage(klasse, nowMs, lauf.blockiert))
|
|
lauf.faellig += seite.length
|
|
let bewegt = false
|
|
for (const eintrag of seite) {
|
|
if (lauf.versucht.has(eintrag.$id)) continue
|
|
// Pause und leeres Gesamtbudget beenden den Lauf, die Reserve die nachrangige Klasse; das Kontingent betrifft nur die Funktion
|
|
const grund = pruefePlatz(nowMs, eintrag.featureId, eintrag.art)
|
|
if (grund === 'pause' || grund === 'budget') return false
|
|
if (grund === 'reserve') return true
|
|
if (grund === 'kontingent') {
|
|
if (!lauf.blockiert.has(eintrag.featureId)) {
|
|
lauf.blockiert.add(eintrag.featureId)
|
|
bewegt = true
|
|
}
|
|
continue
|
|
}
|
|
lauf.versucht.add(eintrag.$id)
|
|
try {
|
|
const ergebnis = await sendeEintrag(eintrag.$id, now)
|
|
if (ergebnis === 'gesendet') lauf.gesendet += 1
|
|
// auch ein verworfener Eintrag verlaesst die Liste (`wartet`); so kommen die Eintraege dahinter in der naechsten Runde dran
|
|
if (ergebnis === 'gesendet' || ergebnis === 'verworfen') bewegt = true
|
|
} catch (err) {
|
|
console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`)
|
|
}
|
|
}
|
|
// Weiter nur, wenn die Seite voll war (es kann mehr geben) und sich etwas bewegt hat; sonst kaeme dieselbe Seite wieder
|
|
if (seite.length < config.termine.mailListe || !bewegt) return true
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12, G15) vor normalen vor nachrangigen (G05, K20, K21),
|
|
* in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege
|
|
* (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen
|
|
* kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
|
|
* @param {Date} now
|
|
* @returns {Promise<{ faellig: number, gesendet: number }>} faellig = die gelesenen Eintraege (0 bei Pause oder leerem Budget)
|
|
*/
|
|
export async function verarbeite(now) {
|
|
const nowMs = pruefeNow(now, 'verarbeite')
|
|
// Fruehausstieg: ohne Pause und mit freiem Gesamtbudget lohnt sich das Lesen, sonst nicht
|
|
if (gesamtBremse(nowMs)) return { faellig: 0, gesendet: 0 }
|
|
// blockiert: Funktionen mit ausgeschoepftem Kontingent (gilt fuer alle Klassen); versucht: Eintraege, die dieser Lauf schon angefasst hat
|
|
const lauf = { faellig: 0, gesendet: 0, blockiert: new Set(), versucht: new Set() }
|
|
for (const klasse of KLASSEN) {
|
|
if (!(await verarbeiteKlasse(klasse, nowMs, now, lauf))) break
|
|
}
|
|
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])
|
|
}
|
|
|
|
/**
|
|
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler; verworfene veraltete G01
|
|
* zaehlen nicht: der Betrieb sieht fuer einen verfallenen Halte-Eintrag keinen Zustellfehler). Das Portal zeigt dort
|
|
* „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler).
|
|
* Fragt ueber den Index idx_booking (bookingId) ab.
|
|
* @param {string[]} bookingIds
|
|
* @returns {Promise<Set<string>>}
|
|
*/
|
|
export async function zustellFehler(bookingIds) {
|
|
const ids = [...new Set((bookingIds ?? []).filter((id) => typeof id === 'string' && id !== ''))]
|
|
const betroffen = new Set()
|
|
for (let ab = 0; ab < ids.length; ab += ID_BLOCK) {
|
|
const eintraege = await listAllDocuments(col(), [Query.equal('bookingId', ids.slice(ab, ab + ID_BLOCK)), Query.equal('status', 'fehler')])
|
|
for (const eintrag of eintraege) betroffen.add(eintrag.bookingId)
|
|
}
|
|
return betroffen
|
|
}
|
|
|
|
/**
|
|
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand, fehlgeschlagene 48 Stunden nach dem Fehler und
|
|
* verworfene (veraltete G01) 48 Stunden nach dem Verwerfen (alles steht in naechsterVersuch). Wartende Eintraege bleiben.
|
|
* @param {Date} now
|
|
* @returns {Promise<number>} Anzahl geloeschter Eintraege
|
|
*/
|
|
export async function raeumeAuf(now) {
|
|
const nowMs = pruefeNow(now, 'raeumeAuf')
|
|
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler', 'verworfen']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
|
|
let geloescht = 0
|
|
for (const eintrag of alt) {
|
|
try {
|
|
await deleteDocument(col(), eintrag.$id)
|
|
geloescht += 1
|
|
} catch (err) {
|
|
if (err.status !== 404) throw err // 404: ein paralleler Lauf war schneller
|
|
}
|
|
}
|
|
return geloescht
|
|
}
|
|
|
|
/**
|
|
* Gastdaten loeschen (Portal "Gastdaten löschen"): noch nicht versandte Mails einer Buchung (Status wartet) werden
|
|
* geloescht, denn sie halten Adresse und Inhalt. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem Lesen:
|
|
* ein Eintrag, der inzwischen gesendet wurde (seine Felder sind dann schon leer), bleibt; ein inzwischen geloeschter
|
|
* stoert nicht. Gesendete, fehlgeschlagene und verworfene Eintraege behandelt raeumeAuf. Fragt ueber den Index idx_booking ab.
|
|
* @param {string} bookingId
|
|
* @returns {Promise<number>} Anzahl geloeschter Eintraege
|
|
*/
|
|
export async function verwerfeWartende(bookingId) {
|
|
pruefeText(bookingId, 'bookingId')
|
|
const wartend = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.equal('bookingId', bookingId)])
|
|
let geloescht = 0
|
|
for (const eintrag of wartend) {
|
|
const weg = await withLocks([`termine-mail:${eintrag.$id}`], async () => {
|
|
let frisch
|
|
try {
|
|
frisch = await getDocument(col(), eintrag.$id)
|
|
} catch (err) {
|
|
if (err.status === 404) return false
|
|
throw err
|
|
}
|
|
if (frisch.status !== 'wartet') return false
|
|
try {
|
|
await deleteDocument(col(), frisch.$id)
|
|
} catch (err) {
|
|
if (err.status === 404) return false
|
|
throw err
|
|
}
|
|
unverbucht.delete(frisch.$id)
|
|
return true
|
|
})
|
|
if (weg) geloescht += 1
|
|
}
|
|
return geloescht
|
|
}
|
|
|
|
/**
|
|
* Taeglicher Job, vor raeumeAuf: Eintraege, die laenger als 48 Stunden (ab createdAt) in `wartet` stehen, werden zu
|
|
* `fehler` (das Portal zeigt dann den Zustellhinweis) und bekommen als Loeschtermin jetzt + 48 Stunden, damit raeumeAuf
|
|
* sie erst dann loescht und nicht noch im selben Lauf. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem
|
|
* Lesen: ein Eintrag, der inzwischen gesendet wurde (oder dessen Versand gerade laeuft), bleibt, wie er ist.
|
|
* @param {Date} now
|
|
* @returns {Promise<number>} Anzahl beendeter Eintraege
|
|
*/
|
|
export async function beendeFestgefahrene(now) {
|
|
const nowMs = pruefeNow(now, 'beendeFestgefahrene')
|
|
const grenze = nowMs - WARTEN_MAX_MS
|
|
const alt = (await listAllDocuments(col(), [Query.equal('status', 'wartet')])).filter((eintrag) => zeitMs(eintrag.createdAt) < grenze)
|
|
let beendet = 0
|
|
for (const eintrag of alt) {
|
|
const geaendert = await withLocks([`termine-mail:${eintrag.$id}`], async () => {
|
|
let frisch
|
|
try {
|
|
frisch = await getDocument(col(), eintrag.$id)
|
|
} catch (err) {
|
|
if (err.status === 404) return false
|
|
throw err
|
|
}
|
|
if (frisch.status !== 'wartet' || !(zeitMs(frisch.createdAt) < grenze) || unverbucht.has(frisch.$id)) return false
|
|
await updateDocument(col(), frisch.$id, {
|
|
status: 'fehler',
|
|
naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS),
|
|
fehler: 'Nicht zugestellt: länger als 48 Stunden in der Warteschlange.',
|
|
})
|
|
return true
|
|
})
|
|
if (geaendert) beendet += 1
|
|
}
|
|
return beendet
|
|
}
|
|
|
|
/** Wie viele veraltete G01 dieser Prozess seit dem Start verworfen hat (Betriebsmeldung, A10b). */
|
|
export function verworfeneG01() {
|
|
return verworfenG01
|
|
}
|
|
|
|
/** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */
|
|
export function pausiertBis() {
|
|
return pauseBisMs ? new Date(pauseBisMs) : null
|
|
}
|
|
|
|
/** Nur fuer Tests: Pause, Budgetfenster, nicht gespeicherte Versände und den Zaehler verworfener G01 zuruecksetzen. */
|
|
export function resetOutboxForTests() {
|
|
pauseBisMs = 0
|
|
plaetze = []
|
|
unverbucht.clear()
|
|
verworfenG01 = 0
|
|
}
|