verarbeite liest nicht mehr alle wartenden Eintraege, sondern je Klasse (vorrang, normal, nachrangig) eine begrenzte Liste (config.termine.mailListe, Standard 100) mit select und orderAsc(naechsterVersuch). Bei Pause oder leerem Gesamtbudget endet der Lauf ohne Abfrage, bei erreichter Reserve entfaellt die nachrangige Klasse. Funktionen mit ausgeschoepftem Kontingent kommen in eine Ausschlussliste (hoechstens 90 Werte); hoechstens vier weitere Runden je Klasse, damit keine Funktion hinter einer vollen verhungert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
542 lines
24 KiB
JavaScript
542 lines
24 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { Query, createDocument, deleteDocument, getDocument, 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).
|
|
*
|
|
* `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) 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
|
|
/** 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). */
|
|
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 = []
|
|
/** 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')
|
|
* @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'|'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; uebersprungen = Eintrag fehlt oder ist schon
|
|
* gesendet/fehlgeschlagen
|
|
*/
|
|
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'
|
|
}
|
|
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'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 (Appwrite erlaubt hoechstens 100 Werte je Abfrage). */
|
|
const AUSSCHLUSS_MAX = 90
|
|
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
|
|
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
|
|
|
|
/** Filter auf die Art der Mail: die Arten der Klasse (equal) bzw. alle anderen (notEqual, die Klasse normal). */
|
|
function klassenFilter(klasse) {
|
|
if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG])
|
|
if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG])
|
|
return Query.notEqual('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),
|
|
...(blockiert.size > 0 ? [Query.notEqual('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 {
|
|
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') {
|
|
lauf.gesendet += 1
|
|
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) 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 }
|
|
}
|
|
|
|
/** 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). Das Portal zeigt dort
|
|
* „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler).
|
|
* @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 und fehlgeschlagene 48 Stunden nach dem Fehler
|
|
* (beides 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']), 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 und fehlgeschlagene Eintraege behandelt raeumeAuf.
|
|
* @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
|
|
}
|
|
|
|
/** 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 und nicht gespeicherte Versände zuruecksetzen. */
|
|
export function resetOutboxForTests() {
|
|
pauseBisMs = 0
|
|
plaetze = []
|
|
unverbucht.clear()
|
|
}
|