5 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 b622c1bb28 feat: Benachrichtigungen in den Einstellungen bedienen
Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der
Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht
eingerichtet, steht das direkt daneben statt in einem Fehler beim
Speichern.

Der Knopf "Probe senden" umgeht Ruhezeit und Sperre absichtlich: wer
darauf drueckt, will jetzt wissen, ob es ankommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:57 +02:00
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
Florian WildeandClaude Opus 5 ddb13d7dd0 feat: Regeln fuer Benachrichtigungen als reine Logik
Anlaesse, Ruhezeit ueber Mitternacht, Wiederholungssperre und die
Entscheidung, ob eine Meldung jetzt hinausgeht. Ohne Datenbank und ohne
Systemzeit — der Zeitpunkt kommt immer als Parameter herein, damit sich
alles ohne Warten pruefen laesst.

Der Tagesueberblick ist von der Ruhezeit ausgenommen: wer ihn auf 06:00
stellt, bekaeme ihn sonst nie.

Die Voreinstellung ist bewusst zurueckhaltend. Wer beim ersten Start mit
Meldungen ueberschuettet wird, schaltet alles ab — und dann auch das
Nuetzliche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:27 +02:00
Florian WildeandClaude Opus 5 24f613d372 feat: Datenmodell und Umgebung fuer Benachrichtigungen
Drei Tabellen: die Einstellung je Benutzer, angemeldete Push-Geraete und
ein Nachweis je verschickter Meldung. Der Nachweis ist das Gedaechtnis
gegen Wiederholungen — ohne ihn kaeme dieselbe Sache bei jedem Durchlauf
erneut.

Kalenderkonten bekommen einen Zaehler fuer Fehlversuche in Folge, damit
ein Abgleichfehler erst nach mehreren Versuchen gemeldet wird.

VAPID- und SMTP-Werte sind freiwillig: fehlen sie, laeuft der Planer
ohne den jeweiligen Weg weiter und sagt es in den Einstellungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:58:59 +02:00
Florian WildeandClaude Opus 5 ef79b24a96 Merge: Meilenstein 4 – Wochenplanung und Zeitblöcke (0.4.0)
Damit steht die Kernidee des Projekts: Aufgaben werden per Ziehen und
Ablegen zu echten Kalenderterminen, und Verschiebungen aus dem Kalender
kommen zurück.

Der Schleifenschutz über sha256(startISO|endeISO) verhindert, dass App
und Kalender einander endlos hinterherschreiben. Bei HTTP 412 gewinnt der
Kalender.

Geprüft vor dem Merge: npm run pruefen mit 250 Unit-Tests, 40
Integrationsprüfungen, Migrationstest und 17 E2E-Tests. Der Schreibweg ist
gegen die Logik geprüft, nicht gegen die echten APIs — dafür fehlen die
Zugangsdaten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:33:28 +02:00
19 changed files with 2310 additions and 8 deletions
+23
View File
@@ -74,6 +74,29 @@ GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# --- Benachrichtigungen ----------------------------------------------------
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
# weiter und sagt es in den Einstellungen.
# Web-Push. Schluesselpaar einmalig erzeugen mit:
# npx web-push generate-vapid-keys
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
VAPID_OEFFENTLICH=
VAPID_PRIVAT=
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
VAPID_KONTAKT=
# E-Mail. Push braucht SMTP nicht und umgekehrt.
SMTP_HOST=
SMTP_PORT=587
SMTP_BENUTZER=
SMTP_PASSWORT=
# Absender, z. B. "Planer <planer@example.de>".
SMTP_ABSENDER=
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
SMTP_TLS=false
# --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin
+1
View File
@@ -8,3 +8,4 @@ test-results
playwright-report
coverage
devlog/zeiten.csv
public/sw.js
+2
View File
@@ -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,
+148 -4
View File
@@ -38,6 +38,7 @@
"server-only": "0.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1"
},
"devDependencies": {
@@ -49,6 +50,7 @@
"@types/pg": "8.23.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2",
@@ -4545,6 +4547,16 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
@@ -5446,6 +5458,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -5678,6 +5699,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5832,6 +5865,12 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -5892,6 +5931,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -6413,7 +6458,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -6597,6 +6641,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -8085,6 +8138,28 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -8139,6 +8214,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8804,6 +8885,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9280,6 +9382,12 @@
"node": ">=8.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9300,7 +9408,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -9310,7 +9417,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -10731,6 +10837,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -10793,7 +10919,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -12398,6 +12523,25 @@
"node": ">=18"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+2
View File
@@ -68,6 +68,7 @@
"server-only": "0.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1"
},
"devDependencies": {
@@ -79,6 +80,7 @@
"@types/pg": "8.23.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2",
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "benachrichtigungseinstellung" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
"perPush" BOOLEAN NOT NULL DEFAULT false,
"perMail" BOOLEAN NOT NULL DEFAULT false,
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
"ruheBis" INTEGER NOT NULL DEFAULT 420,
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "push_geraet" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"endpunkt" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meldungsnachweis" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
-- CreateIndex
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
-- CreateIndex
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
-- CreateIndex
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
-- AddForeignKey
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
+74 -4
View File
@@ -31,10 +31,13 @@ model Benutzer {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
benachrichtigung Benachrichtigungseinstellung?
pushGeraete PushGeraet[]
meldungen Meldungsnachweis[]
@@index([aktiv])
@@map("benutzer")
@@ -300,6 +303,9 @@ model Kalenderkonto {
letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String?
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
/// Nachricht wert; gemeldet wird erst ab mehreren.
fehlversuche Int @default(0)
aktiv Boolean @default(true)
@@ -378,3 +384,67 @@ model Zeitblock {
@@index([aufgabeId])
@@map("zeitblock")
}
/// Wie und worueber ein Benutzer benachrichtigt werden will.
model Benachrichtigungseinstellung {
id String @id @default(cuid())
benutzerId String @unique
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
anlaesse String @default("tagesueberblick,faelligkeit")
perPush Boolean @default(false)
perMail Boolean @default(false)
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
ueberblickUm Int @default(420)
ruheVon Int @default(1200)
ruheBis Int @default(420)
ruheAmWochenende Boolean @default(false)
zeitblockVorlaufMinuten Int @default(10)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@map("benachrichtigungseinstellung")
}
/// Ein Browser, der Push-Nachrichten empfangen darf.
model PushGeraet {
id String @id @default(cuid())
benutzerId String
/// Vom Browser vergebene Adresse; eindeutig je Geraet.
endpunkt String @unique
/// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt.
p256dh String
auth String
bezeichnung String?
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
/// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg.
fehlversuche Int @default(0)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
@@map("push_geraet")
}
/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen.
model Meldungsnachweis {
id String @id @default(cuid())
benutzerId String
/// anlass:sache:tag, siehe meldungsSchluessel().
schluessel String
gesendetAm DateTime @default(now())
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@unique([benutzerId, schluessel])
@@index([gesendetAm])
@@map("meldungsnachweis")
}
+53
View File
@@ -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,439 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ANLAESSE,
ANLASS_BESCHREIBUNG,
ANLASS_NAME,
type Anlass,
} from '@/lib/benachrichtigung'
import {
einstellungSpeichern,
geraetEntfernen,
probeSenden,
type Rueckmeldung,
} from '@/server/aktionen/benachrichtigungen'
export type GeraetAnzeige = {
id: string
bezeichnung: string | null
erstelltAm: string
zuletztBenutzt: string | null
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
diesesGeraet?: boolean
}
export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
ruheAmWochenende: boolean
zeitblockVorlaufMinuten: number
}
type Eigenschaften = {
einstellung: EinstellungAnzeige
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
}
/**
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
*
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
*/
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
const roh = atob(base64)
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
return bytes
}
export function Benachrichtigungen({
einstellung,
geraete,
pushMoeglich,
mailMoeglich,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [pushZustand, setPushZustand] = useState<
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
>('unbekannt')
const [pushLaeuft, setPushLaeuft] = useState(false)
useEffect(() => {
let abgebrochen = false
async function zustandPruefen() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
return
}
if (Notification.permission === 'denied') {
if (!abgebrochen) setPushZustand('verweigert')
return
}
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
}
void zustandPruefen()
return () => {
abgebrochen = true
}
}, [])
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
async function pushAnmelden() {
setPushLaeuft(true)
try {
const erlaubnis = await Notification.requestPermission()
if (erlaubnis !== 'granted') {
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
return
}
const antwort = await fetch('/api/push')
const daten = (await antwort.json()) as {
aktiv: boolean
oeffentlicherSchluessel: string | null
}
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
toast.error('Push ist auf diesem Server nicht eingerichtet.')
return
}
const registrierung = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const abo = await registrierung.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
})
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
const gespeichert = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: roh.endpoint,
p256dh: roh.keys?.p256dh,
auth: roh.keys?.auth,
bezeichnung: geraetename(),
}),
})
if (!gespeichert.ok) {
const fehler = (await gespeichert.json()) as { fehler?: string }
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
return
}
setPushZustand('an')
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
router.refresh()
} catch {
toast.error('Die Anmeldung hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
async function pushAbmelden() {
setPushLaeuft(true)
try {
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (abo) {
await fetch('/api/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: abo.endpoint }),
})
await abo.unsubscribe()
}
setPushZustand('aus')
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
router.refresh()
} catch {
toast.error('Das Abmelden hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
{/* Dieses Gerät */}
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
{pushZustand === 'an' ? (
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
) : (
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs">
{pushZustand === 'nicht-unterstuetzt' &&
'Dieser Browser kann keine Push-Nachrichten empfangen.'}
{pushZustand === 'verweigert' &&
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'}
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'}
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'}
{pushZustand === 'unbekannt' && '…'}
</p>
</div>
{!pushMoeglich ? (
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
) : pushZustand === 'an' ? (
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Abmelden
</Button>
) : (
pushZustand !== 'nicht-unterstuetzt' &&
pushZustand !== 'verweigert' && (
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Anmelden
</Button>
)
)}
</div>
{/* Einstellungen */}
<form
className="flex flex-col gap-5"
onSubmit={(ereignis) => {
ereignis.preventDefault()
const daten = new FormData(ereignis.currentTarget)
mitRueckmeldung(() => einstellungSpeichern(daten))
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Worüber</legend>
{ANLAESSE.map((anlass) => (
<label key={anlass} className="flex items-start gap-3">
<input
type="checkbox"
name="anlaesse"
value={anlass}
defaultChecked={einstellung.anlaesse.includes(anlass)}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{ANLASS_BESCHREIBUNG[anlass]}
</span>
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Wie</legend>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perPush"
defaultChecked={einstellung.perPush}
className="accent-betont size-4"
/>
<span className="text-sm">
Push an angemeldete Geräte
{!pushMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perMail"
defaultChecked={einstellung.perMail}
className="accent-betont size-4"
/>
<span className="text-sm">
E-Mail
{!mailMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
<Input
id="ueberblickUm"
name="ueberblickUm"
type="time"
defaultValue={einstellung.ueberblickUm}
aria-invalid={Boolean(felder.ueberblickUm)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="zeitblockVorlaufMinuten">
Vorlauf vor einem Zeitblock (Minuten)
</Label>
<Input
id="zeitblockVorlaufMinuten"
name="zeitblockVorlaufMinuten"
type="number"
min={0}
max={120}
step={5}
defaultValue={einstellung.zeitblockVorlaufMinuten}
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
/>
{felder.zeitblockVorlaufMinuten && (
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheVon">Ruhe von</Label>
<Input
id="ruheVon"
name="ruheVon"
type="time"
defaultValue={einstellung.ruheVon}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheBis">bis</Label>
<Input
id="ruheBis"
name="ruheBis"
type="time"
defaultValue={einstellung.ruheBis}
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="ruheAmWochenende"
defaultChecked={einstellung.ruheAmWochenende}
className="accent-betont size-4"
/>
<span className="text-sm">Am Wochenende gar nichts melden</span>
</label>
<p className="text-gedaempft-vordergrund text-xs">
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
nie, wenn du früh aufstehst.
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Speichern
</Button>
<Button
type="button"
art="umriss"
disabled={laeuft}
onClick={() => mitRueckmeldung(() => probeSenden())}
>
<Send aria-hidden />
Probe schicken
</Button>
</div>
</form>
{/* Angemeldete Geräte */}
{geraete.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
<ul className="divide-rand divide-y">
{geraete.map((geraet) => (
<li key={geraet.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angemeldet {geraet.erstelltAm}
{geraet.zuletztBenutzt
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
: ' · noch nichts empfangen'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', geraet.id)
mitRueckmeldung(() => geraetEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Abmelden</span>
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
function geraetename(): string {
const kennung = navigator.userAgent
if (/Android/i.test(kennung)) return 'Android-Gerät'
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
if (/Macintosh/i.test(kennung)) return 'Mac'
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
return 'Unbekanntes Gerät'
}
+66
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import {
Card,
@@ -8,6 +9,9 @@ import {
CardTitel,
} from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { alsUhrzeit } from '@/lib/zeit'
import { umgebung } from '@/server/umgebung'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen')
const einst = umgebung()
const [benachrichtigung, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
}),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
bezeichnung: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const einstellung = alsEinstellung(benachrichtigung)
const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
/>
</CardInhalt>
</Card>
<Card data-bild="benachrichtigungen">
<CardKopf>
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
<CardBeschreibung>
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
dann wertlos.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
ruheAmWochenende: einstellung.ruheAmWochenende,
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
}}
geraete={pushGeraete.map((g) => ({
id: g.id,
bezeichnung: g.bezeichnung,
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
}))}
/>
</CardInhalt>
</Card>
</div>
)
}
+120
View File
@@ -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 })
}
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from 'vitest'
import {
VOREINSTELLUNG,
einstellungSchema,
inRuhezeit,
meldungsSchluessel,
ruhezeitText,
sendenEntscheiden,
ueberblickFaellig,
zeitblockErinnerungFaellig,
type Einstellung,
type Meldung,
} from './benachrichtigung'
/** Dienstag, 17. März 2026. Berlin liegt dann auf UTC+1. */
function berlin(stunde: number, minute = 0): Date {
return new Date(Date.UTC(2026, 2, 17, stunde - 1, minute))
}
/** Samstag, 21. März 2026. */
function samstag(stunde: number): Date {
return new Date(Date.UTC(2026, 2, 21, stunde - 1, 0))
}
const einstellung: Einstellung = {
...VOREINSTELLUNG,
anlaesse: ['tagesueberblick', 'faelligkeit', 'zeitblock'],
perPush: true,
}
function meldung(teil: Partial<Meldung> = {}): Meldung {
return {
anlass: 'faelligkeit',
schluessel: 'faelligkeit:a1:2026-03-17',
titel: 'Fällig heute',
text: 'Angebot schreiben',
pfad: '/aufgaben',
...teil,
}
}
describe('VOREINSTELLUNG', () => {
it('meldet ab Werk über keinen Weg', () => {
// Wer beim ersten Start überschüttet wird, schaltet alles ab.
expect(VOREINSTELLUNG.perPush).toBe(false)
expect(VOREINSTELLUNG.perMail).toBe(false)
})
it('hat eine Ruhezeit über Nacht', () => {
expect(VOREINSTELLUNG.ruheVon).toBe(20 * 60)
expect(VOREINSTELLUNG.ruheBis).toBe(7 * 60)
})
})
describe('inRuhezeit', () => {
it('erkennt die Nacht, obwohl sie über Mitternacht läuft', () => {
expect(inRuhezeit(einstellung, berlin(21))).toBe(true)
expect(inRuhezeit(einstellung, berlin(2))).toBe(true)
expect(inRuhezeit(einstellung, berlin(6, 59))).toBe(true)
})
it('lässt den Tag frei', () => {
expect(inRuhezeit(einstellung, berlin(7))).toBe(false)
expect(inRuhezeit(einstellung, berlin(12))).toBe(false)
expect(inRuhezeit(einstellung, berlin(19, 59))).toBe(false)
})
it('beginnt pünktlich mit der Ruhe', () => {
expect(inRuhezeit(einstellung, berlin(20))).toBe(true)
})
it('kennt auch eine Ruhezeit innerhalb eines Tages', () => {
const mittags: Einstellung = { ...einstellung, ruheVon: 12 * 60, ruheBis: 13 * 60 }
expect(inRuhezeit(mittags, berlin(12, 30))).toBe(true)
expect(inRuhezeit(mittags, berlin(11))).toBe(false)
expect(inRuhezeit(mittags, berlin(14))).toBe(false)
})
it('deutet gleiche Zeiten als "keine Ruhezeit"', () => {
const ohne: Einstellung = { ...einstellung, ruheVon: 0, ruheBis: 0 }
expect(inRuhezeit(ohne, berlin(3))).toBe(false)
})
it('schweigt das Wochenende, wenn gewünscht', () => {
const still: Einstellung = { ...einstellung, ruheAmWochenende: true }
expect(inRuhezeit(still, samstag(12))).toBe(true)
expect(inRuhezeit(still, berlin(12))).toBe(false)
})
it('lässt das Wochenende sonst in Ruhe', () => {
expect(inRuhezeit(einstellung, samstag(12))).toBe(false)
})
})
describe('sendenEntscheiden', () => {
const leer = new Set<string>()
it('lässt eine gewöhnliche Meldung durch', () => {
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(10))).toEqual({
senden: true,
})
})
it('schweigt bei abgeschaltetem Anlass', () => {
const ohne: Einstellung = { ...einstellung, anlaesse: ['tagesueberblick'] }
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(10))).toEqual({
senden: false,
grund: 'abgeschaltet',
})
})
it('schweigt, wenn es keinen Weg hinaus gibt', () => {
const stumm: Einstellung = { ...einstellung, perPush: false, perMail: false }
expect(sendenEntscheiden(stumm, meldung(), leer, berlin(10))).toEqual({
senden: false,
grund: 'kein-weg',
})
})
it('schweigt in der Ruhezeit', () => {
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(22))).toEqual({
senden: false,
grund: 'ruhezeit',
})
})
it('meldet dieselbe Sache nicht zweimal', () => {
const gemeldet = new Set(['faelligkeit:a1:2026-03-17'])
expect(sendenEntscheiden(einstellung, meldung(), gemeldet, berlin(10))).toEqual({
senden: false,
grund: 'schon-gemeldet',
})
})
it('lässt den Tagesüberblick auch in der Ruhezeit hinaus', () => {
// Sonst käme er nie, wenn jemand früh aufsteht und die Ruhe bis 07:00 läuft.
const ueberblick = meldung({
anlass: 'tagesueberblick',
schluessel: 'tagesueberblick:tag:2026-03-17',
})
expect(sendenEntscheiden(einstellung, ueberblick, leer, berlin(6, 30))).toEqual({
senden: true,
})
})
it('prüft die Abschaltung vor der Ruhezeit', () => {
// Ein abgeschalteter Anlass ist abgeschaltet, nicht "gerade still".
const ohne: Einstellung = { ...einstellung, anlaesse: [] }
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(22)).senden).toBe(false)
expect(
sendenEntscheiden(ohne, meldung(), leer, berlin(22)) as { grund: string },
).toMatchObject({ grund: 'abgeschaltet' })
})
})
describe('meldungsSchluessel', () => {
it('trennt Anlass, Sache und Tag', () => {
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).toBe(
'faelligkeit:a1:2026-03-17',
)
})
it('erlaubt dieselbe Sache an einem anderen Tag erneut', () => {
// Eine Aufgabe, die heute fällig war, ist es morgen immer noch.
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).not.toBe(
meldungsSchluessel('faelligkeit', 'a1', '2026-03-18'),
)
})
})
describe('ueberblickFaellig', () => {
it('trifft das Zeitfenster', () => {
expect(ueberblickFaellig(einstellung, berlin(7))).toBe(true)
expect(ueberblickFaellig(einstellung, berlin(7, 14))).toBe(true)
})
it('verpasst das Fenster davor und danach', () => {
expect(ueberblickFaellig(einstellung, berlin(6, 59))).toBe(false)
expect(ueberblickFaellig(einstellung, berlin(7, 15))).toBe(false)
})
})
describe('zeitblockErinnerungFaellig', () => {
const beginn = berlin(10)
it('erinnert im Vorlauffenster', () => {
// Vorlauf 10 Minuten, Toleranz 15: von 25 bis 10 Minuten vorher.
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 55))).toBe(true)
})
it('erinnert nicht zu früh', () => {
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 30))).toBe(false)
})
it('erinnert nicht mehr, wenn der Block schon läuft', () => {
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(10, 5))).toBe(false)
})
})
describe('einstellungSchema', () => {
const gueltig = {
anlaesse: ['tagesueberblick'],
perPush: 'true',
perMail: '',
ueberblickUm: '07:00',
ruheVon: '20:00',
ruheBis: '07:00',
ruheAmWochenende: '',
zeitblockVorlaufMinuten: '10',
}
it('liest Uhrzeiten in Minuten um', () => {
const ergebnis = einstellungSchema.parse(gueltig)
expect(ergebnis.ueberblickUm).toBe(420)
expect(ergebnis.ruheVon).toBe(1200)
})
it('weist unsinnige Uhrzeiten ab', () => {
expect(einstellungSchema.safeParse({ ...gueltig, ruheVon: '25:00' }).success).toBe(
false,
)
})
it('begrenzt den Vorlauf', () => {
expect(
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '121' }).success,
).toBe(false)
expect(
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '120' }).success,
).toBe(true)
})
it('weist unbekannte Anlässe ab', () => {
expect(
einstellungSchema.safeParse({ ...gueltig, anlaesse: ['geburtstag'] }).success,
).toBe(false)
})
})
describe('ruhezeitText', () => {
it('beschreibt die Nachtruhe', () => {
expect(ruhezeitText(einstellung)).toBe('Still von 20:00 bis 07:00.')
})
it('nennt das Wochenende mit', () => {
expect(ruhezeitText({ ...einstellung, ruheAmWochenende: true })).toBe(
'Still von 20:00 bis 07:00 und am ganzen Wochenende.',
)
})
it('sagt es auch, wenn es keine Ruhezeit gibt', () => {
expect(ruhezeitText({ ...einstellung, ruheVon: 0, ruheBis: 0 })).toBe(
'Keine Ruhezeit.',
)
})
})
+229
View File
@@ -0,0 +1,229 @@
import { z } from 'zod'
import {
alsUhrzeit,
ausUhrzeit,
minutenSeitMitternacht,
wochentagIndex,
} from '@/lib/zeit'
/**
* Regeln für Benachrichtigungen.
*
* Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die
* zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die
* Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird
* nie zweimal gemeldet.
*
* Reine Logik ohne Datenbank und ohne Systemzeit.
*/
export const ANLAESSE = [
'tagesueberblick',
'faelligkeit',
'zeitblock',
'abgleichfehler',
] as const
export type Anlass = (typeof ANLAESSE)[number]
export const ANLASS_NAME: Record<Anlass, string> = {
tagesueberblick: 'Tagesüberblick',
faelligkeit: 'Fällige Aufgaben',
zeitblock: 'Gleich beginnt ein Zeitblock',
abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen',
}
export const ANLASS_BESCHREIBUNG: Record<Anlass, string> = {
tagesueberblick:
'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.',
faelligkeit:
'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.',
zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.',
abgleichfehler:
'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.',
}
export type Einstellung = {
/** Welche Anlässe überhaupt melden. */
anlaesse: readonly Anlass[]
perPush: boolean
perMail: boolean
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
ueberblickUm: number
/** Ruhezeit: von hier bis … nichts melden. */
ruheVon: number
ruheBis: number
/** Am Wochenende nichts melden. */
ruheAmWochenende: boolean
/** Vorlauf vor einem Zeitblock, in Minuten. */
zeitblockVorlaufMinuten: number
}
/**
* Voreinstellung: bewusst wenig.
*
* Wer mehr will, schaltet es zu. Wer beim ersten Start mit Meldungen
* überschüttet wird, schaltet alles ab — und dann auch das Nützliche.
*/
export const VOREINSTELLUNG: Einstellung = {
anlaesse: ['tagesueberblick', 'faelligkeit'],
perPush: false,
perMail: false,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
ruheAmWochenende: false,
zeitblockVorlaufMinuten: 10,
}
/**
* Liegt der Zeitpunkt in der Ruhezeit?
*
* Die Ruhezeit läuft über Mitternacht hinweg — 20:00 bis 07:00 ist der
* Normalfall, nicht die Ausnahme.
*/
export function inRuhezeit(einstellung: Einstellung, zeitpunkt: Date): boolean {
if (einstellung.ruheAmWochenende && wochentagIndex(zeitpunkt) >= 5) return true
const minute = minutenSeitMitternacht(zeitpunkt)
const { ruheVon, ruheBis } = einstellung
// Gleiche Zeiten heißen: keine Ruhezeit.
if (ruheVon === ruheBis) return false
return ruheVon < ruheBis
? minute >= ruheVon && minute < ruheBis
: minute >= ruheVon || minute < ruheBis
}
export type Meldung = {
anlass: Anlass
/** Eindeutig je Sache und Tag; verhindert doppelte Meldungen. */
schluessel: string
titel: string
text: string
/** Wohin der Klick führt. */
pfad: string
}
/**
* Soll diese Meldung jetzt hinaus?
*
* Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit,
* oder genau diese Meldung ging schon einmal hinaus.
*/
export type Entscheidung =
| { senden: true }
| { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' }
export function sendenEntscheiden(
einstellung: Einstellung,
meldung: Meldung,
bereitsGemeldet: ReadonlySet<string>,
zeitpunkt: Date,
): Entscheidung {
if (!einstellung.anlaesse.includes(meldung.anlass)) {
return { senden: false, grund: 'abgeschaltet' }
}
if (!einstellung.perPush && !einstellung.perMail) {
return { senden: false, grund: 'kein-weg' }
}
if (bereitsGemeldet.has(meldung.schluessel)) {
return { senden: false, grund: 'schon-gemeldet' }
}
// Der Tagesüberblick kommt zu seiner festen Zeit; ihn wegen der Ruhezeit zu
// unterdrücken hieße, ihn nie zu schicken, wenn jemand früh aufsteht.
if (meldung.anlass !== 'tagesueberblick' && inRuhezeit(einstellung, zeitpunkt)) {
return { senden: false, grund: 'ruhezeit' }
}
return { senden: true }
}
/**
* Schlüssel einer Meldung: Anlass, Sache und Tag.
*
* Der Tag gehört dazu, damit dieselbe Aufgabe morgen wieder gemeldet werden
* darf — sie ist dann ja immer noch fällig.
*/
export function meldungsSchluessel(
anlass: Anlass,
sache: string,
tagesschluessel: string,
): string {
return `${anlass}:${sache}:${tagesschluessel}`
}
/** Ist jetzt die Zeit für den Tagesüberblick? */
export function ueberblickFaellig(
einstellung: Einstellung,
zeitpunkt: Date,
toleranzMinuten = 15,
): boolean {
const minute = minutenSeitMitternacht(zeitpunkt)
return (
minute >= einstellung.ueberblickUm &&
minute < einstellung.ueberblickUm + toleranzMinuten
)
}
/** Beginnt der Zeitblock bald genug für eine Erinnerung? */
export function zeitblockErinnerungFaellig(
einstellung: Einstellung,
beginn: Date,
zeitpunkt: Date,
toleranzMinuten = 15,
): boolean {
const minutenBis = (beginn.getTime() - zeitpunkt.getTime()) / 60_000
return (
minutenBis <= einstellung.zeitblockVorlaufMinuten &&
minutenBis > einstellung.zeitblockVorlaufMinuten - toleranzMinuten
)
}
/**
* Ab wann ein Abgleichfehler gemeldet wird.
*
* Nicht beim ersten Mal: ein Netz hustet, ein Anbieter hat eine Wartung.
* Erst wenn es mehrfach hintereinander nicht klappt, ist es eine Nachricht
* wert.
*/
export const ABGLEICHFEHLER_AB_VERSUCH = 3
// --- Eingaben --------------------------------------------------------------
const uhrzeit = z
.string()
.trim()
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 07:00 angeben.')
.transform((w) => ausUhrzeit(w) as number)
export const einstellungSchema = z.object({
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
perPush: z.coerce.boolean().default(false),
perMail: z.coerce.boolean().default(false),
ueberblickUm: uhrzeit,
ruheVon: uhrzeit,
ruheBis: uhrzeit,
ruheAmWochenende: z.coerce.boolean().default(false),
zeitblockVorlaufMinuten: z.coerce
.number()
.int()
.min(0, 'Nicht weniger als null Minuten.')
.max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'),
})
export type EinstellungEingabe = z.infer<typeof einstellungSchema>
/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */
export function ruhezeitText(einstellung: Einstellung): string {
if (einstellung.ruheVon === einstellung.ruheBis) {
return einstellung.ruheAmWochenende
? 'Keine Ruhezeit, aber am Wochenende still.'
: 'Keine Ruhezeit.'
}
const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}`
return einstellung.ruheAmWochenende
? `Still von ${zeiten} und am ganzen Wochenende.`
: `Still von ${zeiten}.`
}
+195
View File
@@ -0,0 +1,195 @@
'use server'
import { revalidatePath } from 'next/cache'
import { einstellungSchema } from '@/lib/benachrichtigung'
import { alsTagesschluessel } from '@/lib/zeit'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
export type Rueckmeldung =
| { ok: true; meldung: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
async function akteurOderFehler() {
try {
return { akteur: await pruefeRolle('BENUTZER') }
} catch (fehler) {
if (fehler instanceof NichtErlaubt) {
return { antwort: { ok: false as const, fehler: fehler.message } }
}
throw fehler
}
}
/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */
export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const gelesen = einstellungSchema.safeParse({
anlaesse: daten.getAll('anlaesse'),
perPush: daten.get('perPush') !== null,
perMail: daten.get('perMail') !== null,
ueberblickUm: daten.get('ueberblickUm'),
ruheVon: daten.get('ruheVon'),
ruheBis: daten.get('ruheBis'),
ruheAmWochenende: daten.get('ruheAmWochenende') !== null,
zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'),
})
if (!gelesen.success) {
const felder: Record<string, string> = {}
for (const problem of gelesen.error.issues) {
const feld = problem.path.join('.')
felder[feld] ??= problem.message
}
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
}
const werte = {
anlaesse: gelesen.data.anlaesse.join(','),
perPush: gelesen.data.perPush,
perMail: gelesen.data.perMail,
ueberblickUm: gelesen.data.ueberblickUm,
ruheVon: gelesen.data.ruheVon,
ruheBis: gelesen.data.ruheBis,
ruheAmWochenende: gelesen.data.ruheAmWochenende,
zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten,
}
await db.benachrichtigungseinstellung.upsert({
where: { benutzerId: akteur.id },
update: werte,
create: { benutzerId: akteur.id, ...werte },
})
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.geaendert',
objektTyp: 'Benachrichtigungseinstellung',
objektId: akteur.id,
details: {
anlaesse: werte.anlaesse,
perPush: werte.perPush,
perMail: werte.perMail,
},
})
revalidatePath('/einstellungen')
return { ok: true, meldung: 'Einstellungen gespeichert.' }
}
/**
* Schickt eine Probemeldung über alle eingeschalteten Wege.
*
* Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf
* drückt, will jetzt wissen, ob es ankommt.
*/
export async function probeSenden(): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const zeile = await db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: akteur.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
})
const einstellung = alsEinstellung(zeile)
if (!einstellung.perPush && !einstellung.perMail) {
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
}
const meldung = {
anlass: 'tagesueberblick' as const,
// Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form.
schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`,
titel: 'Probemeldung',
text: 'Wenn du das liest, kommen Benachrichtigungen an.',
pfad: '/einstellungen',
}
const wege: string[] = []
const einst = umgebung()
if (einstellung.perPush) {
if (!einst.pushAktiv) {
wege.push('Push ist auf diesem Server nicht eingerichtet')
} else {
const bericht = await pushSenden(akteur.id, meldung)
wege.push(
bericht.zugestellt > 0
? `Push an ${bericht.zugestellt} Gerät(e)`
: 'kein Gerät für Push angemeldet',
)
}
}
if (einstellung.perMail) {
if (!einst.mailAktiv) {
wege.push('E-Mail ist auf diesem Server nicht eingerichtet')
} else {
wege.push(
(await mailSenden(akteur.email, meldung))
? 'E-Mail verschickt'
: 'E-Mail fehlgeschlagen',
)
}
}
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.probe',
objektTyp: 'Benachrichtigungseinstellung',
objektId: akteur.id,
})
return { ok: true, meldung: wege.join(' · ') }
}
/** Entfernt ein angemeldetes Push-Gerät. */
export async function geraetEntfernen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const id = daten.get('id')
if (typeof id !== 'string' || id.length === 0) {
return { ok: false, fehler: 'Ungültige Anfrage.' }
}
const geraet = await db.pushGeraet.findFirst({
where: { id, benutzerId: akteur.id },
select: { id: true, bezeichnung: true },
})
if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' }
await db.pushGeraet.delete({ where: { id: geraet.id } })
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'push.abgemeldet',
objektTyp: 'PushGeraet',
objektId: geraet.id,
details: { bezeichnung: geraet.bezeichnung },
})
revalidatePath('/einstellungen')
return { ok: true, meldung: 'Gerät abgemeldet.' }
}
+110
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** 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.',
}
}
}
+152
View File
@@ -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 }
}
+349
View File
@@ -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
}
+23
View File
@@ -41,6 +41,22 @@ const schema = z.object({
GOOGLE_CLIENT_ID: z.string().trim().default(''),
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
// --- Benachrichtigungen (M5) ---
// VAPID-Schluessel fuer Web-Push. Erzeugen mit:
// npx web-push generate-vapid-keys
VAPID_OEFFENTLICH: z.string().trim().default(''),
VAPID_PRIVAT: z.string().trim().default(''),
/// Kontaktadresse fuer den Push-Dienst, als mailto:.
VAPID_KONTAKT: z.string().trim().default(''),
SMTP_HOST: z.string().trim().default(''),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_BENUTZER: z.string().trim().default(''),
SMTP_PASSWORT: z.string().default(''),
SMTP_ABSENDER: z.string().trim().default(''),
/// Implizites TLS (Port 465). Sonst wird STARTTLS versucht.
SMTP_TLS: jaNein.default(false),
DEMO_MODUS: jaNein.default(false),
})
@@ -51,6 +67,9 @@ export type Umgebung = z.infer<typeof schema> & {
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
graphAktiv: boolean
googleAktiv: boolean
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
pushAktiv: boolean
mailAktiv: boolean
}
let zwischengespeichert: Umgebung | null = null
@@ -72,6 +91,10 @@ function lesen(): Umgebung {
),
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
pushAktiv: Boolean(
werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT,
),
mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER),
}
}