diff --git a/.env.example b/.env.example index 8dda2c7..787a36e 100644 --- a/.env.example +++ b/.env.example @@ -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 ". +SMTP_ABSENDER= +# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS. +SMTP_TLS=false + # --- Sonstiges ------------------------------------------------------------- # Zeitzone fuer Anzeigen und Tagesgrenzen. TZ=Europe/Berlin diff --git a/.prettierignore b/.prettierignore index ebb889a..6f686b6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ test-results playwright-report coverage devlog/zeiten.csv +public/sw.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cd3f9..73f382a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +## [0.5.0] – 2026-09-11 + +### Hinzugefügt + +- **Benachrichtigungen** per Web-Push und E-Mail, je Benutzer einstellbar. + Vier Anlässe: Tagesüberblick am Morgen, fällige Aufgaben, Vorlauf vor einem + Zeitblock und ein Kalender, der sich nicht abgleichen lässt. +- **Ruhezeit** von 20:00 bis 07:00 ab Werk, über Mitternacht hinweg; wahlweise + das ganze Wochenende. Der Tagesüberblick kommt trotzdem zu seiner Zeit — er + käme sonst nie, wenn jemand früh aufsteht. +- Anmeldung eines Browsers für Push, Liste der angemeldeten Geräte und ein + Knopf, der eine Probemeldung über alle eingeschalteten Wege schickt. +- Ein Lauf alle zehn Minuten prüft, ob etwas zu melden ist; die nächtliche + Aufräumrunde entfernt Nachweise, die älter als 30 Tage sind. +- Handbuchseite zu Benachrichtigungen. + +### Geändert + +- Der Kalenderabgleich zählt Fehlversuche in Folge mit und meldet erst ab dem + dritten. Ein Netz hustet, ein Anbieter hat Wartung — das ist noch keine + Nachricht wert. +- Ab Werk meldet der Planer gar nichts: kein Weg ist eingeschaltet. Wer beim + ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und dann + auch das Nützliche. + +### Sicherheit + +- Die Push-Schlüssel eines Geräts liegen verschlüsselt in der Datenbank. + Weder Endpunkt noch Schlüssel noch Mailinhalte landen im Protokoll. +- Ein Gerät, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort + entfernt; andere Fehler führen nach fünf Versuchen zum selben Ergebnis. +- Der Nachweis einer Meldung wird **vor** dem Senden geschrieben: lieber eine + verpasste Nachricht als eine, die im Minutentakt erneut versucht wird. + ## [0.4.0] – 2026-09-11 ### Hinzugefügt diff --git a/README.md b/README.md index 564a02f..8fe37b7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ wird. Bereich, wie voll der heutige Tag schon ist - **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten Kalenderterminen, Verschiebungen im Kalender kommen zurück +- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige + Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird @@ -43,24 +45,25 @@ wird. ### Geplant -Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein -MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). +Integrationen, Schnellerfassung vom Handy und ein MCP-Server. +Siehe [ROADMAP.md](ROADMAP.md). ## Bildergalerie -| | Hell | Dunkel | -| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | -| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | -| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | -| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) | -| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | -| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | -| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | -| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | -| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | -| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | -| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | +| | Hell | Dunkel | +| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | +| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | +| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | +| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) | +| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | +| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | +| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | +| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | +| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | +| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | +| Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) | +| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten. @@ -136,6 +139,38 @@ Rückleit-URI beim Anbieter lautet `/api/auth/callback/oidc`. Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind — und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`). +### Benachrichtigungen + +Beides ist freiwillig. Fehlt ein Satz, bleibt der Weg in den Einstellungen +ausgegraut, und der Planer läuft ohne ihn weiter. + +| Variable | Standard | Bedeutung | +| ------------------- | -------- | --------------------------------------------------------- | +| `VAPID_OEFFENTLICH` | _(leer)_ | Öffentlicher Push-Schlüssel, geht an den Browser | +| `VAPID_PRIVAT` | _(leer)_ | Privater Push-Schlüssel, bleibt auf dem Server | +| `VAPID_KONTAKT` | _(leer)_ | Kontakt für den Push-Dienst, `mailto:` oder `https:` | +| `SMTP_HOST` | _(leer)_ | Mailserver. Ohne ihn kein Mailversand | +| `SMTP_PORT` | `587` | Port des Mailservers | +| `SMTP_BENUTZER` | _(leer)_ | Anmeldename. Leer lassen für einen Server ohne Anmeldung | +| `SMTP_PASSWORT` | _(leer)_ | Passwort dazu | +| `SMTP_ABSENDER` | _(leer)_ | Absenderadresse, z. B. `Planer ` | +| `SMTP_TLS` | `false` | `true` bei Port 465 (SMTPS), `false` bei 587 mit STARTTLS | + +Push braucht **alle drei** VAPID-Werte, Mail mindestens `SMTP_HOST` und +`SMTP_ABSENDER`. Die Schlüssel entstehen einmalig mit: + +```bash +npx web-push generate-vapid-keys +``` + +Wird das Schlüsselpaar später getauscht, müssen sich alle Geräte neu +anmelden — die alten Abos sind dann wertlos. Push setzt außerdem HTTPS +voraus; `http://localhost` gilt dem Browser als sicher, jede andere +Klartextadresse nicht. + +Was der Planer wann meldet, steht im +[Handbuch](docs/handbuch/benachrichtigungen.md). + ### Einrichtung und Sonstiges | Variable | Standard | Bedeutung | diff --git a/ROADMAP.md b/ROADMAP.md index a33ace7..05b5db6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | -| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | +| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt | | [M6](#m6--integrationen) | Integrationen | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M8](#m8--mcp) | MCP | geplant | @@ -94,10 +94,21 @@ ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter ## M5 – Benachrichtigungen -- [ ] Web-Push mit `web-push` -- [ ] Tagesüberblick per E-Mail über nodemailer -- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron -- [ ] Je Benutzer einstellbar, mit Ruhezeiten +**Erledigt am 11.09.2026 mit Version 0.5.0.** + +**Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf +die Idee kommt, alles abzuschalten. + +- [x] Web-Push mit `web-push`, Anmeldung je Browser +- [x] Tagesüberblick per E-Mail über nodemailer +- [x] Erinnerungen an Fälligkeiten und vor Zeitblöcken, Jobs über node-cron +- [x] Je Benutzer einstellbar, mit Ruhezeiten über Mitternacht +- [x] Wiederholungssperre je Anlass, Sache und Tag +- [x] Abgleichfehler erst nach mehreren Versuchen hintereinander + +**Bewusst offen gelassen:** Keine Sammelmeldung mehrerer Anlässe in einer +Nachricht und kein Nachholen dessen, was in die Ruhezeit fiel. Eine +Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem. ## M6 – Integrationen diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index 29e2452..631a68e 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -5,13 +5,107 @@ die Übersicht aus `scripts/zeit.sh summe`. ## Zeit je Meilenstein -| Meilenstein | Art | Minuten | Stunden | -| ----------- | ------ | ------- | -------- | -| M1 | claude | 161 | 2,68 | -| M2 | claude | 44 | 0,73 | -| M3 | claude | 41 | 0,68 | -| M4 | claude | 167 | 2,78 | -| **Gesamt** | | **413** | **6,88** | +| Meilenstein | Art | Minuten | Stunden | +| ----------- | ------ | ------- | --------- | +| M1 | claude | 161 | 2,68 | +| M2 | claude | 44 | 0,73 | +| M3 | claude | 41 | 0,68 | +| M4 | claude | 167 | 2,78 | +| M5 | claude | 420 | 7,00 | +| **Gesamt** | | **833** | **13,88** | + +--- + +## 2026-09-11 – M5: Benachrichtigungen + +**Dauer:** 420 Minuten (06:35–13:35), Art `claude`. + +**Ziel der Sitzung:** Sagen, was ansteht — und dabei so selten stören, dass +niemand auf die Idee kommt, alles abzuschalten. + +### Erledigt + +- Regeln als reine Logik: Anlässe, Ruhezeit, Wiederholungssperre (30 Tests) +- Web-Push über VAPID samt Service Worker, An- und Abmeldung je Browser +- Mailversand über nodemailer, als Text und als HTML +- Vier Anlässe: Tagesüberblick, Fälligkeit, Vorlauf vor einem Zeitblock, + Abgleichfehler +- Bedienung in den Einstellungen mit Probemeldung und Geräteliste +- Ein Lauf alle zehn Minuten, Nachweise räumt die Nachtrunde weg +- 2 neue E2E-Tests, 7 neue Integrationsprüfungen, Handbuchseite + +### Entscheidungen mit Begründung + +**Ab Werk meldet der Planer gar nichts.** Kein Weg ist eingeschaltet. Wer +beim ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und +dann auch das Nützliche. Die Einrichtung ist ein bewusster Handgriff, und +das ist der Preis dafür, dass die Meldungen später ernst genommen werden. + +**Der Tagesüberblick ist von der Ruhezeit ausgenommen.** Sonst bekäme ihn +nie, wer ihn auf 06:00 stellt — die voreingestellte Ruhezeit läuft bis +07:00. Die Ruhezeit schützt vor Störungen, nicht vor der Nachricht, um die +ausdrücklich gebeten wurde. + +**Der Nachweis wird vor dem Senden geschrieben, nicht danach.** Bricht der +Versand mittendrin ab, gilt die Meldung als zugestellt und kommt nicht +wieder. Andersherum würde ein hängender Mailserver dieselbe Nachricht alle +zehn Minuten erneut versuchen. Lieber eine verpasste Nachricht als eine, +die im Minutentakt klopft. + +**Was in die Ruhezeit fällt, wird nicht nachgeholt.** Eine Erinnerung an +einen Zeitblock, der längst begonnen hat, ist nur der Beweis, dass die +Anwendung zu spät kommt. + +**Abgleichfehler erst ab dem dritten Versuch in Folge.** Ein Netz hustet, +ein Anbieter hat Wartung. Beim ersten Fehlschlag zu melden hieße, für +Dinge zu wecken, die sich von selbst erledigen. Nach dem dritten Mal ist es +dagegen dringend — solange steht die Wochenansicht auf altem Stand. + +**Der Tagesüberblick entfällt, wenn es nichts zu sagen gibt.** „Heute steht +nichts an" ist keine Information, sondern Lärm. + +**Tote Abos putzen sich selbst weg.** 404 und 410 heißen endgültig, das +Gerät verschwindet sofort; andere Fehler zählen hoch und führen nach fünf +Versuchen zum selben Ergebnis. Eine Liste, die von Hand gepflegt werden +muss, wird nicht gepflegt. + +**Push-Schlüssel liegen verschlüsselt, Endpunkte nie im Protokoll.** Ein +Endpunkt genügt, um jemandem Nachrichten zu schicken — er gehört damit in +dieselbe Klasse wie ein Token. + +### Probleme und Lösungen + +| Problem | Lösung | +| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pushManager.subscribe` lehnte den VAPID-Schlüssel als `Uint8Array` ab | Der Puffer wird jetzt ausdrücklich als `ArrayBuffer` angelegt; ein geteilter Puffer ist dort nicht erlaubt. | +| Der Integrationstest brach mit einem Fremdschlüsselfehler ab | Die neuen Prüfungen standen hinter dem Löschen des Testbenutzers. Jetzt davor — und danach wird geprüft, dass Geräte und Nachweise mit ihm verschwinden. | +| E2E-Läufe scheiterten unter Last immer wieder beim Anmelden | Ein Klick, der vor der Hydratation ankommt, verpufft folgenlos. Der Anmeldehelfer liegt jetzt einmal in `e2e/hilfe.ts` und fasst nach, bis die Startseite da ist. | +| Die Karte meldete gleichzeitig „Browser blockiert" und „Server nicht eingerichtet" | Zwei Aussagen, von denen nur eine zählt. Der Serverzustand geht jetzt vor. Gefunden nur durch das Ansehen des aufgenommenen Bildes. | +| Das erfundene Push-Gerät in den Demo-Daten wäre beim ersten Versandlauf gelöscht worden | Seine Schlüssel lagen als Klartext-Platzhalter, und unlesbare Schlüssel führen zum Entfernen. Die Demo-Daten enthalten jetzt kein Gerät mehr — die Bilder zeigen den Zustand davor. | +| Im Bild meldet der Browser „blockiert", obwohl die Erlaubnis erteilt wurde | Chromium im Kopflosbetrieb antwortet auf `Notification.permission` immer mit `denied`; `grantPermissions` ändert daran nichts. Das Handbuch schreibt es jetzt unter das Bild. | +| Ein Docker-Bau brauchte 14 Minuten, ein E2E-Lauf 18 | Systemlast, kein Codeproblem: dieselben Läufe danach in 1,5 beziehungsweise 0,4 Minuten. Ein vergessener Testserver auf Port 3100 hat kräftig mitgeholfen. | + +### Offene Punkte + +- Der Versandweg ist gegen die Logik geprüft, nicht gegen echte Dienste: + ohne VAPID-Schlüssel und SMTP-Zugang geht lokal nichts hinaus. Das braucht + einen Lauf auf dem Server. +- Mehrere Anlässe erscheinen als mehrere Nachrichten. Ob eine Sammelmeldung + fehlt, zeigt erst der Alltag. + +### Commits + +- 24f613d feat: Datenmodell und Umgebung für Benachrichtigungen +- ddb13d7 feat: Regeln für Benachrichtigungen als reine Logik +- e0e11c8 feat: Versand per Web-Push und E-Mail +- b622c1b feat: Benachrichtigungen in den Einstellungen bedienen +- 047a4a1 feat: Meldungen alle zehn Minuten prüfen und verschicken +- 5bbbdd5 chore: Demo-Daten und Bildaufnahme für Benachrichtigungen +- f03ea73 docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen +- 6e1f279 test: Benachrichtigungen end-to-end und in der Datenbank absichern +- 7bef435 fix: Serverzustand geht der Browsermeldung vor +- 35ecb2f chore: Screenshot-Instanz mit eingerichteten Meldewegen +- bb7b189 docs: Handbuchbilder für M5 neu aufnehmen --- diff --git a/devlog/zeiten.csv b/devlog/zeiten.csv index 0e74973..fa1b7eb 100644 --- a/devlog/zeiten.csv +++ b/devlog/zeiten.csv @@ -3,3 +3,4 @@ datum;start;ende;minuten;meilenstein;art;notiz 2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig 2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig 2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig +2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig diff --git a/docker-compose.screenshots.yml b/docker-compose.screenshots.yml index b92667c..f226003 100644 --- a/docker-compose.screenshots.yml +++ b/docker-compose.screenshots.yml @@ -57,5 +57,16 @@ services: WEBAUTHN_RP_NAME: Planer WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200} DEMO_MODUS: 'true' + # Damit die Karte "Benachrichtigungen" den Normalfall zeigt und nicht + # den Hinweis, dass nichts eingerichtet ist. Das Schluesselpaar erzeugt + # scripts/screenshots.ts fuer jeden Lauf neu – es steht bewusst nirgends + # im Verzeichnis. + VAPID_OEFFENTLICH: ${SCREENSHOT_VAPID_OEFFENTLICH:-} + VAPID_PRIVAT: ${SCREENSHOT_VAPID_PRIVAT:-} + VAPID_KONTAKT: mailto:planer@planer.invalid + # Ein Host, den es nie geben wird: die Oberflaeche zeigt E-Mail als + # moeglich, hinaus geht trotzdem nichts. + SMTP_HOST: smtp.planer.invalid + SMTP_ABSENDER: Planer ports: - '${SCREENSHOT_APP_PORT:-3200}:3000' diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png index 619bf91..d92cf07 100644 Binary files a/docs/bilder/anmelden-dunkel-handy.png and b/docs/bilder/anmelden-dunkel-handy.png differ diff --git a/docs/bilder/anmelden-dunkel-schreibtisch.png b/docs/bilder/anmelden-dunkel-schreibtisch.png index efeccc0..53e72a6 100644 Binary files a/docs/bilder/anmelden-dunkel-schreibtisch.png and b/docs/bilder/anmelden-dunkel-schreibtisch.png differ diff --git a/docs/bilder/anmelden-hell-handy.png b/docs/bilder/anmelden-hell-handy.png index 3eb45f2..b4c69ea 100644 Binary files a/docs/bilder/anmelden-hell-handy.png and b/docs/bilder/anmelden-hell-handy.png differ diff --git a/docs/bilder/anmelden-hell-schreibtisch.png b/docs/bilder/anmelden-hell-schreibtisch.png index cc9e53c..2e7d719 100644 Binary files a/docs/bilder/anmelden-hell-schreibtisch.png and b/docs/bilder/anmelden-hell-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-dunkel-handy.png b/docs/bilder/aufgaben-dunkel-handy.png index 5060076..0549d56 100644 Binary files a/docs/bilder/aufgaben-dunkel-handy.png and b/docs/bilder/aufgaben-dunkel-handy.png differ diff --git a/docs/bilder/aufgaben-dunkel-schreibtisch.png b/docs/bilder/aufgaben-dunkel-schreibtisch.png index 2c74ed3..81cfa90 100644 Binary files a/docs/bilder/aufgaben-dunkel-schreibtisch.png and b/docs/bilder/aufgaben-dunkel-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-hell-handy.png b/docs/bilder/aufgaben-hell-handy.png index 4557812..432f52a 100644 Binary files a/docs/bilder/aufgaben-hell-handy.png and b/docs/bilder/aufgaben-hell-handy.png differ diff --git a/docs/bilder/aufgaben-hell-schreibtisch.png b/docs/bilder/aufgaben-hell-schreibtisch.png index c5620f3..a6bc40f 100644 Binary files a/docs/bilder/aufgaben-hell-schreibtisch.png and b/docs/bilder/aufgaben-hell-schreibtisch.png differ diff --git a/docs/bilder/benachrichtigungen-dunkel-handy.png b/docs/bilder/benachrichtigungen-dunkel-handy.png new file mode 100644 index 0000000..3940c01 Binary files /dev/null and b/docs/bilder/benachrichtigungen-dunkel-handy.png differ diff --git a/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png b/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png new file mode 100644 index 0000000..9ecf428 Binary files /dev/null and b/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/benachrichtigungen-hell-handy.png b/docs/bilder/benachrichtigungen-hell-handy.png new file mode 100644 index 0000000..560b198 Binary files /dev/null and b/docs/bilder/benachrichtigungen-hell-handy.png differ diff --git a/docs/bilder/benachrichtigungen-hell-schreibtisch.png b/docs/bilder/benachrichtigungen-hell-schreibtisch.png new file mode 100644 index 0000000..c0ff8cf Binary files /dev/null and b/docs/bilder/benachrichtigungen-hell-schreibtisch.png differ diff --git a/docs/bilder/bereiche-dunkel-handy.png b/docs/bilder/bereiche-dunkel-handy.png index 0c28847..d70a1ab 100644 Binary files a/docs/bilder/bereiche-dunkel-handy.png and b/docs/bilder/bereiche-dunkel-handy.png differ diff --git a/docs/bilder/bereiche-dunkel-schreibtisch.png b/docs/bilder/bereiche-dunkel-schreibtisch.png index 46fd4f6..fef26a4 100644 Binary files a/docs/bilder/bereiche-dunkel-schreibtisch.png and b/docs/bilder/bereiche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/bereiche-hell-handy.png b/docs/bilder/bereiche-hell-handy.png index d618b11..348e24a 100644 Binary files a/docs/bilder/bereiche-hell-handy.png and b/docs/bilder/bereiche-hell-handy.png differ diff --git a/docs/bilder/bereiche-hell-schreibtisch.png b/docs/bilder/bereiche-hell-schreibtisch.png index 94c7fca..3caf9aa 100644 Binary files a/docs/bilder/bereiche-hell-schreibtisch.png and b/docs/bilder/bereiche-hell-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-dunkel-handy.png b/docs/bilder/einstellungen-dunkel-handy.png index a306325..fa2e246 100644 Binary files a/docs/bilder/einstellungen-dunkel-handy.png and b/docs/bilder/einstellungen-dunkel-handy.png differ diff --git a/docs/bilder/einstellungen-dunkel-schreibtisch.png b/docs/bilder/einstellungen-dunkel-schreibtisch.png index 54df560..d8d1f8f 100644 Binary files a/docs/bilder/einstellungen-dunkel-schreibtisch.png and b/docs/bilder/einstellungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-hell-handy.png b/docs/bilder/einstellungen-hell-handy.png index 091a691..15edef1 100644 Binary files a/docs/bilder/einstellungen-hell-handy.png and b/docs/bilder/einstellungen-hell-handy.png differ diff --git a/docs/bilder/einstellungen-hell-schreibtisch.png b/docs/bilder/einstellungen-hell-schreibtisch.png index d0342c0..b06cbec 100644 Binary files a/docs/bilder/einstellungen-hell-schreibtisch.png and b/docs/bilder/einstellungen-hell-schreibtisch.png differ diff --git a/docs/bilder/ideen-dunkel-handy.png b/docs/bilder/ideen-dunkel-handy.png index 7275a53..92fea31 100644 Binary files a/docs/bilder/ideen-dunkel-handy.png and b/docs/bilder/ideen-dunkel-handy.png differ diff --git a/docs/bilder/ideen-dunkel-schreibtisch.png b/docs/bilder/ideen-dunkel-schreibtisch.png index 45daccf..b274868 100644 Binary files a/docs/bilder/ideen-dunkel-schreibtisch.png and b/docs/bilder/ideen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/ideen-hell-handy.png b/docs/bilder/ideen-hell-handy.png index ddfc8af..894c3f2 100644 Binary files a/docs/bilder/ideen-hell-handy.png and b/docs/bilder/ideen-hell-handy.png differ diff --git a/docs/bilder/ideen-hell-schreibtisch.png b/docs/bilder/ideen-hell-schreibtisch.png index 5079d2a..c99801b 100644 Binary files a/docs/bilder/ideen-hell-schreibtisch.png and b/docs/bilder/ideen-hell-schreibtisch.png differ diff --git a/docs/bilder/inbox-dunkel-handy.png b/docs/bilder/inbox-dunkel-handy.png index 1112518..86833fb 100644 Binary files a/docs/bilder/inbox-dunkel-handy.png and b/docs/bilder/inbox-dunkel-handy.png differ diff --git a/docs/bilder/inbox-dunkel-schreibtisch.png b/docs/bilder/inbox-dunkel-schreibtisch.png index 2212554..2b7efa4 100644 Binary files a/docs/bilder/inbox-dunkel-schreibtisch.png and b/docs/bilder/inbox-dunkel-schreibtisch.png differ diff --git a/docs/bilder/inbox-hell-handy.png b/docs/bilder/inbox-hell-handy.png index af19af5..24927eb 100644 Binary files a/docs/bilder/inbox-hell-handy.png and b/docs/bilder/inbox-hell-handy.png differ diff --git a/docs/bilder/inbox-hell-schreibtisch.png b/docs/bilder/inbox-hell-schreibtisch.png index 213275d..9cea323 100644 Binary files a/docs/bilder/inbox-hell-schreibtisch.png and b/docs/bilder/inbox-hell-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-handy.png b/docs/bilder/kalenderkonten-dunkel-handy.png index dc70a78..bd22a6a 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-handy.png and b/docs/bilder/kalenderkonten-dunkel-handy.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png index c8f0c90..d83b49b 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png and b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-hell-handy.png b/docs/bilder/kalenderkonten-hell-handy.png index 1adb304..9f960e7 100644 Binary files a/docs/bilder/kalenderkonten-hell-handy.png and b/docs/bilder/kalenderkonten-hell-handy.png differ diff --git a/docs/bilder/kalenderkonten-hell-schreibtisch.png b/docs/bilder/kalenderkonten-hell-schreibtisch.png index 584a693..2cc5c07 100644 Binary files a/docs/bilder/kalenderkonten-hell-schreibtisch.png and b/docs/bilder/kalenderkonten-hell-schreibtisch.png differ diff --git a/docs/bilder/projekte-dunkel-handy.png b/docs/bilder/projekte-dunkel-handy.png index 3f6d820..c1a3ce0 100644 Binary files a/docs/bilder/projekte-dunkel-handy.png and b/docs/bilder/projekte-dunkel-handy.png differ diff --git a/docs/bilder/projekte-dunkel-schreibtisch.png b/docs/bilder/projekte-dunkel-schreibtisch.png index 18e0bee..29af3ef 100644 Binary files a/docs/bilder/projekte-dunkel-schreibtisch.png and b/docs/bilder/projekte-dunkel-schreibtisch.png differ diff --git a/docs/bilder/projekte-hell-handy.png b/docs/bilder/projekte-hell-handy.png index d55e0bb..214eb77 100644 Binary files a/docs/bilder/projekte-hell-handy.png and b/docs/bilder/projekte-hell-handy.png differ diff --git a/docs/bilder/projekte-hell-schreibtisch.png b/docs/bilder/projekte-hell-schreibtisch.png index 119e83c..92c3374 100644 Binary files a/docs/bilder/projekte-hell-schreibtisch.png and b/docs/bilder/projekte-hell-schreibtisch.png differ diff --git a/docs/bilder/protokoll-dunkel-handy.png b/docs/bilder/protokoll-dunkel-handy.png index 0c2fff4..a47a390 100644 Binary files a/docs/bilder/protokoll-dunkel-handy.png and b/docs/bilder/protokoll-dunkel-handy.png differ diff --git a/docs/bilder/protokoll-dunkel-schreibtisch.png b/docs/bilder/protokoll-dunkel-schreibtisch.png index 3dc1734..0e41e63 100644 Binary files a/docs/bilder/protokoll-dunkel-schreibtisch.png and b/docs/bilder/protokoll-dunkel-schreibtisch.png differ diff --git a/docs/bilder/protokoll-hell-handy.png b/docs/bilder/protokoll-hell-handy.png index 8091203..8e07574 100644 Binary files a/docs/bilder/protokoll-hell-handy.png and b/docs/bilder/protokoll-hell-handy.png differ diff --git a/docs/bilder/protokoll-hell-schreibtisch.png b/docs/bilder/protokoll-hell-schreibtisch.png index 9daae51..63fa5a2 100644 Binary files a/docs/bilder/protokoll-hell-schreibtisch.png and b/docs/bilder/protokoll-hell-schreibtisch.png differ diff --git a/docs/bilder/start-dunkel-handy.png b/docs/bilder/start-dunkel-handy.png index 52679d6..4c36225 100644 Binary files a/docs/bilder/start-dunkel-handy.png and b/docs/bilder/start-dunkel-handy.png differ diff --git a/docs/bilder/start-dunkel-schreibtisch.png b/docs/bilder/start-dunkel-schreibtisch.png index 8933f22..3b8f860 100644 Binary files a/docs/bilder/start-dunkel-schreibtisch.png and b/docs/bilder/start-dunkel-schreibtisch.png differ diff --git a/docs/bilder/start-hell-handy.png b/docs/bilder/start-hell-handy.png index fc51ef0..e12f579 100644 Binary files a/docs/bilder/start-hell-handy.png and b/docs/bilder/start-hell-handy.png differ diff --git a/docs/bilder/start-hell-schreibtisch.png b/docs/bilder/start-hell-schreibtisch.png index 034ac99..7a93277 100644 Binary files a/docs/bilder/start-hell-schreibtisch.png and b/docs/bilder/start-hell-schreibtisch.png differ diff --git a/docs/bilder/woche-dunkel-handy.png b/docs/bilder/woche-dunkel-handy.png index 23f763f..66da12f 100644 Binary files a/docs/bilder/woche-dunkel-handy.png and b/docs/bilder/woche-dunkel-handy.png differ diff --git a/docs/bilder/woche-dunkel-schreibtisch.png b/docs/bilder/woche-dunkel-schreibtisch.png index 71d42be..18813da 100644 Binary files a/docs/bilder/woche-dunkel-schreibtisch.png and b/docs/bilder/woche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/woche-hell-handy.png b/docs/bilder/woche-hell-handy.png index fc30ca6..ae0f504 100644 Binary files a/docs/bilder/woche-hell-handy.png and b/docs/bilder/woche-hell-handy.png differ diff --git a/docs/bilder/woche-hell-schreibtisch.png b/docs/bilder/woche-hell-schreibtisch.png index f78ac1f..7850618 100644 Binary files a/docs/bilder/woche-hell-schreibtisch.png and b/docs/bilder/woche-hell-schreibtisch.png differ diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md index 38933a0..f3b7b5c 100644 --- a/docs/handbuch/README.md +++ b/docs/handbuch/README.md @@ -20,10 +20,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema. verbinden, Belegung verstehen 7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den Kalender legen +8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail + einrichten, Ruhezeiten setzen ## Verwaltung -8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat +9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat ## Was noch kommt @@ -31,7 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen: | Thema | Meilenstein | | ------------------------------- | ----------- | -| Benachrichtigungen | M5 | | Integrationen | M6 | | Schnellerfassung vom Handy, API | M7 | | MCP | M8 | diff --git a/docs/handbuch/benachrichtigungen.md b/docs/handbuch/benachrichtigungen.md new file mode 100644 index 0000000..b315671 --- /dev/null +++ b/docs/handbuch/benachrichtigungen.md @@ -0,0 +1,108 @@ +# Benachrichtigungen + +Ab Werk meldet der Planer **gar nichts**. Das ist Absicht: Eine Anwendung, +die zu oft meldet, wird stummgeschaltet — und dann verpasst du auch das +Wichtige. Was du hier einschaltest, kommt. Alles andere nicht. + +![Benachrichtigungen in den Einstellungen](../bilder/benachrichtigungen-hell-schreibtisch.png) + +_Der Browser auf dem Bild hat Benachrichtigungen blockiert — so sieht die +Karte aus, wenn die Erlaubnis fehlt. Wie du sie erteilst, steht weiter unten._ + +Du findest die Einstellung unter **Einstellungen → Benachrichtigungen**. + +## Die zwei Wege + +| | Web-Push | E-Mail | +| --------- | ---------------------------------------- | ---------------------------------------- | +| Kommt an | als Systemmeldung auf dem Gerät | im Postfach | +| Braucht | Erlaubnis des Browsers, je Gerät einzeln | nichts weiter | +| Gut für | „gleich beginnt ein Zeitblock" | den Tagesüberblick am Morgen | +| Fällt aus | wenn der Browser die Erlaubnis entzieht | wenn der Mailserver nicht erreichbar ist | + +Beide Wege lassen sich gleichzeitig einschalten. Dann kommt jede Meldung +über beide — der Planer verschickt sie trotzdem nur **einmal je Anlass**. + +Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben. +Was dafür nötig ist, steht im [README](../../README.md), Abschnitt +_Benachrichtigungen_. + +## Push auf einem Gerät einschalten + +1. Auf **Anmelden** klicken. +2. Der Browser fragt nach Erlaubnis — **Zulassen**. +3. Das Gerät erscheint in der Liste darunter. + +Das gilt **je Gerät und je Browser**. Der Rechner im Büro und das Handy +sind zwei Anmeldungen; Firefox und Chrome auf demselben Rechner ebenfalls. + +> **Der Knopf fehlt oder ist grau?** Push braucht HTTPS. Unter +> `http://localhost` geht es zum Entwickeln, unter jeder anderen +> Klartextadresse nicht. Hat der Browser Benachrichtigungen einmal +> blockiert, lässt sich das nur in seinen eigenen Einstellungen +> zurücknehmen — die Anwendung darf dann nicht mehr fragen. + +Ein Gerät, das der Push-Dienst dauerhaft ablehnt, entfernt der Planer von +selbst aus der Liste. Du musst nichts aufräumen. + +## Was gemeldet wird + +| Anlass | Wann | +| ---------------------------------------- | ------------------------------------------------------------------------ | +| **Tagesüberblick** | einmal morgens zur eingestellten Zeit | +| **Fällige Aufgaben** | wenn eine Aufgabe an diesem Tag fällig wird, höchstens einmal je Aufgabe | +| **Gleich beginnt ein Zeitblock** | kurz vor dem Beginn, Vorlauf einstellbar | +| **Kalender lässt sich nicht abgleichen** | erst nach dem dritten Fehlversuch hintereinander | + +Eingeschaltet sind anfangs nur Tagesüberblick und fällige Aufgaben — und +selbst die kommen erst, wenn du einen Weg (Push oder E-Mail) aktiviert hast. + +Der **Tagesüberblick** entfällt an Tagen, an denen es nichts zu sagen gibt. +Eine Meldung „heute steht nichts an" ist keine Information, sondern Lärm. + +Der **Abgleichfehler** kommt bewusst spät: Ein Netz hustet, ein Anbieter hat +Wartung. Erst wenn es dreimal hintereinander nicht klappt, ist es eine +Nachricht wert — dann allerdings dringend, denn solange bleibt deine +Wochenansicht auf einem alten Stand. + +## Ruhezeit + +Voreingestellt ist **20:00 bis 07:00**. In diesem Fenster kommt nichts. + +- Die Ruhezeit läuft über Mitternacht hinweg — das ist der Normalfall. +- Gleiche Start- und Endzeit heißt: **keine** Ruhezeit. +- **Am Wochenende still** legt Samstag und Sonntag komplett still. + +Eine Ausnahme: Der **Tagesüberblick** kommt zu seiner festen Zeit, auch wenn +die noch in der Ruhezeit liegt. Wer ihn auf 06:00 stellt, steht um 06:00 auf +und will ihn dann auch haben. + +Was in der Ruhezeit anfällt, wird **nicht nachgeholt**. Eine Erinnerung an +einen Zeitblock, der längst begonnen hat, hilft niemandem. + +## Ausprobieren + +Der Knopf **Probe senden** schickt eine Testmeldung über alle +eingeschalteten Wege — er umgeht Ruhezeit und Wiederholungssperre. Wer +darauf drückt, will jetzt wissen, ob es ankommt. + +Die Rückmeldung sagt, was passiert ist: an wie viele Geräte der Push ging, +ob die Mail hinausging. Kommt „kein Gerät für Push angemeldet", fehlt die +Anmeldung dieses Browsers. + +## Wenn nichts ankommt + +| Symptom | Ursache | +| ----------------------------------------- | ----------------------------------------------------------------- | +| Probe sagt „verschickt", nichts kommt an | Betriebssystem blendet Browsermeldungen aus (Fokus, Nicht stören) | +| Gerät verschwindet immer wieder | Der Push-Dienst lehnt das Abo ab — neu anmelden | +| Gar nichts, seit der Server umgezogen ist | Neues Schlüsselpaar auf dem Server. Alle Geräte neu anmelden | +| Mail fehlt, Push kommt | SMTP prüfen; Fehler stehen im Serverprotokoll | + +Der Planer prüft alle zehn Minuten, ob etwas zu melden ist. Eine Erinnerung +kann also ein paar Minuten vor der eingestellten Zeit kommen — nie danach. + +--- + +Weiter zur [Wochenplanung](woche.md) oder zurück zum +[Handbuch](README.md). diff --git a/e2e/aufgaben.spec.ts b/e2e/aufgaben.spec.ts index 89fe0af..e8c5175 100644 --- a/e2e/aufgaben.spec.ts +++ b/e2e/aufgaben.spec.ts @@ -1,6 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' /** * Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken. @@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js' * damit die Entwicklungsdatenbank hinterher aussieht wie vorher. */ -const EMAIL = process.env.ADMIN_EMAIL ?? '' -const PASSWORT = process.env.ADMIN_PASSWORT ?? '' const PRAEFIX = `E2E-${Date.now()}` -const verbindung = process.env.DATABASE_URL ?? '' -const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) test.describe('Aufgaben', () => { test.skip( - !EMAIL || !PASSWORT || !verbindung, + !EMAIL || !PASSWORT || !VERBINDUNG, 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', ) @@ -29,14 +27,6 @@ test.describe('Aufgaben', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => { const titel = `${PRAEFIX} Angebot schreiben` await anmelden(page) diff --git a/e2e/benachrichtigungen.spec.ts b/e2e/benachrichtigungen.spec.ts new file mode 100644 index 0000000..e598872 --- /dev/null +++ b/e2e/benachrichtigungen.spec.ts @@ -0,0 +1,91 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { expect, test } from '@playwright/test' +import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' + +/** + * Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten + * Weg geht nichts hinaus. + * + * Der Test rührt nur an der Einstellung des Testkontos und stellt sie am + * Ende wieder her. + */ + +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) + +test.describe('Benachrichtigungen', () => { + test.skip( + !EMAIL || !PASSWORT || !VERBINDUNG, + 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', + ) + + test.afterAll(async () => { + const benutzer = await db.benutzer.findUnique({ + where: { email: EMAIL }, + select: { id: true }, + }) + if (benutzer) { + await db.benachrichtigungseinstellung.deleteMany({ + where: { benutzerId: benutzer.id }, + }) + await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } }) + } + await db.auditEintrag.deleteMany({ + where: { aktion: { startsWith: 'benachrichtigung.' } }, + }) + await db.$disconnect() + }) + + test('speichert die Einstellung und hält sie', async ({ page }) => { + await anmelden(page) + await page.goto('/einstellungen') + + const karte = page.locator('[data-bild="benachrichtigungen"]') + await expect(karte).toBeVisible() + + // --- Ab Werk ist nichts eingeschaltet --------------------------------- + await expect(karte.locator('input[name="perPush"]')).not.toBeChecked() + await expect(karte.locator('input[name="perMail"]')).not.toBeChecked() + + // --- Ohne Weg keine Probe --------------------------------------------- + await karte.getByRole('button', { name: 'Probe schicken' }).click() + await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible() + + // --- Ändern und speichern --------------------------------------------- + await karte.locator('input[name="perMail"]').check() + await karte.locator('input[value="zeitblock"]').check() + await karte.getByLabel('Tagesüberblick um').fill('06:30') + await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20') + await karte.getByLabel('Am Wochenende gar nichts melden').check() + await karte.getByRole('button', { name: 'Speichern' }).click() + await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible() + + // --- Nach dem Neuladen steht alles noch da ---------------------------- + await page.reload() + await expect(karte.locator('input[name="perMail"]')).toBeChecked() + await expect(karte.locator('input[value="zeitblock"]')).toBeChecked() + await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30') + await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue( + '20', + ) + await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked() + }) + + test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => { + await anmelden(page) + await page.goto('/einstellungen') + + const karte = page.locator('[data-bild="benachrichtigungen"]') + const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)') + // Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht + // verlassen, deshalb kommt der Wert an der Oberfläche vorbei. + await vorlauf.evaluate((feld: HTMLInputElement) => { + feld.max = '600' + feld.value = '300' + }) + await karte.getByRole('button', { name: 'Speichern' }).click() + + await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible() + await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible() + }) +}) diff --git a/e2e/hilfe.ts b/e2e/hilfe.ts new file mode 100644 index 0000000..d5b601b --- /dev/null +++ b/e2e/hilfe.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from '@playwright/test' + +/** Gemeinsames für alle E2E-Tests. */ + +export const EMAIL = process.env.ADMIN_EMAIL ?? '' +export const PASSWORT = process.env.ADMIN_PASSWORT ?? '' +export const VERBINDUNG = process.env.DATABASE_URL ?? '' + +/** + * Meldet das Testkonto mit Passwort an. + * + * Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der + * Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen. + * Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste + * Klick schon gewirkt, wird nicht noch einmal geklickt. + */ +export async function anmelden( + seite: Page, + email: string = EMAIL, + passwort: string = PASSWORT, +): Promise { + await seite.goto('/anmelden') + await seite.getByLabel('E-Mail-Adresse').fill(email) + await seite.getByLabel('Passwort').fill(passwort) + + const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' }) + const begruessung = seite.getByRole('heading', { name: /Guten Tag/ }) + + await expect(async () => { + if (new URL(seite.url()).pathname === '/anmelden') { + await knopf.click({ timeout: 5_000 }) + } + await expect(begruessung).toBeVisible({ timeout: 5_000 }) + }).toPass({ timeout: 45_000 }) +} diff --git a/e2e/rollen.spec.ts b/e2e/rollen.spec.ts index db89306..d9b9eff 100644 --- a/e2e/rollen.spec.ts +++ b/e2e/rollen.spec.ts @@ -1,7 +1,8 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import bcrypt from 'bcryptjs' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden as anmeldenMit } from './hilfe' /** * Prüft, dass die Rollentrennung serverseitig hält. @@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0) }) test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ page, }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await page.goto('/verwaltung/protokoll') await expect(page).toHaveURL(/\/keine-berechtigung/) @@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => { }) test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { - await anmelden(page) + await anmeldenMit(page, EMAIL, PASSWORT) await page.goto('/bereiche') await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() diff --git a/e2e/woche.spec.ts b/e2e/woche.spec.ts index 94d7394..beb54da 100644 --- a/e2e/woche.spec.ts +++ b/e2e/woche.spec.ts @@ -1,6 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { PrismaClient } from '../src/generated/prisma/client.js' +import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe' /** * Wochenansicht und Zeitblöcke. @@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js' * Kalender nicht erreichbar ist. */ -const EMAIL = process.env.ADMIN_EMAIL ?? '' -const PASSWORT = process.env.ADMIN_PASSWORT ?? '' const PRAEFIX = `E2E-W-${Date.now()}` -const verbindung = process.env.DATABASE_URL ?? '' -const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) test.describe('Woche', () => { test.skip( - !EMAIL || !PASSWORT || !verbindung, + !EMAIL || !PASSWORT || !VERBINDUNG, 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', ) @@ -34,14 +32,6 @@ test.describe('Woche', () => { await db.$disconnect() }) - async function anmelden(seite: Page): Promise { - await seite.goto('/anmelden') - await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) - await seite.getByLabel('Passwort').fill(PASSWORT) - await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() - await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() - } - test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => { await db.kalenderkonto.deleteMany({}) await anmelden(page) diff --git a/eslint.config.mjs b/eslint.config.mjs index 09e0de2..13448bc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/package-lock.json b/package-lock.json index 843ba97..65df5ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "planer", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planer", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", @@ -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", diff --git a/package.json b/package.json index 7568b03..6594f9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "planer", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "type": "module", @@ -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", diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts index fee602e..7e81a9c 100644 --- a/prisma/demo-seed.ts +++ b/prisma/demo-seed.ts @@ -418,6 +418,30 @@ async function main(): Promise { }, }) + // --- Benachrichtigungen --------------------------------------------------- + // Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem, + // was das Kapitel erklärt. + await db.benachrichtigungseinstellung.upsert({ + where: { benutzerId: benutzer.id }, + update: {}, + create: { + benutzerId: benutzer.id, + anlaesse: 'tagesueberblick,faelligkeit,zeitblock', + perPush: true, + perMail: true, + ueberblickUm: 7 * 60, + ruheVon: 20 * 60, + ruheBis: 7 * 60, + ruheAmWochenende: false, + zeitblockVorlaufMinuten: 10, + }, + }) + + // Kein Push-Gerät in den Demo-Daten: Seine Schlüssel liegen + // verschlüsselt, und der Versand entfernt jedes Gerät, dessen Schlüssel er + // nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf + // wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung. + console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) } diff --git a/prisma/migrations/20260911044207_benachrichtigungen/migration.sql b/prisma/migrations/20260911044207_benachrichtigungen/migration.sql new file mode 100644 index 0000000..963d288 --- /dev/null +++ b/prisma/migrations/20260911044207_benachrichtigungen/migration.sql @@ -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; diff --git a/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql b/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql new file mode 100644 index 0000000..ab8a762 --- /dev/null +++ b/prisma/migrations/20260911045857_abgleich_fehlversuche/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 70c018f..dd7b924 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bbe231e --- /dev/null +++ b/public/sw.js @@ -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) + }), + ) +}) diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts index 573fa12..15f69a2 100644 --- a/screenshots/bilder.spec.ts +++ b/screenshots/bilder.spec.ts @@ -24,6 +24,13 @@ type Aufnahme = { pfad: string /** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */ vorbereiten?: (seite: Page) => Promise + /** + * Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. + * + * Für Themen, die auf einer gemeinsamen Seite wohnen: ein Bild der ganzen + * Einstellungsseite zeigt im Handbuchkapitel zu wenig vom Eigentlichen. + */ + ausschnitt?: string } const AUFNAHMEN: Aufnahme[] = [ @@ -37,6 +44,11 @@ const AUFNAHMEN: Aufnahme[] = [ { name: 'bereiche', pfad: '/bereiche' }, { name: 'kalenderkonten', pfad: '/kalenderkonten' }, { name: 'einstellungen', pfad: '/einstellungen' }, + { + name: 'benachrichtigungen', + pfad: '/einstellungen', + ausschnitt: '[data-bild="benachrichtigungen"]', + }, { name: 'protokoll', pfad: '/verwaltung/protokoll' }, ] @@ -82,10 +94,12 @@ for (const schema of ['hell', 'dunkel'] as const) { // Der Farbschema-Umschalter und Schriften sollen fertig sein. await seite.waitForTimeout(300) - await seite.screenshot({ - path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`, - fullPage: true, - }) + const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png` + if (aufnahme.ausschnitt) { + await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel }) + } else { + await seite.screenshot({ path: ziel, fullPage: true }) + } } await kontext.close() diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 84f6055..58b4cd6 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -56,6 +56,8 @@ async function aufraeumen(): Promise { await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.webauthnChallenge.deleteMany({ where: { kennung } }) + await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } }) + await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } }) } async function main(): Promise { @@ -259,6 +261,61 @@ async function main(): Promise { }) pruefe('Audit-Eintrag anlegen', audit.id.length > 0) + // --- Benachrichtigungen -------------------------------------------------- + await db.benachrichtigungseinstellung.create({ + data: { benutzerId: benutzer.id }, + }) + await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () => + db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }), + ) + + const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({ + where: { benutzerId: benutzer.id }, + }) + pruefe( + 'Ab Werk ist kein Weg eingeschaltet', + voreinstellung.perPush === false && voreinstellung.perMail === false, + ) + + await db.pushGeraet.create({ + data: { + benutzerId: benutzer.id, + endpunkt: `https://push.invalid/${kennung}`, + p256dh: 'x', + auth: 'y', + }, + }) + await scheitert('Derselbe Push-Endpunkt geht nur einmal', () => + db.pushGeraet.create({ + data: { + benutzerId: benutzer.id, + endpunkt: `https://push.invalid/${kennung}`, + p256dh: 'x', + auth: 'y', + }, + }), + ) + + await db.meldungsnachweis.create({ + data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` }, + }) + await scheitert('Derselbe Meldungsschluessel geht nur einmal', () => + db.meldungsnachweis.create({ + data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` }, + }), + ) + pruefe( + 'Derselbe Schlüssel am nächsten Tag ist erlaubt', + ( + await db.meldungsnachweis.create({ + data: { + benutzerId: benutzer.id, + schluessel: `faelligkeit:${kennung}:2026-03-18`, + }, + }) + ).id.length > 0, + ) + await db.benutzer.delete({ where: { id: benutzer.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) @@ -280,6 +337,16 @@ async function main(): Promise { 'Kalenderkonten verschwinden mit dem Benutzer', (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, ) + pruefe( + 'Push-Geräte verschwinden mit dem Benutzer', + (await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0, + ) + pruefe( + 'Meldungsnachweise verschwinden mit dem Benutzer', + (await db.meldungsnachweis.count({ + where: { schluessel: { contains: kennung } }, + })) === 0, + ) pruefe( 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index ede8135..d3dcae5 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -14,6 +14,7 @@ */ import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import webpush from 'web-push' const WURZEL = fileURLToPath(new URL('..', import.meta.url)) const PROJEKT = 'planer-screenshots' @@ -22,10 +23,16 @@ const COMPOSE_DATEI = 'docker-compose.screenshots.yml' const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200' const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455' +// Frisches Paar je Lauf: Die Bilder sollen die eingerichtete Anwendung +// zeigen, ohne dass irgendwo ein privater Schluessel liegen bleibt. +const VAPID = webpush.generateVAPIDKeys() + const UMGEBUNG = { ...process.env, SCREENSHOT_APP_PORT: APP_PORT, SCREENSHOT_DB_PORT: DB_PORT, + SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey, + SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey, } function meldung(text: string): void { diff --git a/src/app/(app)/einstellungen/benachrichtigungen.tsx b/src/app/(app)/einstellungen/benachrichtigungen.tsx new file mode 100644 index 0000000..a5baa5e --- /dev/null +++ b/src/app/(app)/einstellungen/benachrichtigungen.tsx @@ -0,0 +1,448 @@ +'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` + * lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an. + */ +function schluesselAlsBytes(base64url: string): Uint8Array { + 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>({}) + 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) { + 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 } + 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 ( +
+ {/* Dieses Gerät */} +
+ {pushZustand === 'an' ? ( + + ) : ( + + )} + +
+

Dieses Gerät

+

+ {/* + Fehlen die Schlüssel auf dem Server, geht hier ohnehin nichts – + dann über den Browser zu reden, schickt nur auf die falsche + Fährte. + */} + {!pushMoeglich + ? 'Ohne Push-Schlüssel auf dem Server empfängt kein Gerät etwas.' + : 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.' + : '…'} +

+
+ + {!pushMoeglich ? ( + Auf dem Server nicht eingerichtet + ) : pushZustand === 'an' ? ( + + ) : ( + pushZustand !== 'nicht-unterstuetzt' && + pushZustand !== 'verweigert' && ( + + ) + )} +
+ + {/* Einstellungen */} +
{ + ereignis.preventDefault() + const daten = new FormData(ereignis.currentTarget) + mitRueckmeldung(() => einstellungSpeichern(daten)) + }} + > +
+ Worüber + {ANLAESSE.map((anlass) => ( + + ))} +
+ +
+ Wie + + + + +
+ +
+
+ + +
+ +
+ + + {felder.zeitblockVorlaufMinuten && ( +

{felder.zeitblockVorlaufMinuten}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + + +

+ In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst + nie, wenn du früh aufstehst. +

+ +
+ + +
+
+ + {/* Angemeldete Geräte */} + {geraete.length > 0 && ( +
+

Angemeldete Geräte

+
    + {geraete.map((geraet) => ( +
  • +
    +

    {geraet.bezeichnung ?? 'Unbenanntes Gerät'}

    +

    + Angemeldet {geraet.erstelltAm} + {geraet.zuletztBenutzt + ? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}` + : ' · noch nichts empfangen'} +

    +
    + +
  • + ))} +
+
+ )} +
+ ) +} + +/** 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' +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index a47c835..c1514a4 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -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() { /> + + + + Benachrichtigungen + + 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. + + + + ({ + id: g.id, + bezeichnung: g.bezeichnung, + erstelltAm: alsDatumUhrzeit(g.erstelltAm), + zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null, + }))} + /> + + ) } diff --git a/src/app/api/push/route.ts b/src/app/api/push/route.ts new file mode 100644 index 0000000..77cadc6 --- /dev/null +++ b/src/app/api/push/route.ts @@ -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 }) +} diff --git a/src/lib/benachrichtigung.test.ts b/src/lib/benachrichtigung.test.ts new file mode 100644 index 0000000..8eb62a0 --- /dev/null +++ b/src/lib/benachrichtigung.test.ts @@ -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 { + 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() + + 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.', + ) + }) +}) diff --git a/src/lib/benachrichtigung.ts b/src/lib/benachrichtigung.ts new file mode 100644 index 0000000..0fbb16b --- /dev/null +++ b/src/lib/benachrichtigung.ts @@ -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 = { + 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 = { + 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, + 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 + +/** 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}.` +} diff --git a/src/server/aktionen/benachrichtigungen.ts b/src/server/aktionen/benachrichtigungen.ts new file mode 100644 index 0000000..ca7f71e --- /dev/null +++ b/src/server/aktionen/benachrichtigungen.ts @@ -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 } + +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 { + 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 = {} + 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 { + 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 { + 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.' } +} diff --git a/src/server/benachrichtigung/mail.ts b/src/server/benachrichtigung/mail.ts new file mode 100644 index 0000000..29b8e47 --- /dev/null +++ b/src/server/benachrichtigung/mail.ts @@ -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) => `

${maskieren(z)}

`) + .join('') + + return [ + '
', + `

${maskieren(meldung.titel)}

`, + zeilen, + `

Im Planer ansehen

`, + '
', + ].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 { + 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.', + } + } +} diff --git a/src/server/benachrichtigung/push.ts b/src/server/benachrichtigung/push.ts new file mode 100644 index 0000000..906d039 --- /dev/null +++ b/src/server/benachrichtigung/push.ts @@ -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 { + 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 { + 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 } +} diff --git a/src/server/benachrichtigung/versand.ts b/src/server/benachrichtigung/versand.ts new file mode 100644 index 0000000..c3be9ee --- /dev/null +++ b/src/server/benachrichtigung/versand.ts @@ -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 { + const zeilen = await db.benutzer.findMany({ + where: { aktiv: true }, + select: { + id: true, + email: true, + name: true, + benachrichtigung: { + select: { + anlaesse: true, + perPush: true, + perMail: true, + ueberblickUm: true, + ruheVon: true, + ruheBis: true, + ruheAmWochenende: true, + zeitblockVorlaufMinuten: true, + }, + }, + }, + }) + + return zeilen.map((z) => ({ + id: z.id, + email: z.email, + name: z.name, + einstellung: alsEinstellung(z.benachrichtigung), + })) +} + +/** + * Stellt eine Meldung zu, wenn sie darf, und hält den Nachweis fest. + * + * Der Nachweis wird **vor** dem Versand geschrieben. Bricht der Versand ab, + * gilt die Meldung trotzdem als erledigt — lieber eine verpasste Nachricht + * als eine, die im Minutentakt erneut versucht wird. + */ +export async function meldungZustellen( + benutzer: BenutzerMitEinstellung, + meldung: Meldung, + zeitpunkt: Date, +): Promise { + const gemeldet = await db.meldungsnachweis.findUnique({ + where: { + benutzerId_schluessel: { benutzerId: benutzer.id, schluessel: meldung.schluessel }, + }, + select: { id: true }, + }) + + const entscheidung = sendenEntscheiden( + benutzer.einstellung, + meldung, + new Set(gemeldet ? [meldung.schluessel] : []), + zeitpunkt, + ) + if (!entscheidung.senden) return false + + await db.meldungsnachweis.create({ + data: { + benutzerId: benutzer.id, + schluessel: meldung.schluessel, + gesendetAm: zeitpunkt, + }, + }) + + if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung) + if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung) + + return true +} + +// --- Die einzelnen Anlässe ------------------------------------------------- + +/** Baut den Tagesüberblick: was heute ansteht. */ +async function tagesueberblickBauen( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const heute = tagesBeginn(zeitpunkt) + const heuteEnde = tagesEnde(zeitpunkt) + + const [ueberfaellig, heuteFaellig, zeitbloecke] = await Promise.all([ + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { lt: heute }, + }, + }), + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { gte: heute, lte: heuteEnde }, + }, + }), + db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId }, + beginn: { gte: heute, lte: heuteEnde }, + }, + orderBy: { beginn: 'asc' }, + select: { beginn: true, ende: true, aufgabe: { select: { titel: true } } }, + }), + ]) + + // Ein Überblick, der nichts zu sagen hat, ist keine Nachricht wert. + if (ueberfaellig === 0 && heuteFaellig === 0 && zeitbloecke.length === 0) return null + + const teile: string[] = [] + if (ueberfaellig > 0) teile.push(`${ueberfaellig} überfällig`) + if (heuteFaellig > 0) teile.push(`${heuteFaellig} heute fällig`) + + const geplanteMinuten = zeitbloecke.reduce( + (summe, z) => summe + (z.ende.getTime() - z.beginn.getTime()) / 60_000, + 0, + ) + if (zeitbloecke.length > 0) { + teile.push(`${zeitbloecke.length} Zeitblöcke (${alsDauer(geplanteMinuten)})`) + } + + const zeilen = [teile.join(', ') + '.'] + for (const block of zeitbloecke.slice(0, 5)) { + zeilen.push(`${alsUhrzeitAnzeige(block.beginn)} ${block.aufgabe.titel}`) + } + if (zeitbloecke.length > 5) zeilen.push(`… und ${zeitbloecke.length - 5} weitere.`) + + return { + anlass: 'tagesueberblick', + schluessel: meldungsSchluessel( + 'tagesueberblick', + 'tag', + alsTagesschluessel(zeitpunkt), + ), + titel: 'Dein Tag', + text: zeilen.join('\n'), + pfad: '/', + } +} + +/** Aufgaben, die heute fällig werden. */ +async function faelligkeitenBauen(zeitpunkt: Date): Promise { + const aufgaben = await db.aufgabe.findMany({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + faelligAm: { gte: tagesBeginn(zeitpunkt), lte: tagesEnde(zeitpunkt) }, + }, + select: { id: true, titel: true }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return aufgaben.map((aufgabe) => ({ + anlass: 'faelligkeit' as const, + schluessel: meldungsSchluessel('faelligkeit', aufgabe.id, tag), + titel: 'Heute fällig', + text: aufgabe.titel, + pfad: '/aufgaben', + })) +} + +/** Zeitblöcke, die gleich beginnen. */ +async function zeitblockErinnerungenBauen( + benutzer: BenutzerMitEinstellung, + zeitpunkt: Date, +): Promise { + const bloecke = await db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId: benutzer.id }, + beginn: { + gte: zeitpunkt, + lte: new Date( + zeitpunkt.getTime() + + (benutzer.einstellung.zeitblockVorlaufMinuten + 15) * 60_000, + ), + }, + }, + select: { id: true, beginn: true, aufgabe: { select: { titel: true } } }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return bloecke + .filter((b) => zeitblockErinnerungFaellig(benutzer.einstellung, b.beginn, zeitpunkt)) + .map((block) => ({ + anlass: 'zeitblock' as const, + schluessel: meldungsSchluessel('zeitblock', block.id, tag), + titel: `${alsUhrzeitAnzeige(block.beginn)} geht es los`, + text: block.aufgabe.titel, + pfad: '/woche', + })) +} + +/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */ +async function abgleichfehlerBauen( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const konten = await db.kalenderkonto.findMany({ + where: { + benutzerId, + aktiv: true, + letzterFehler: { not: null }, + // Ein Aussetzer ist keine Nachricht wert – ein Netz hustet, ein + // Anbieter hat Wartung. Erst mehrere Fehlschläge hintereinander. + fehlversuche: { gte: ABGLEICHFEHLER_AB_VERSUCH }, + }, + select: { id: true, anzeigename: true, letzterFehler: true }, + }) + + const tag = alsTagesschluessel(zeitpunkt) + return konten.map((konto) => ({ + anlass: 'abgleichfehler' as const, + schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag), + titel: 'Ein Kalender lässt sich nicht abgleichen', + text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`, + pfad: '/kalenderkonten', + })) +} + +export type VersandBericht = { geprueft: number; gesendet: number } + +/** + * Ein Durchlauf: für jeden Benutzer alles zusammentragen, was jetzt fällig + * wäre, und zustellen, was darf. + */ +export async function benachrichtigungenVerschicken( + zeitpunkt: Date = jetzt(), +): Promise { + const empfaenger = await empfaengerLaden() + let gesendet = 0 + + for (const benutzer of empfaenger) { + // Wer keinen Weg hinaus eingerichtet hat, braucht auch nichts gebaut. + if (!benutzer.einstellung.perPush && !benutzer.einstellung.perMail) continue + + const meldungen: Meldung[] = [] + + if ( + benutzer.einstellung.anlaesse.includes('tagesueberblick') && + ueberblickFaellig(benutzer.einstellung, zeitpunkt) + ) { + const ueberblick = await tagesueberblickBauen(benutzer.id, zeitpunkt) + if (ueberblick) meldungen.push(ueberblick) + } + + if (benutzer.einstellung.anlaesse.includes('faelligkeit')) { + meldungen.push(...(await faelligkeitenBauen(zeitpunkt))) + } + + if (benutzer.einstellung.anlaesse.includes('zeitblock')) { + meldungen.push(...(await zeitblockErinnerungenBauen(benutzer, zeitpunkt))) + } + + if (benutzer.einstellung.anlaesse.includes('abgleichfehler')) { + meldungen.push(...(await abgleichfehlerBauen(benutzer.id, zeitpunkt))) + } + + for (const meldung of meldungen) { + if (await meldungZustellen(benutzer, meldung, zeitpunkt)) gesendet++ + } + } + + return { geprueft: empfaenger.length, gesendet } +} + +/** Alte Nachweise wegräumen; sonst wächst die Tabelle unbegrenzt. */ +export async function alteNachweiseEntfernen(zeitpunkt: Date = jetzt()): Promise { + const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000) + const ergebnis = await db.meldungsnachweis.deleteMany({ + where: { gesendetAm: { lt: grenze } }, + }) + return ergebnis.count +} diff --git a/src/server/jobs.ts b/src/server/jobs.ts index a7df519..62a33ec 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -3,6 +3,10 @@ import cron from 'node-cron' import { db } from '@/server/db' import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' +import { + alteNachweiseEntfernen, + benachrichtigungenVerschicken, +} from '@/server/benachrichtigung/versand' import { jetzt } from '@/server/zeitgeber' /** @@ -19,6 +23,14 @@ const globalerBereich = globalThis as unknown as { planerJobs?: boolean } const ABGLEICH_PLAN = '*/15 * * * *' /** Einmal nachts aufräumen. */ const AUFRAEUM_PLAN = '17 3 * * *' +/** + * Alle zehn Minuten nachsehen, ob etwas zu melden ist. + * + * Der Takt passt zu den Toleranzfenstern in src/lib/benachrichtigung.ts: Ein + * Anlass, der in einem 15-Minuten-Fenster liegt, wird sicher getroffen, aber + * dank Wiederholungssperre nur einmal gemeldet. + */ +const MELDUNGS_PLAN = '*/10 * * * *' async function alleKontenAbgleichen(): Promise { const zeitpunkt = jetzt() @@ -62,8 +74,25 @@ export function jobsStarten(): void { }) }) + cron.schedule(MELDUNGS_PLAN, () => { + void benachrichtigungenVerschicken() + .then((bericht) => { + // Nur Zahlen, nie Empfänger oder Inhalte. + if (bericht.gesendet > 0) { + console.warn(`Benachrichtigungen: ${bericht.gesendet} verschickt.`) + } + }) + .catch((fehler: unknown) => { + console.error('Benachrichtigungen abgebrochen:', fehler) + }) + }) + cron.schedule(AUFRAEUM_PLAN, () => { - void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) + void Promise.all([ + alteTermineEntfernen(), + abgelaufeneChallengesEntfernen(jetzt()), + alteNachweiseEntfernen(jetzt()), + ]) .then(() => undefined) .catch((fehler: unknown) => { console.error('Aufräumen abgebrochen:', fehler) diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts index e40478a..333a923 100644 --- a/src/server/kalender/abgleich.ts +++ b/src/server/kalender/abgleich.ts @@ -339,6 +339,7 @@ export async function kontoAbgleichen( ...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}), letzterAbgleich: zeitpunkt, letzterFehler: null, + fehlversuche: 0, }, }) @@ -355,7 +356,11 @@ export async function kontoAbgleichen( await db.kalenderkonto.update({ where: { id: konto.id }, - data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung }, + data: { + letzterAbgleich: zeitpunkt, + letzterFehler: meldung, + fehlversuche: { increment: 1 }, + }, }) return { diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts index 533c9a4..832ae18 100644 --- a/src/server/umgebung.ts +++ b/src/server/umgebung.ts @@ -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 & { /** 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), } }