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>
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
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
|
||
}
|