Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da2068440 | ||
|
|
01d7b10599 | ||
|
|
350f5fdbfc | ||
|
|
bb7b189e53 | ||
|
|
35ecb2fa78 | ||
|
|
7bef4351ec | ||
|
|
6e1f2791cd | ||
|
|
f03ea73dcd | ||
|
|
5bbbdd5209 | ||
|
|
047a4a1c74 |
@@ -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
|
||||
|
||||
@@ -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,13 +45,13 @@ 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 |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
@@ -60,6 +62,7 @@ MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
| Kalender |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Meldungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
|
||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||
@@ -136,6 +139,38 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/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 <planer@example.de>` |
|
||||
| `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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,12 +6,106 @@ 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** |
|
||||
| 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<ArrayBufferLike>` 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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 <planer@planer.invalid>
|
||||
ports:
|
||||
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
_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).
|
||||
@@ -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<void> {
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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 })
|
||||
}
|
||||
@@ -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<void> {
|
||||
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()
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -418,6 +418,30 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// --- 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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ type Aufnahme = {
|
||||
pfad: string
|
||||
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
||||
vorbereiten?: (seite: Page) => Promise<void>
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -56,6 +56,8 @@ async function aufraeumen(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -259,6 +261,61 @@ async function main(): Promise<void> {
|
||||
})
|
||||
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<void> {
|
||||
'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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -206,13 +206,22 @@ export function Benachrichtigungen({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Dieses Gerät</p>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{pushZustand === 'nicht-unterstuetzt' &&
|
||||
'Dieser Browser kann keine Push-Nachrichten empfangen.'}
|
||||
{pushZustand === 'verweigert' &&
|
||||
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'}
|
||||
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'}
|
||||
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'}
|
||||
{pushZustand === 'unbekannt' && '…'}
|
||||
{/*
|
||||
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.'
|
||||
: '…'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||