Files
planer/src/server/benachrichtigung/versand.ts
T
Florian WildeandClaude Opus 5 e0e11c85b2 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 <noreply@anthropic.com>
2026-09-11 08:59:31 +02:00

350 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<BenutzerMitEinstellung[]> {
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<boolean> {
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<Meldung | null> {
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<Meldung[]> {
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<Meldung[]> {
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<Meldung[]> {
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<VersandBericht> {
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<number> {
const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000)
const ergebnis = await db.meldungsnachweis.deleteMany({
where: { gesendetAm: { lt: grenze } },
})
return ergebnis.count
}