Terminplaner: Hintergrund-Jobs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
efe17551c2
commit
485ff3bc15
6 files changed
+1428
-3
No files matched your search
@@ -23,6 +23,7 @@ import { purgeViewLog } from './services/customerViewLog.js'
|
||||
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
||||
import { runActivation } from './services/siteFeatures.js'
|
||||
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
||||
import { fuenfMinutenJob, minutenJob, taeglicherJob } from './services/termine/jobs.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
@@ -192,6 +193,21 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
|
||||
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// Terminplaner (Modus off: jeder Lauf kehrt sofort zurueck, ohne Abfrage; ein noch laufender Job wird nicht ueberlappt):
|
||||
// jede Minute abgelaufene Haltungen verfallen lassen und die Mail-Warteschlange senden, alle 5 Minuten Erinnerungen, erledigte
|
||||
// Termine und vergangene Anfragen, taeglich (3 Minuten nach dem Start beginnend) Loeschfristen und Aufraeumen. Die Jobs melden
|
||||
// einzelne Fehler selbst; hier faengt der Rahmen alles Unerwartete ab. Im Log stehen keine Gastdaten.
|
||||
const termineMinutenJob = () =>
|
||||
minutenJob(new Date()).catch((err) => console.warn('[termine] Minutenjob fehlgeschlagen:', err.message))
|
||||
setInterval(termineMinutenJob, 60 * 1000).unref()
|
||||
const termineFuenfMinutenJob = () =>
|
||||
fuenfMinutenJob(new Date()).catch((err) => console.warn('[termine] Fünf-Minuten-Job fehlgeschlagen:', err.message))
|
||||
setInterval(termineFuenfMinutenJob, 5 * 60 * 1000).unref()
|
||||
const termineTagesJob = () =>
|
||||
taeglicherJob(new Date()).catch((err) => console.warn('[termine] Tagesjob fehlgeschlagen:', err.message))
|
||||
setTimeout(termineTagesJob, 180 * 1000).unref()
|
||||
setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
|
||||
const repoAssign = () =>
|
||||
runRepoAssignJob({ now: new Date() }).catch((err) =>
|
||||
|
||||
@@ -441,7 +441,7 @@ export function kannAbsagen(einstellung, buchung, now) {
|
||||
* Zeitpunkt des Vertragsschlusses in ms: bestaetigtAt bei einer sofort buchbaren Leistung, angenommenAt bei einer vom
|
||||
* Betrieb angenommenen Anfrage; NaN, wenn es keinen gibt (auch eine Anfrage, die nur bestaetigt wurde, ist kein Vertrag).
|
||||
*/
|
||||
function vertragAm(buchung) {
|
||||
export function vertragAm(buchung) {
|
||||
if (buchung.buchungsart === 'sofort') return zeitMs(buchung.bestaetigtAt)
|
||||
if (buchung.buchungsart === 'anfrage') return zeitMs(buchung.angenommenAt)
|
||||
return Number.NaN
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { config } from '../../config.js'
|
||||
import { Query, deleteDocument, listAllDocuments } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
|
||||
import {
|
||||
abgelaufeneHaltungen,
|
||||
aendere,
|
||||
erinnerungsKandidaten,
|
||||
hole,
|
||||
loesche,
|
||||
loeschKandidaten,
|
||||
vergangeneAnfragen,
|
||||
zuErledigen,
|
||||
} from './buchungen.js'
|
||||
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
|
||||
import { anGast } from './mails.js'
|
||||
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
|
||||
import { termineAus } from './schalter.js'
|
||||
import { buchungsNummer } from './token.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
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
|
||||
* 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
|
||||
*
|
||||
* Gemeinsame Regeln:
|
||||
* - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
|
||||
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
|
||||
* Log je Ueberlappung).
|
||||
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
|
||||
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
|
||||
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
|
||||
* - Jede Zustandsaenderung einer Buchung laeuft unter `termine-buchung:<id>` mit frischem Lesen und erneuter Pruefung der
|
||||
* Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job
|
||||
* ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange.
|
||||
* - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre).
|
||||
*/
|
||||
|
||||
const MIN_MS = 60 * 1000
|
||||
const STUNDE_MS = 60 * MIN_MS
|
||||
const TAG_MS = 24 * STUNDE_MS
|
||||
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
|
||||
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
|
||||
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
|
||||
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
|
||||
const AUFBEWAHRUNG_VORGABE = 90
|
||||
const AUFBEWAHRUNG_MIN = 14
|
||||
const AUFBEWAHRUNG_MAX = 365
|
||||
|
||||
const laufend = new Set()
|
||||
const ueberlappGemeldet = new Set()
|
||||
/** Zeitpunkt (ms) der letzten Warnung wegen fehlender Collection je Job. */
|
||||
const letzteWarnung = new Map()
|
||||
|
||||
const fehlendeCollection = (err) => err?.status === 404 && (err.type === 'collection_not_found' || err.type === 'database_not_found')
|
||||
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
|
||||
|
||||
/** Fehler eines Schritts ins Log (ohne Gastdaten) und in die Zaehlung; fehlende Collection hoechstens einmal je Stunde und Job. */
|
||||
function melde(lauf, was, err, bookingId = null) {
|
||||
lauf.fehler += 1
|
||||
if (fehlendeCollection(err)) {
|
||||
const zuletzt = letzteWarnung.get(lauf.job)
|
||||
if (zuletzt !== undefined && lauf.nowMs - zuletzt < STUNDE_MS) return
|
||||
letzteWarnung.set(lauf.job, lauf.nowMs)
|
||||
console.warn(`[termine-jobs] ${lauf.job}: ${err.type}, die Termine-Einrichtung ist unvollständig (Warnung höchstens einmal je Stunde)`)
|
||||
return
|
||||
}
|
||||
const nummer = bookingId ? ` (${buchungsNummer(bookingId)})` : ''
|
||||
console.error(`[termine-jobs] ${lauf.job}: ${was}${nummer}: ${fehlerText(err)}`)
|
||||
}
|
||||
|
||||
/** Fuehrt einen Schritt aus; ein Fehler wird gemeldet und ergibt `ersatz`. */
|
||||
async function schritt(lauf, was, fn, ersatz) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
melde(lauf, was, err)
|
||||
return ersatz
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
|
||||
*/
|
||||
async function fuehreAus(job, now, arbeit) {
|
||||
const nowMs = pruefeNow(now, job)
|
||||
if (termineAus()) return { aus: true }
|
||||
if (laufend.has(job)) {
|
||||
if (!ueberlappGemeldet.has(job)) {
|
||||
ueberlappGemeldet.add(job)
|
||||
console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
|
||||
}
|
||||
return { uebersprungen: true }
|
||||
}
|
||||
laufend.add(job)
|
||||
try {
|
||||
return await arbeit({ job, nowMs, fehler: 0 })
|
||||
} finally {
|
||||
laufend.delete(job)
|
||||
ueberlappGemeldet.delete(job)
|
||||
}
|
||||
}
|
||||
|
||||
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
|
||||
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0 })
|
||||
|
||||
/**
|
||||
* Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt.
|
||||
* => Ergebnis von fn | false (Buchung fehlt) | undefined (Fehler, im Log; die Liste laeuft weiter)
|
||||
*/
|
||||
async function mitFrischer(lauf, was, id, fn) {
|
||||
try {
|
||||
return await withLocks([`termine-buchung:${id}`], async () => {
|
||||
const frisch = await hole(id)
|
||||
return frisch ? fn(frisch) : false
|
||||
})
|
||||
} catch (err) {
|
||||
melde(lauf, was, err, id)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Minutenjob
|
||||
|
||||
/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */
|
||||
async function verfalleHaltungen(lauf, now) {
|
||||
let verfallen = 0
|
||||
for (const kandidat of await abgelaufeneHaltungen(now)) {
|
||||
const ok = await mitFrischer(lauf, 'Haltung nicht verfallen', kandidat.$id, async (frisch) => {
|
||||
if (frisch.status !== 'gehalten' || !(zeitMs(frisch.heldUntil) <= lauf.nowMs)) return false
|
||||
await aendere(frisch.$id, { status: 'verfallen', updatedAt: iso(now) })
|
||||
await gibFreiSicher(frisch.$id)
|
||||
return true
|
||||
})
|
||||
if (ok) verfallen += 1
|
||||
}
|
||||
return verfallen
|
||||
}
|
||||
|
||||
/**
|
||||
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
|
||||
*/
|
||||
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 })
|
||||
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Fuenf-Minuten-Job
|
||||
|
||||
/**
|
||||
* Ist die Erinnerung jetzt zu senden? bestaetigt, Wunsch, kein Marker, Adresse da, Beginn in (jetzt + 1 h, jetzt + stundenVorher]
|
||||
* und Vertragsschluss VOR Beginn - stundenVorher (wer erst danach gebucht hat, bekommt keine). Ohne Vertragsschluss nein.
|
||||
*/
|
||||
function erinnerungFaellig(buchung, stundenVorher, nowMs) {
|
||||
if (buchung.status !== 'bestaetigt' || buchung.erinnerung !== true || buchung.erinnerungGesendetAt) return false
|
||||
if (typeof buchung.gastEmail !== 'string' || buchung.gastEmail === '') return false
|
||||
const start = zeitMs(buchung.startsAt)
|
||||
const fruehestens = start - stundenVorher * STUNDE_MS
|
||||
if (!(start > nowMs + STUNDE_MS && fruehestens <= nowMs)) return false
|
||||
return vertragAm(buchung) < fruehestens
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungen: unter der Buchungssperre frisch lesen und pruefen, erst den Marker erinnerungGesendetAt setzen, dann (ausserhalb
|
||||
* der Sperre) G05 einreihen. Scheitert das Einreihen, bleibt der Marker: eine Erinnerung geht hoechstens einmal raus.
|
||||
* Der Kontext einer Funktion (Einstellung mit stundenVorher) wird je Lauf einmal gelesen. Ohne Funktion oder Einstellung: keine
|
||||
* Erinnerung, kein Marker.
|
||||
*/
|
||||
async function sendeErinnerungen(lauf, now) {
|
||||
const kontexte = new Map()
|
||||
const kontext = (featureId) => {
|
||||
if (!kontexte.has(featureId)) {
|
||||
kontexte.set(
|
||||
featureId,
|
||||
kontextPerFeature(featureId).catch((err) => {
|
||||
melde(lauf, 'Funktion nicht gelesen', err)
|
||||
return null
|
||||
}),
|
||||
)
|
||||
}
|
||||
return kontexte.get(featureId)
|
||||
}
|
||||
|
||||
let gesendet = 0
|
||||
for (const kandidat of await erinnerungsKandidaten(now)) {
|
||||
const id = kandidat.$id
|
||||
const ctx = await kontext(kandidat.featureId)
|
||||
const stundenVorher = ctx?.einstellung?.erinnerung?.stundenVorher
|
||||
if (!Number.isInteger(stundenVorher) || stundenVorher < 1) continue
|
||||
if (!erinnerungFaellig(kandidat, stundenVorher, lauf.nowMs)) continue
|
||||
|
||||
const markiert = await mitFrischer(lauf, 'Erinnerung nicht vermerkt', id, async (frisch) =>
|
||||
erinnerungFaellig(frisch, stundenVorher, lauf.nowMs) ? aendere(id, { erinnerungGesendetAt: iso(now), updatedAt: iso(now) }) : false,
|
||||
)
|
||||
if (!markiert) continue
|
||||
try {
|
||||
await anGast('G05', ctx, markiert, {}, now)
|
||||
gesendet += 1
|
||||
} catch (err) {
|
||||
melde(lauf, 'G05 nicht eingereiht', err, id)
|
||||
}
|
||||
}
|
||||
return gesendet
|
||||
}
|
||||
|
||||
/** bestaetigt mit Ende vor mehr als 1 Stunde (auch Sperren) => erledigt. Zellen bleiben (vergangen, der Tagesjob raeumt sie). */
|
||||
async function erledige(lauf, now) {
|
||||
let erledigt = 0
|
||||
for (const kandidat of await zuErledigen(now)) {
|
||||
const ok = await mitFrischer(lauf, 'Termin nicht erledigt', kandidat.$id, async (frisch) => {
|
||||
if (frisch.status !== 'bestaetigt' || !(zeitMs(frisch.endsAt) < lauf.nowMs - STUNDE_MS)) return false
|
||||
await aendere(frisch.$id, { status: 'erledigt', updatedAt: iso(now) })
|
||||
return true
|
||||
})
|
||||
if (ok) erledigt += 1
|
||||
}
|
||||
return erledigt
|
||||
}
|
||||
|
||||
/** angefragt mit Beginn in der Vergangenheit => abgelehnt vom System, Zellen frei, keine Mail. */
|
||||
async function lehneVergangeneAnfragenAb(lauf, now) {
|
||||
let abgelehnt = 0
|
||||
for (const kandidat of await vergangeneAnfragen(now)) {
|
||||
const ok = await mitFrischer(lauf, 'Anfrage nicht abgelehnt', kandidat.$id, async (frisch) => {
|
||||
if (frisch.status !== 'angefragt' || !(zeitMs(frisch.startsAt) < lauf.nowMs)) return false
|
||||
await aendere(frisch.$id, { status: 'abgelehnt', abgesagtVon: 'system', updatedAt: iso(now) })
|
||||
await gibFreiSicher(frisch.$id)
|
||||
return true
|
||||
})
|
||||
if (ok) abgelehnt += 1
|
||||
}
|
||||
return abgelehnt
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle 5 Minuten: Erinnerungen, erledigte Termine, vergangene Anfragen.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ erinnerungen: number, erledigt: number, abgelehnt: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
|
||||
*/
|
||||
export async function fuenfMinutenJob(now) {
|
||||
return fuehreAus('fuenfMinutenJob', now, async (lauf) => {
|
||||
const erinnerungen = await schritt(lauf, 'Erinnerungen', () => sendeErinnerungen(lauf, now), 0)
|
||||
const erledigt = await schritt(lauf, 'Erledigt', () => erledige(lauf, now), 0)
|
||||
const abgelehnt = await schritt(lauf, 'Vergangene Anfragen', () => lehneVergangeneAnfragenAb(lauf, now), 0)
|
||||
return { erinnerungen, erledigt, abgelehnt, fehler: lauf.fehler }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Tagesjob
|
||||
|
||||
/**
|
||||
* Aufbewahrung (Tage nach Terminende) einer Funktion. Nicht gespeichert oder unlesbar (null aus ladeEinstellung) oder ausserhalb
|
||||
* 14-365: 90. Scheitert das Lesen selbst (Datenbank), null: die Buchungen dieser Funktion bleiben bis zum naechsten Lauf, es wird
|
||||
* nie frueher geloescht als eingestellt.
|
||||
*/
|
||||
async function aufbewahrungTage(lauf, featureId) {
|
||||
if (typeof featureId !== 'string' || featureId === '') return AUFBEWAHRUNG_VORGABE
|
||||
try {
|
||||
const tage = (await ladeEinstellung(featureId))?.einstellung?.aufbewahrungTage
|
||||
return Number.isInteger(tage) && tage >= AUFBEWAHRUNG_MIN && tage <= AUFBEWAHRUNG_MAX ? tage : AUFBEWAHRUNG_VORGABE
|
||||
} catch (err) {
|
||||
melde(lauf, 'Aufbewahrung nicht lesbar, Funktion bleibt unberührt', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). => true | false | undefined (Fehler) */
|
||||
function loescheMitZellen(lauf, id, status) {
|
||||
return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => {
|
||||
if (status !== null && frisch.status !== status) return false
|
||||
await gibZellenFrei(id)
|
||||
await loesche(id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function loeschfristen(lauf, now) {
|
||||
const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }
|
||||
const tage = new Map()
|
||||
const tageFuer = (featureId) => {
|
||||
if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId))
|
||||
return tage.get(featureId)
|
||||
}
|
||||
// Je Status: Kandidaten ab der fruehesten moeglichen Frist; bei erledigt/nichterschienen entscheidet die Frist der Funktion
|
||||
const fristen = [
|
||||
['verfallen', VERFALLEN_BEHALTEN_MS, false],
|
||||
['abgesagt', ABGESAGT_BEHALTEN_MS, false],
|
||||
['abgelehnt', ABGESAGT_BEHALTEN_MS, false],
|
||||
['erledigt', AUFBEWAHRUNG_MIN * TAG_MS, true],
|
||||
['nichterschienen', AUFBEWAHRUNG_MIN * TAG_MS, true],
|
||||
]
|
||||
for (const [status, behalten, jeFunktion] of fristen) {
|
||||
await schritt(lauf, `Löschfrist ${status}`, async () => {
|
||||
for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - behalten))) {
|
||||
if (jeFunktion) {
|
||||
const t = await tageFuer(kandidat.featureId)
|
||||
if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue
|
||||
}
|
||||
if (await loescheMitZellen(lauf, kandidat.$id, status)) geloescht[status] += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
return geloescht
|
||||
}
|
||||
|
||||
/**
|
||||
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
|
||||
* loeschen. Scheitert eine Buchung, bleibt die Einstellung bis zum naechsten Lauf. Zellen ohne Buchung raeumt danach der Tagesjob.
|
||||
*/
|
||||
async function loescheAbgeschaltete(lauf) {
|
||||
const erg = { funktionen: 0, buchungen: 0 }
|
||||
const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS
|
||||
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
|
||||
for (const funktion of funktionen) {
|
||||
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
|
||||
erg.funktionen += 1
|
||||
const buchungen = await schritt(lauf, 'Buchungen abgeschalteter Funktion', () => listAllDocuments(config.collections.termineBookings, [Query.equal('featureId', funktion.$id)]), null)
|
||||
if (buchungen === null) continue
|
||||
let vollstaendig = true
|
||||
for (const buchung of buchungen) {
|
||||
const weg = await loescheMitZellen(lauf, buchung.$id, null)
|
||||
if (weg) erg.buchungen += 1
|
||||
if (weg === undefined) vollstaendig = false
|
||||
}
|
||||
if (vollstaendig) {
|
||||
await schritt(lauf, 'Einstellung abgeschalteter Funktion', () =>
|
||||
withLocks([`termine-cfg:${funktion.$id}`], async () => {
|
||||
try {
|
||||
await deleteDocument(config.collections.termineConfig, funktion.$id)
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) throw err
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return erg
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten
|
||||
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen. Baustein des
|
||||
* Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number }>}
|
||||
*/
|
||||
export function wendeLoeschfristenAn(now) {
|
||||
return loeschfristen(einzelLauf('loeschfristen', now), now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen. Baustein des Tagesjobs, ohne Schalter- und
|
||||
* Ueberlappungspruefung (fuer Tests).
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ funktionen: number, buchungen: number }>}
|
||||
*/
|
||||
export function loescheAbgeschalteteFunktionen(now) {
|
||||
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
|
||||
}
|
||||
|
||||
/**
|
||||
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
|
||||
* Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
|
||||
*/
|
||||
export async function taeglicherJob(now) {
|
||||
return fuehreAus('taeglicherJob', now, async (lauf) => {
|
||||
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
|
||||
const loeschung = await loeschfristen(lauf, now)
|
||||
const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 })
|
||||
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
|
||||
const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0)
|
||||
return { festgefahren, loeschfristen: loeschung, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler }
|
||||
})
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */
|
||||
export function resetJobsForTests() {
|
||||
laufend.clear()
|
||||
ueberlappGemeldet.clear()
|
||||
letzteWarnung.clear()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, u
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendGuestMail } from '../mailer.js'
|
||||
import { renderMail } from '../mailTemplates.js'
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
import { pruefeNow, pruefeText, zeitMs } from './util.js'
|
||||
|
||||
/**
|
||||
* Mail-Warteschlange des Terminplaners (termineOutbox; Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6).
|
||||
@@ -39,6 +39,8 @@ 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
|
||||
@@ -314,6 +316,41 @@ export async function raeumeAuf(now) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user