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 }