From e0e11c85b2c69098400c4b7feed94943c60e6627 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 08:59:31 +0200 Subject: [PATCH] feat: Versand per Web-Push und E-Mail Push ueber VAPID, Mail ueber SMTP, beide unabhaengig zuschaltbar. Die Schluessel eines Push-Geraets liegen verschluesselt in der Datenbank. Ein Geraet, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort entfernt; andere Fehler zaehlen hoch und fuehren nach fuenf Versuchen zum selben Ergebnis. Eine Liste toter Abos putzt sich so von allein. Der Nachweis wird vor dem Senden geschrieben, nicht danach: lieber eine verpasste Nachricht als eine, die im Minutentakt erneut versucht wird. Weder Endpunkt noch Schluessel noch Mailinhalt landen im Protokoll. Co-Authored-By: Claude Opus 5 --- .prettierignore | 1 + eslint.config.mjs | 2 + public/sw.js | 53 ++++ src/app/api/push/route.ts | 120 +++++++++ src/server/benachrichtigung/mail.ts | 110 ++++++++ src/server/benachrichtigung/push.ts | 152 +++++++++++ src/server/benachrichtigung/versand.ts | 349 +++++++++++++++++++++++++ 7 files changed, 787 insertions(+) create mode 100644 public/sw.js create mode 100644 src/app/api/push/route.ts create mode 100644 src/server/benachrichtigung/mail.ts create mode 100644 src/server/benachrichtigung/push.ts create mode 100644 src/server/benachrichtigung/versand.ts diff --git a/.prettierignore b/.prettierignore index ebb889a..6f686b6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ test-results playwright-report coverage devlog/zeiten.csv +public/sw.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 09e0de2..13448bc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,8 @@ export default tseslint.config( // Vom Generator erzeugt, nicht von Hand gepflegt. 'src/generated/**', 'next-env.d.ts', + // Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette. + 'public/sw.js', ], }, ...naechstesWebVitals, diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bbe231e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,53 @@ +/* + * Service Worker des Planers. + * + * Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die + * richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb + * veralteter Planer wäre schlimmer als gar keiner. + */ + +self.addEventListener('install', () => { + // Sofort übernehmen, nicht auf das Schließen aller Tabs warten. + self.skipWaiting() +}) + +self.addEventListener('activate', (ereignis) => { + ereignis.waitUntil(self.clients.claim()) +}) + +self.addEventListener('push', (ereignis) => { + let daten = { titel: 'Planer', text: '', pfad: '/' } + try { + if (ereignis.data) daten = { ...daten, ...ereignis.data.json() } + } catch { + // Kaputte Nutzlast: lieber eine karge Meldung als gar keine. + } + + ereignis.waitUntil( + self.registration.showNotification(daten.titel, { + body: daten.text, + lang: 'de', + // Gleiche Anlässe überschreiben einander, statt sich zu stapeln. + tag: daten.anlass || 'planer', + data: { pfad: daten.pfad }, + }), + ) +}) + +self.addEventListener('notificationclick', (ereignis) => { + ereignis.notification.close() + const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/' + + ereignis.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => { + // Ist der Planer schon offen, dorthin wechseln statt ein zweites + // Fenster aufzumachen. + for (const f of fenster) { + if (f.url.includes(self.location.origin)) { + return f.focus().then(() => f.navigate(pfad)) + } + } + return self.clients.openWindow(pfad) + }), + ) +}) diff --git a/src/app/api/push/route.ts b/src/app/api/push/route.ts new file mode 100644 index 0000000..77cadc6 --- /dev/null +++ b/src/app/api/push/route.ts @@ -0,0 +1,120 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { auditSchreiben } from '@/server/audit' +import { verlangeAnmeldungFuerApi } from '@/server/berechtigung' +import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push' +import { umgebung } from '@/server/umgebung' + +export const dynamic = 'force-dynamic' + +/** + * An- und Abmelden eines Browsers für Push-Nachrichten. + * + * Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird + * aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die + * Einrichtung nach außen zu zeigen. + */ + +const anmeldung = z.object({ + endpunkt: z.url(), + p256dh: z.string().min(1).max(500), + auth: z.string().min(1).max(500), + bezeichnung: z.string().trim().max(60).nullable().default(null), +}) + +export async function GET() { + const benutzer = await verlangeAnmeldungFuerApi() + if (!benutzer) { + return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }) + } + + const einst = umgebung() + return NextResponse.json( + { + aktiv: einst.pushAktiv, + oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ) +} + +export async function POST(anfrage: Request) { + const benutzer = await verlangeAnmeldungFuerApi() + if (!benutzer) { + return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }) + } + if (!umgebung().pushAktiv) { + return NextResponse.json( + { fehler: 'Push ist auf diesem Server nicht eingerichtet.' }, + { status: 400 }, + ) + } + + let inhalt: unknown + try { + inhalt = await anfrage.json() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const gelesen = anmeldung.safeParse(inhalt) + if (!gelesen.success) { + return NextResponse.json( + { fehler: 'Die Anmeldung war unvollständig.' }, + { status: 400 }, + ) + } + + const geraet = await geraetAnmelden( + benutzer.id, + { + endpunkt: gelesen.data.endpunkt, + p256dh: gelesen.data.p256dh, + auth: gelesen.data.auth, + }, + gelesen.data.bezeichnung, + ) + + await auditSchreiben({ + akteur: { id: benutzer.id, email: benutzer.email }, + aktion: 'push.angemeldet', + objektTyp: 'PushGeraet', + objektId: geraet.id, + // Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen. + details: { bezeichnung: gelesen.data.bezeichnung }, + }) + + return NextResponse.json({ ok: true }) +} + +const abmeldung = z.object({ endpunkt: z.url() }) + +export async function DELETE(anfrage: Request) { + const benutzer = await verlangeAnmeldungFuerApi() + if (!benutzer) { + return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }) + } + + let inhalt: unknown + try { + inhalt = await anfrage.json() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const gelesen = abmeldung.safeParse(inhalt) + if (!gelesen.success) { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + await geraetAbmelden(gelesen.data.endpunkt) + + await auditSchreiben({ + akteur: { id: benutzer.id, email: benutzer.email }, + aktion: 'push.abgemeldet', + objektTyp: 'PushGeraet', + objektId: null, + }) + + return NextResponse.json({ ok: true }) +} diff --git a/src/server/benachrichtigung/mail.ts b/src/server/benachrichtigung/mail.ts new file mode 100644 index 0000000..29b8e47 --- /dev/null +++ b/src/server/benachrichtigung/mail.ts @@ -0,0 +1,110 @@ +import 'server-only' +import nodemailer, { type Transporter } from 'nodemailer' +import type { Meldung } from '@/lib/benachrichtigung' +import { umgebung } from '@/server/umgebung' + +/** + * E-Mail-Versand. + * + * Jede Mail geht als reiner Text **und** als HTML hinaus. Wer in einem + * Textclient liest, bekommt etwas Lesbares; wer HTML liest, etwas + * Aufgeräumtes. + * + * Der Inhalt bleibt knapp: Titel, ein paar Zeilen, ein Link. Eine Mail, die + * alles wiederholt, was ohnehin in der Anwendung steht, liest niemand. + */ + +let versand: Transporter | null = null + +function transporter(): Transporter | null { + const einst = umgebung() + if (!einst.mailAktiv) return null + if (versand) return versand + + versand = nodemailer.createTransport({ + host: einst.SMTP_HOST, + port: einst.SMTP_PORT, + secure: einst.SMTP_TLS, + ...(einst.SMTP_BENUTZER + ? { auth: { user: einst.SMTP_BENUTZER, pass: einst.SMTP_PASSWORT } } + : {}), + }) + return versand +} + +/** Nur für Tests und nach einer Konfigurationsänderung. */ +export function versandVergessen(): void { + versand = null +} + +function alsHtml(meldung: Meldung, adresse: string): string { + // Bewusst schlichtes HTML ohne Bilder und ohne externe Stile: Mailclients + // blockieren beides ohnehin, und ein Text, der nur mit CSS lesbar ist, + // ist kaputt. + const zeilen = meldung.text + .split('\n') + .filter((z) => z.trim().length > 0) + .map((z) => `

${maskieren(z)}

`) + .join('') + + return [ + '
', + `

${maskieren(meldung.titel)}

`, + zeilen, + `

Im Planer ansehen

`, + '
', + ].join('') +} + +function maskieren(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +/** Schickt eine Meldung per Mail. Gibt zurück, ob es geklappt hat. */ +export async function mailSenden(empfaenger: string, meldung: Meldung): Promise { + const versender = transporter() + if (!versender) return false + + const einst = umgebung() + const adresse = `${einst.APP_URL}${meldung.pfad}` + + try { + await versender.sendMail({ + from: einst.SMTP_ABSENDER, + to: empfaenger, + subject: `Planer: ${meldung.titel}`, + text: `${meldung.text}\n\nIm Planer ansehen: ${adresse}\n`, + html: alsHtml(meldung, adresse), + }) + return true + } catch (fehler) { + // Nie den Mailinhalt ins Protokoll – nur, dass es nicht ging. + console.error( + 'Mailversand fehlgeschlagen:', + fehler instanceof Error ? fehler.message : 'unbekannter Fehler', + ) + return false + } +} + +/** Prüft die Verbindung, ohne etwas zu verschicken. */ +export async function mailVerbindungPruefen(): Promise< + { ok: true } | { ok: false; fehler: string } +> { + const versender = transporter() + if (!versender) return { ok: false, fehler: 'SMTP ist nicht eingerichtet.' } + + try { + await versender.verify() + return { ok: true } + } catch (fehler) { + return { + ok: false, + fehler: fehler instanceof Error ? fehler.message : 'Verbindung fehlgeschlagen.', + } + } +} diff --git a/src/server/benachrichtigung/push.ts b/src/server/benachrichtigung/push.ts new file mode 100644 index 0000000..906d039 --- /dev/null +++ b/src/server/benachrichtigung/push.ts @@ -0,0 +1,152 @@ +import 'server-only' +import webpush from 'web-push' +import type { Meldung } from '@/lib/benachrichtigung' +import { db } from '@/server/db' +import { entschluesseln, verschluesseln } from '@/server/krypto' +import { umgebung } from '@/server/umgebung' + +/** + * Web-Push. + * + * Die Schlüssel eines Push-Abos liegen verschlüsselt in der Datenbank: Wer + * sie hat, kann Nachrichten an fremde Browser schicken. Das ist kein + * Vollzugriff, aber auch nichts, was offen in einer Sicherung liegen sollte. + */ + +/** Ab so vielen Fehlversuchen hintereinander wird das Gerät entfernt. */ +const FEHLVERSUCHE_GRENZE = 5 + +let eingerichtet = false + +function vapidEinrichten(): boolean { + const einst = umgebung() + if (!einst.pushAktiv) return false + if (eingerichtet) return true + + webpush.setVapidDetails( + einst.VAPID_KONTAKT, + einst.VAPID_OEFFENTLICH, + einst.VAPID_PRIVAT, + ) + eingerichtet = true + return true +} + +export type PushAbo = { + endpunkt: string + p256dh: string + auth: string +} + +/** Legt ein Gerät an oder frischt es auf. */ +export async function geraetAnmelden( + benutzerId: string, + abo: PushAbo, + bezeichnung: string | null, +): Promise<{ id: string }> { + return db.pushGeraet.upsert({ + where: { endpunkt: abo.endpunkt }, + update: { + benutzerId, + p256dh: verschluesseln(abo.p256dh), + auth: verschluesseln(abo.auth), + fehlversuche: 0, + ...(bezeichnung ? { bezeichnung } : {}), + }, + create: { + benutzerId, + endpunkt: abo.endpunkt, + p256dh: verschluesseln(abo.p256dh), + auth: verschluesseln(abo.auth), + bezeichnung, + }, + select: { id: true }, + }) +} + +export async function geraetAbmelden(endpunkt: string): Promise { + await db.pushGeraet.deleteMany({ where: { endpunkt } }) +} + +export type PushBericht = { zugestellt: number; entfernt: number; gescheitert: number } + +/** + * Schickt eine Meldung an alle Geräte eines Benutzers. + * + * Antwortet der Push-Dienst mit 404 oder 410, gibt es das Abo nicht mehr — + * das Gerät wird sofort entfernt. Andere Fehler werden gezählt; erst nach + * mehreren hintereinander fliegt es raus, denn ein Aussetzer ist kein Grund, + * jemanden abzumelden. + */ +export async function pushSenden( + benutzerId: string, + meldung: Meldung, +): Promise { + if (!vapidEinrichten()) return { zugestellt: 0, entfernt: 0, gescheitert: 0 } + + const geraete = await db.pushGeraet.findMany({ + where: { benutzerId }, + select: { id: true, endpunkt: true, p256dh: true, auth: true, fehlversuche: true }, + }) + + const inhalt = JSON.stringify({ + titel: meldung.titel, + text: meldung.text, + pfad: meldung.pfad, + anlass: meldung.anlass, + }) + + let zugestellt = 0 + let entfernt = 0 + let gescheitert = 0 + + for (const geraet of geraete) { + const p256dh = entschluesseln(geraet.p256dh) + const auth = entschluesseln(geraet.auth) + + if (!p256dh || !auth) { + // Mit gewechseltem AUTH_SECRET sind die Schlüssel nicht mehr lesbar. + await db.pushGeraet.delete({ where: { id: geraet.id } }) + entfernt++ + continue + } + + try { + await webpush.sendNotification( + { endpoint: geraet.endpunkt, keys: { p256dh, auth } }, + inhalt, + { TTL: 60 * 60 }, + ) + await db.pushGeraet.update({ + where: { id: geraet.id }, + data: { zuletztBenutzt: new Date(), fehlversuche: 0 }, + }) + zugestellt++ + } catch (fehler) { + const status = + typeof fehler === 'object' && fehler !== null && 'statusCode' in fehler + ? Number(fehler.statusCode) + : 0 + + if (status === 404 || status === 410) { + await db.pushGeraet.delete({ where: { id: geraet.id } }) + entfernt++ + continue + } + + const neu = geraet.fehlversuche + 1 + if (neu >= FEHLVERSUCHE_GRENZE) { + await db.pushGeraet.delete({ where: { id: geraet.id } }) + entfernt++ + } else { + await db.pushGeraet.update({ + where: { id: geraet.id }, + data: { fehlversuche: neu }, + }) + gescheitert++ + } + } + } + + return { zugestellt, entfernt, gescheitert } +} diff --git a/src/server/benachrichtigung/versand.ts b/src/server/benachrichtigung/versand.ts new file mode 100644 index 0000000..c3be9ee --- /dev/null +++ b/src/server/benachrichtigung/versand.ts @@ -0,0 +1,349 @@ +import 'server-only' +import { + ABGLEICHFEHLER_AB_VERSUCH, + VOREINSTELLUNG, + meldungsSchluessel, + sendenEntscheiden, + ueberblickFaellig, + zeitblockErinnerungFaellig, + type Anlass, + type Einstellung, + type Meldung, +} from '@/lib/benachrichtigung' +import { + alsDauer, + alsTagesschluessel, + alsUhrzeitAnzeige, + tagesBeginn, + tagesEnde, +} from '@/lib/zeit' +import { db } from '@/server/db' +import { mailSenden } from '@/server/benachrichtigung/mail' +import { pushSenden } from '@/server/benachrichtigung/push' +import { jetzt } from '@/server/zeitgeber' + +/** + * Was wann an wen hinausgeht. + * + * Die Entscheidung selbst trifft die reine Logik in src/lib; hier wird nur + * beschafft, zugestellt und nachgehalten. Jede tatsächlich verschickte + * Meldung hinterlässt einen Nachweis — dieselbe Sache geht nie zweimal raus. + */ + +/** Wie lange Nachweise aufgehoben werden. Danach räumt der Job sie weg. */ +export const NACHWEIS_TAGE = 30 + +type BenutzerMitEinstellung = { + id: string + email: string + name: string | null + einstellung: Einstellung +} + +/** Liest die Einstellung; fehlt sie, gilt die zurückhaltende Voreinstellung. */ +export function alsEinstellung( + zeile: { + anlaesse: string + perPush: boolean + perMail: boolean + ueberblickUm: number + ruheVon: number + ruheBis: number + ruheAmWochenende: boolean + zeitblockVorlaufMinuten: number + } | null, +): Einstellung { + if (!zeile) return VOREINSTELLUNG + + const anlaesse = zeile.anlaesse + .split(',') + .map((a) => a.trim()) + .filter((a): a is Anlass => + ['tagesueberblick', 'faelligkeit', 'zeitblock', 'abgleichfehler'].includes(a), + ) + + return { + anlaesse, + perPush: zeile.perPush, + perMail: zeile.perMail, + ueberblickUm: zeile.ueberblickUm, + ruheVon: zeile.ruheVon, + ruheBis: zeile.ruheBis, + ruheAmWochenende: zeile.ruheAmWochenende, + zeitblockVorlaufMinuten: zeile.zeitblockVorlaufMinuten, + } +} + +async function empfaengerLaden(): Promise { + const zeilen = await db.benutzer.findMany({ + where: { aktiv: true }, + select: { + id: true, + email: true, + name: true, + benachrichtigung: { + select: { + anlaesse: true, + perPush: true, + perMail: true, + ueberblickUm: true, + ruheVon: true, + ruheBis: true, + ruheAmWochenende: true, + zeitblockVorlaufMinuten: true, + }, + }, + }, + }) + + return zeilen.map((z) => ({ + id: z.id, + email: z.email, + name: z.name, + einstellung: alsEinstellung(z.benachrichtigung), + })) +} + +/** + * Stellt eine Meldung zu, wenn sie darf, und hält den Nachweis fest. + * + * Der Nachweis wird **vor** dem Versand geschrieben. Bricht der Versand ab, + * gilt die Meldung trotzdem als erledigt — lieber eine verpasste Nachricht + * als eine, die im Minutentakt erneut versucht wird. + */ +export async function meldungZustellen( + benutzer: BenutzerMitEinstellung, + meldung: Meldung, + zeitpunkt: Date, +): Promise { + const gemeldet = await db.meldungsnachweis.findUnique({ + where: { + benutzerId_schluessel: { benutzerId: benutzer.id, schluessel: meldung.schluessel }, + }, + select: { id: true }, + }) + + const entscheidung = sendenEntscheiden( + benutzer.einstellung, + meldung, + new Set(gemeldet ? [meldung.schluessel] : []), + zeitpunkt, + ) + if (!entscheidung.senden) return false + + await db.meldungsnachweis.create({ + data: { + benutzerId: benutzer.id, + schluessel: meldung.schluessel, + gesendetAm: zeitpunkt, + }, + }) + + if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung) + if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung) + + return true +} + +// --- Die einzelnen Anlässe ------------------------------------------------- + +/** Baut den Tagesüberblick: was heute ansteht. */ +async function tagesueberblickBauen( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const heute = tagesBeginn(zeitpunkt) + const heuteEnde = tagesEnde(zeitpunkt) + + const [ueberfaellig, heuteFaellig, zeitbloecke] = await Promise.all([ + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { lt: heute }, + }, + }), + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { gte: heute, lte: heuteEnde }, + }, + }), + db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId }, + beginn: { gte: heute, lte: heuteEnde }, + }, + orderBy: { beginn: 'asc' }, + select: { beginn: true, ende: true, aufgabe: { select: { titel: true } } }, + }), + ]) + + // Ein Überblick, der nichts zu sagen hat, ist keine Nachricht wert. + if (ueberfaellig === 0 && heuteFaellig === 0 && zeitbloecke.length === 0) return null + + const teile: string[] = [] + if (ueberfaellig > 0) teile.push(`${ueberfaellig} überfällig`) + if (heuteFaellig > 0) teile.push(`${heuteFaellig} heute fällig`) + + const geplanteMinuten = zeitbloecke.reduce( + (summe, z) => summe + (z.ende.getTime() - z.beginn.getTime()) / 60_000, + 0, + ) + if (zeitbloecke.length > 0) { + teile.push(`${zeitbloecke.length} Zeitblöcke (${alsDauer(geplanteMinuten)})`) + } + + const zeilen = [teile.join(', ') + '.'] + for (const block of zeitbloecke.slice(0, 5)) { + zeilen.push(`${alsUhrzeitAnzeige(block.beginn)} ${block.aufgabe.titel}`) + } + if (zeitbloecke.length > 5) zeilen.push(`… und ${zeitbloecke.length - 5} weitere.`) + + return { + anlass: 'tagesueberblick', + schluessel: meldungsSchluessel( + 'tagesueberblick', + 'tag', + alsTagesschluessel(zeitpunkt), + ), + titel: 'Dein Tag', + text: zeilen.join('\n'), + pfad: '/', + } +} + +/** Aufgaben, die heute fällig werden. */ +async function faelligkeitenBauen(zeitpunkt: Date): Promise { + const aufgaben = await db.aufgabe.findMany({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { gte: tagesBeginn(zeitpunkt), lte: tagesEnde(zeitpunkt) }, + }, + select: { id: true, titel: true }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return aufgaben.map((aufgabe) => ({ + anlass: 'faelligkeit' as const, + schluessel: meldungsSchluessel('faelligkeit', aufgabe.id, tag), + titel: 'Heute fällig', + text: aufgabe.titel, + pfad: '/aufgaben', + })) +} + +/** Zeitblöcke, die gleich beginnen. */ +async function zeitblockErinnerungenBauen( + benutzer: BenutzerMitEinstellung, + zeitpunkt: Date, +): Promise { + const bloecke = await db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId: benutzer.id }, + beginn: { + gte: zeitpunkt, + lte: new Date( + zeitpunkt.getTime() + + (benutzer.einstellung.zeitblockVorlaufMinuten + 15) * 60_000, + ), + }, + }, + select: { id: true, beginn: true, aufgabe: { select: { titel: true } } }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return bloecke + .filter((b) => zeitblockErinnerungFaellig(benutzer.einstellung, b.beginn, zeitpunkt)) + .map((block) => ({ + anlass: 'zeitblock' as const, + schluessel: meldungsSchluessel('zeitblock', block.id, tag), + titel: `${alsUhrzeitAnzeige(block.beginn)} geht es los`, + text: block.aufgabe.titel, + pfad: '/woche', + })) +} + +/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */ +async function abgleichfehlerBauen( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const konten = await db.kalenderkonto.findMany({ + where: { + benutzerId, + aktiv: true, + letzterFehler: { not: null }, + // Ein Aussetzer ist keine Nachricht wert – ein Netz hustet, ein + // Anbieter hat Wartung. Erst mehrere Fehlschläge hintereinander. + fehlversuche: { gte: ABGLEICHFEHLER_AB_VERSUCH }, + }, + select: { id: true, anzeigename: true, letzterFehler: true }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return konten.map((konto) => ({ + anlass: 'abgleichfehler' as const, + schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag), + titel: 'Ein Kalender lässt sich nicht abgleichen', + text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`, + pfad: '/kalenderkonten', + })) +} + +export type VersandBericht = { geprueft: number; gesendet: number } + +/** + * Ein Durchlauf: für jeden Benutzer alles zusammentragen, was jetzt fällig + * wäre, und zustellen, was darf. + */ +export async function benachrichtigungenVerschicken( + zeitpunkt: Date = jetzt(), +): Promise { + const empfaenger = await empfaengerLaden() + let gesendet = 0 + + for (const benutzer of empfaenger) { + // Wer keinen Weg hinaus eingerichtet hat, braucht auch nichts gebaut. + if (!benutzer.einstellung.perPush && !benutzer.einstellung.perMail) continue + + const meldungen: Meldung[] = [] + + if ( + benutzer.einstellung.anlaesse.includes('tagesueberblick') && + ueberblickFaellig(benutzer.einstellung, zeitpunkt) + ) { + const ueberblick = await tagesueberblickBauen(benutzer.id, zeitpunkt) + if (ueberblick) meldungen.push(ueberblick) + } + + if (benutzer.einstellung.anlaesse.includes('faelligkeit')) { + meldungen.push(...(await faelligkeitenBauen(zeitpunkt))) + } + + if (benutzer.einstellung.anlaesse.includes('zeitblock')) { + meldungen.push(...(await zeitblockErinnerungenBauen(benutzer, zeitpunkt))) + } + + if (benutzer.einstellung.anlaesse.includes('abgleichfehler')) { + meldungen.push(...(await abgleichfehlerBauen(benutzer.id, zeitpunkt))) + } + + for (const meldung of meldungen) { + if (await meldungZustellen(benutzer, meldung, zeitpunkt)) gesendet++ + } + } + + return { geprueft: empfaenger.length, gesendet } +} + +/** Alte Nachweise wegräumen; sonst wächst die Tabelle unbegrenzt. */ +export async function alteNachweiseEntfernen(zeitpunkt: Date = jetzt()): Promise { + const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000) + const ergebnis = await db.meldungsnachweis.deleteMany({ + where: { gesendetAm: { lt: grenze } }, + }) + return ergebnis.count +}