From 42c98c8b290eac2ca29cd055a0b094331cb18369 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 18:05:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(kalender):=20Hintergrundjobs=20f=C3=BCr=20?= =?UTF-8?q?Abgleich=20und=20Aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle 15 Minuten abgleichen, nachts alte Termine und abgelaufene WebAuthn-Herausforderungen entfernen. Ohne das Aufräumen bliebe der Terminspiegel für immer stehen. Der Riegel über globalThis ist nötig, weil Next im Entwicklungsbetrieb Module mehrfach lädt — sonst liefen nach ein paar Änderungen ein Dutzend Zeitpläne parallel. Im Demo-Modus starten gar keine Jobs: die Screenshot-Instanz soll sich nicht selbst verändern, während sie abfotografiert wird. Protokolliert werden nur Zahlen, nie Kontonamen oder Termininhalte. Co-Authored-By: Claude Opus 5 --- src/instrumentation.ts | 15 +++++++++ src/server/jobs.ts | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/instrumentation.ts create mode 100644 src/server/jobs.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..91c9d1b --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,15 @@ +/** + * Wird von Next einmal beim Hochfahren des Servers ausgeführt. + * + * Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch + * node-cron. Der Demo-Modus startet bewusst keine Jobs — die + * Screenshot-Instanz soll sich nicht selbst verändern, während sie + * abfotografiert wird. + */ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + if (process.env.DEMO_MODUS === 'true') return + + const { jobsStarten } = await import('@/server/jobs') + jobsStarten() +} diff --git a/src/server/jobs.ts b/src/server/jobs.ts new file mode 100644 index 0000000..a7df519 --- /dev/null +++ b/src/server/jobs.ts @@ -0,0 +1,72 @@ +import 'server-only' +import cron from 'node-cron' +import { db } from '@/server/db' +import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' +import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' +import { jetzt } from '@/server/zeitgeber' + +/** + * Hintergrundjobs. + * + * Werden einmal je Prozess gestartet. Im Entwicklungsbetrieb lädt Next Module + * mehrfach neu — deshalb der Riegel über globalThis, sonst liefen nach ein + * paar Änderungen ein Dutzend Zeitpläne parallel. + */ + +const globalerBereich = globalThis as unknown as { planerJobs?: boolean } + +/** Alle 15 Minuten abgleichen; öfter bringt nichts und kostet nur Aufrufe. */ +const ABGLEICH_PLAN = '*/15 * * * *' +/** Einmal nachts aufräumen. */ +const AUFRAEUM_PLAN = '17 3 * * *' + +async function alleKontenAbgleichen(): Promise { + const zeitpunkt = jetzt() + const konten = await db.kalenderkonto.findMany({ + where: { aktiv: true }, + select: { id: true }, + }) + + let ok = 0 + let gescheitert = 0 + for (const konto of konten) { + const bericht = await kontoAbgleichen(konto.id, zeitpunkt) + if (bericht.ok) ok++ + else gescheitert++ + } + + if (gescheitert > 0) { + // Nur Zahlen, keine Namen und keine Termininhalte. + console.warn( + `Kalenderabgleich: ${ok} Konto/Konten erfolgreich, ${gescheitert} fehlgeschlagen.`, + ) + } +} + +/** + * Entfernt Termine, die aus dem Abgleichfenster herausgewandert sind. + * Ohne das bliebe der Spiegel für immer stehen. + */ +async function alteTermineEntfernen(): Promise { + const fenster = abgleichFenster(jetzt()) + await db.termin.deleteMany({ where: { ende: { lt: fenster.von } } }) +} + +export function jobsStarten(): void { + if (globalerBereich.planerJobs) return + globalerBereich.planerJobs = true + + cron.schedule(ABGLEICH_PLAN, () => { + void alleKontenAbgleichen().catch((fehler: unknown) => { + console.error('Kalenderabgleich abgebrochen:', fehler) + }) + }) + + cron.schedule(AUFRAEUM_PLAN, () => { + void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) + .then(() => undefined) + .catch((fehler: unknown) => { + console.error('Aufräumen abgebrochen:', fehler) + }) + }) +}