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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ddb13d7dd0
commit
e0e11c85b2
@@ -8,3 +8,4 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
devlog/zeiten.csv
|
devlog/zeiten.csv
|
||||||
|
public/sw.js
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default tseslint.config(
|
|||||||
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||||
'src/generated/**',
|
'src/generated/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
|
||||||
|
'public/sw.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...naechstesWebVitals,
|
...naechstesWebVitals,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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) => `<p style="margin:0 0 8px">${maskieren(z)}</p>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2430">',
|
||||||
|
`<h1 style="font-size:18px;margin:0 0 12px">${maskieren(meldung.titel)}</h1>`,
|
||||||
|
zeilen,
|
||||||
|
`<p style="margin:16px 0 0"><a href="${adresse}" style="color:#2563eb">Im Planer ansehen</a></p>`,
|
||||||
|
'</div>',
|
||||||
|
].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<boolean> {
|
||||||
|
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.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<PushBericht> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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<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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user