G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt, aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des Anbieters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
621 lines
29 KiB
JavaScript
621 lines
29 KiB
JavaScript
import { config } from '../../config.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'
|
|
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). Dazu die beiden
|
|
* Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger), G12 Eingangsbestaetigung des Widerrufs und G15 Eingang der Bestellung (SEPA, Zahlung noch offen). Die
|
|
* uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'.
|
|
*/
|
|
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12', 'G15'])
|
|
/** 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 (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 {
|
|
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
|
|
}
|