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 [
+ '',
+ ].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
+}