20 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 bc60e5f864 feat: Integrationen takten und Telegram als dritten Meldeweg
Telegram alle zwei Minuten, Zammad und Spiegel alle fuenfzehn. Wer dem
Bot etwas schickt, erwartet die Aufgabe gleich in der Liste; in Zammad
aendert sich dagegen nichts im Minutentakt.

Meldungen gehen jetzt auch per Telegram hinaus — mit einem Knopf zum
Abhaken, wenn es um genau eine Aufgabe geht. Eine klemmende Integration
meldet sich wie ein klemmender Kalender: erst nach dem dritten
Fehlversuch, dann aber deutlich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:05 +02:00
Florian WildeandClaude Opus 5 6dad3331e2 feat: Zammad, Telegram und den Spiegel anbinden
Zammad liest ueber die Suche und schreibt nichts zurueck. Telegram holt
per getUpdates statt per Webhook — der Planer soll auch hinter einem
Anschluss ohne feste Adresse laufen. Der Spiegel schickt Belegungen ohne
Titel und nur die ausdruecklich markierten Aufgaben.

Wer dem Telegram-Bot schreibt, ohne im Adressbuch zu stehen, bekommt
keine Antwort. Die einzige Ausnahme ist ein gueltiger Kopplungscode: Er
ist der Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
Ohne ihn muesste man die Chatkennung von Hand eintragen — und um sie zu
erfahren, muesste der Bot Unbekannten antworten.

Jede Sendung des Spiegels traegt eine Signatur ueber den Rumpf und gilt
fuenf Minuten. Der Empfaenger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr oder Unsinn im Inhalt sind von aussen
nicht zu unterscheiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:03 +02:00
Florian WildeandClaude Opus 5 e4d3547457 feat: Aufnahmeregeln fuer Integrationen als reine Logik
Was aus einem Fund wird, entscheidet fuer alle Integrationen dieselbe
Stelle: anlegen, aktualisieren, schliessen oder nichts tun.

Gefuehrt werden nur Titel, Notiz, Verweis und Faelligkeit. Bereich,
Projekt, Prioritaet und geschaetzter Aufwand gehoeren der Planung — wer
ein Ticket einsortiert und schaetzt, will nicht, dass der naechste Lauf
das wieder wegraeumt.

Dazu die Regeln des Spiegels: Aufbau der Sendung und ihre Frist. Ohne
Frist liesse sich ein abgefangenes Paket beliebig oft erneut einspielen.

21 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:33 +02:00
Florian WildeandClaude Opus 5 7f8f505126 feat: Datenmodell und Umgebung fuer Integrationen
Ein Zustand je Quelle haelt fest, wie weit gelesen wurde und ob es
klappt. Der Merker ist bewusst ein freier Text: Zammad braucht darin
einen Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.

Dazu die gespiegelte Belegung ohne Titel, das Adressbuch der erlaubten
Telegram-Chats und der Einmalcode, mit dem sich ein Chat anmeldet.

Welche Integration eine Instanz darf, entscheidet weiterhin INSTANZ. Ein
Zammad-Token in der Instanz daheim bleibt wirkungslos — das steht in den
Faehigkeiten, nicht in einer Einstellung, die sich verstellen liesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:32 +02:00
Florian WildeandClaude Opus 5 ab1b898e44 Merge: Meilenstein 5 – Benachrichtigungen (0.5.0)
Der Planer sagt jetzt, was ansteht: Tagesueberblick am Morgen, faellige
Aufgaben, Vorlauf vor einem Zeitblock und ein Kalender, der sich nicht
abgleichen laesst. Per Web-Push, per E-Mail oder beides.

Ab Werk meldet er gar nichts. Eine Anwendung, die zu oft meldet, wird
stummgeschaltet und ist dann wertlos — deshalb ist die Einrichtung ein
bewusster Handgriff, dafuer kommt dann auch nur, was gewollt ist.

Der Nachweis einer Meldung wird vor dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Geprueft vor dem Merge: npm run pruefen mit 280 Unit-Tests, 47
Integrationspruefungen, Migrationstest und 20 E2E-Tests. Der Versandweg ist
gegen die Logik geprueft, nicht gegen echte Dienste — dafuer fehlen
VAPID-Schluessel und SMTP-Zugang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:43:55 +02:00
Florian WildeandClaude Opus 5 1da2068440 chore(release): 0.5.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:39:02 +02:00
Florian WildeandClaude Opus 5 01d7b10599 docs: DEVLOG-Eintrag fuer M5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:04 +02:00
Florian WildeandClaude Opus 5 350f5fdbfc docs: Fahrplan und Aenderungen fuer M5 fortschreiben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:02 +02:00
Florian WildeandClaude Opus 5 bb7b189e53 docs: Handbuchbilder fuer M5 neu aufnehmen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:44 +02:00
Florian WildeandClaude Opus 5 35ecb2fa78 chore: Screenshot-Instanz mit eingerichteten Meldewegen
Die Wegwerf-Instanz bekommt je Lauf ein frisch erzeugtes VAPID-Paar und
einen Mailhost, den es nie geben wird. Damit zeigen die Bilder den
eingerichteten Normalfall statt zweier Warnhinweise — und im Verzeichnis
bleibt kein privater Schluessel liegen.

Kein Push-Geraet mehr in den Demo-Daten: Seine Schluessel liegen
verschluesselt, und der Versand entfernt jedes Geraet, dessen Schluessel
er nicht lesen kann. Der erfundene Eintrag waere beim ersten Lauf wieder
verschwunden.

Dass der Browser im Bild "blockiert" meldet, laesst sich nicht abstellen:
Chromium im Kopflosbetrieb antwortet auf Notification.permission immer so.
Das Handbuch sagt es jetzt dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:40 +02:00
Florian WildeandClaude Opus 5 7bef4351ec fix: Serverzustand geht der Browsermeldung vor
Fehlen die Push-Schluessel auf dem Server, stand unter "Dieses Geraet"
trotzdem, der Browser habe blockiert — zwei Aussagen nebeneinander, von
denen nur eine zaehlt. Jetzt sagt die Karte zuerst das, was den Ausschlag
gibt.

Aufgefallen ist es erst im aufgenommenen Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:39 +02:00
Florian WildeandClaude Opus 5 6e1f2791cd test: Benachrichtigungen end-to-end und in der Datenbank absichern
Die Einstellung haelt ueber das Neuladen, ohne eingeschalteten Weg geht
nichts hinaus, und ein unsinniger Vorlauf wird auch dann abgewiesen, wenn
er an der Oberflaeche vorbei hereinkommt.

Der Anmeldehelfer liegt jetzt einmal in e2e/hilfe.ts und fasst nach: ein
Klick, der vor der Hydratation ankommt, verpufft folgenlos — das hat die
Testlaeufe unter Last wiederholt scheitern lassen.

Der Integrationstest prueft die drei neuen Tabellen samt Kaskade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:28:32 +02:00
Florian WildeandClaude Opus 5 f03ea73dcd docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:23 +02:00
Florian WildeandClaude Opus 5 5bbbdd5209 chore: Demo-Daten und Bildaufnahme fuer Benachrichtigungen
Die Aufnahme kann jetzt einen einzelnen Ausschnitt statt der ganzen Seite
festhalten. Ein Bild der kompletten Einstellungsseite zeigt im Kapitel zu
den Benachrichtigungen zu wenig vom Eigentlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:20 +02:00
Florian WildeandClaude Opus 5 047a4a1c74 feat: Meldungen alle zehn Minuten pruefen und verschicken
Der Takt passt zu den Toleranzfenstern: ein Anlass in einem
Viertelstundenfenster wird sicher getroffen, dank Sperre aber nur einmal
gemeldet. Eine Erinnerung kommt damit hoechstens ein paar Minuten frueher
als gewollt, nie spaeter.

Der Kalenderabgleich zaehlt seine Fehlversuche jetzt mit und setzt den
Zaehler bei Erfolg zurueck.

Alte Nachweise raeumt der naechtliche Lauf weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:00 +02:00
Florian WildeandClaude Opus 5 b622c1bb28 feat: Benachrichtigungen in den Einstellungen bedienen
Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der
Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht
eingerichtet, steht das direkt daneben statt in einem Fehler beim
Speichern.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:57 +02:00
Florian WildeandClaude Opus 5 e0e11c85b2 feat: Versand per Web-Push und E-Mail
Push ueber VAPID, Mail ueber SMTP, beide unabhaengig zuschaltbar. Die
Schluessel eines Push-Geraets liegen verschluesselt in der Datenbank.

Ein Geraet, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler zaehlen hoch und fuehren nach fuenf Versuchen
zum selben Ergebnis. Eine Liste toter Abos putzt sich so von allein.

Der Nachweis wird vor dem Senden geschrieben, nicht danach: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Weder Endpunkt noch Schluessel noch Mailinhalt landen im Protokoll.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:33:28 +02:00
95 changed files with 4636 additions and 87 deletions
+46
View File
@@ -74,6 +74,52 @@ GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# --- Benachrichtigungen ----------------------------------------------------
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
# weiter und sagt es in den Einstellungen.
# Web-Push. Schluesselpaar einmalig erzeugen mit:
# npx web-push generate-vapid-keys
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
VAPID_OEFFENTLICH=
VAPID_PRIVAT=
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
VAPID_KONTAKT=
# E-Mail. Push braucht SMTP nicht und umgekehrt.
SMTP_HOST=
SMTP_PORT=587
SMTP_BENUTZER=
SMTP_PASSWORT=
# Absender, z. B. "Planer <planer@example.de>".
SMTP_ABSENDER=
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
SMTP_TLS=false
# --- Integrationen ---------------------------------------------------------
# Alles freiwillig. Was fehlt, erscheint unter "Integrationen" als nicht
# eingerichtet; der Rest laeuft weiter.
# Welche Integration eine Instanz ueberhaupt darf, entscheidet INSTANZ:
# Zammad und Wartungsplaner nur "firma", Telegram nur "daheim".
# Zammad (nur firma). Token in Zammad unter Profil > Token-Zugriff anlegen;
# er sieht genau das, was der zugehoerige Benutzer sehen darf.
ZAMMAD_URL=
ZAMMAD_TOKEN=
# Suchausdruck in Zammads Syntax. Was darauf passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE=owner_id:me AND (state.name:new OR state.name:open)
# Telegram (nur daheim). Bot bei @BotFather anlegen, Token hier eintragen.
# Welche Chats mitreden duerfen, wird in der Anwendung gekoppelt.
TELEGRAM_TOKEN=
# Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
# Seiten, mindestens 32 Zeichen: openssl rand -base64 32
SPIEGEL_GEHEIMNIS=
# Nur auf der sendenden Seite (firma): die Adresse der anderen Instanz.
SPIEGEL_ZIEL=
# --- Sonstiges ------------------------------------------------------------- # --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen. # Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin TZ=Europe/Berlin
+1
View File
@@ -8,3 +8,4 @@ test-results
playwright-report playwright-report
coverage coverage
devlog/zeiten.csv devlog/zeiten.csv
public/sw.js
+34
View File
@@ -6,6 +6,40 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [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 ## [0.4.0] – 2026-09-11
### Hinzugefügt ### Hinzugefügt
+38 -3
View File
@@ -33,6 +33,8 @@ wird.
Bereich, wie voll der heutige Tag schon ist Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten - **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück 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 - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -43,13 +45,13 @@ wird.
### Geplant ### Geplant
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie ## Bildergalerie
| | Hell | Dunkel | | | Hell | Dunkel |
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | 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) | | 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) | | Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
@@ -60,6 +62,7 @@ MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-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) | | 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) | | 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) | | 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 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 — Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`). 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 ### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung | | Variable | Standard | Bedeutung |
+16 -5
View File
@@ -9,7 +9,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | 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 | | [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant | | [M8](#m8--mcp) | MCP | geplant |
@@ -94,10 +94,21 @@ ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
## M5 – Benachrichtigungen ## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push` **Erledigt am 11.09.2026 mit Version 0.5.0.**
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron **Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
- [ ] Je Benutzer einstellbar, mit Ruhezeiten 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 ## M6 – Integrationen
+96 -2
View File
@@ -6,12 +6,106 @@ die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein ## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden | | Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- | | ----------- | ------ | ------- | --------- |
| M1 | claude | 161 | 2,68 | | M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 | | M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 | | M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 | | 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
--- ---
+1
View File
@@ -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;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;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-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
1 datum start ende minuten meilenstein art notiz
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
+11
View File
@@ -57,5 +57,16 @@ services:
WEBAUTHN_RP_NAME: Planer WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200} WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true' 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: ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000' - '${SCREENSHOT_APP_PORT:-3200}:3000'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

+3 -2
View File
@@ -20,10 +20,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
verbinden, Belegung verstehen verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den 7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
einrichten, Ruhezeiten setzen
## Verwaltung ## 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 ## Was noch kommt
@@ -31,7 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein | | Thema | Meilenstein |
| ------------------------------- | ----------- | | ------------------------------- | ----------- |
| Benachrichtigungen | M5 |
| Integrationen | M6 | | Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 | | Schnellerfassung vom Handy, API | M7 |
| MCP | M8 | | MCP | M8 |
+108
View File
@@ -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).
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 { 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. * 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. * 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 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.describe('Aufgaben', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
await db.$disconnect() 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 }) => { test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben` const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page) await anmelden(page)
+91
View File
@@ -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()
})
})
+35
View File
@@ -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 })
}
+5 -12
View File
@@ -1,7 +1,8 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden as anmeldenMit } from './hilfe'
/** /**
* Prüft, dass die Rollentrennung serverseitig hält. * Prüft, dass die Rollentrennung serverseitig hält.
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
await db.$disconnect() 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 }) => { 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) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
}) })
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page, page,
}) => { }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/verwaltung/protokoll') await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/) await expect(page).toHaveURL(/\/keine-berechtigung/)
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
}) })
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/bereiche') await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' 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 { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/** /**
* Wochenansicht und Zeitblöcke. * Wochenansicht und Zeitblöcke.
@@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* Kalender nicht erreichbar ist. * Kalender nicht erreichbar ist.
*/ */
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-W-${Date.now()}` 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.describe('Woche', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -34,14 +32,6 @@ test.describe('Woche', () => {
await db.$disconnect() 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 }) => { test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
await db.kalenderkonto.deleteMany({}) await db.kalenderkonto.deleteMany({})
await anmelden(page) await anmelden(page)
+2
View File
@@ -16,6 +16,8 @@ export default tseslint.config(
// Vom Generator erzeugt, nicht von Hand gepflegt. // Vom Generator erzeugt, nicht von Hand gepflegt.
'src/generated/**', 'src/generated/**',
'next-env.d.ts', 'next-env.d.ts',
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
'public/sw.js',
], ],
}, },
...naechstesWebVitals, ...naechstesWebVitals,
+150 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
@@ -38,6 +38,7 @@
"server-only": "0.0.1", "server-only": "0.0.1",
"sonner": "2.0.8", "sonner": "2.0.8",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1" "zod": "4.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -49,6 +50,7 @@
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2", "dotenv": "17.4.2",
@@ -4545,6 +4547,16 @@
"@types/react": "^19.3.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0", "version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", "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" "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": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -5678,6 +5699,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/asn1js": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5832,6 +5865,12 @@
"require-from-string": "^2.0.2" "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": { "node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "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": "^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": { "node_modules/c12": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -6413,7 +6458,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -6597,6 +6641,15 @@
"node": ">= 0.4" "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": { "node_modules/effect": {
"version": "3.20.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "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": "^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": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -8139,6 +8214,12 @@
"node": ">=0.8.19" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8804,6 +8885,27 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9280,6 +9382,12 @@
"node": ">=8.6" "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": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9300,7 +9408,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -9310,7 +9417,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
@@ -10731,6 +10837,26 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -10793,7 +10919,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/saxes": { "node_modules/saxes": {
@@ -12398,6 +12523,25 @@
"node": ">=18" "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": { "node_modules/webidl-conversions": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module", "type": "module",
@@ -68,6 +68,7 @@
"server-only": "0.0.1", "server-only": "0.0.1",
"sonner": "2.0.8", "sonner": "2.0.8",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1" "zod": "4.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -79,6 +80,7 @@
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2", "dotenv": "17.4.2",
+24
View File
@@ -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}`) console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
} }
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "benachrichtigungseinstellung" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
"perPush" BOOLEAN NOT NULL DEFAULT false,
"perMail" BOOLEAN NOT NULL DEFAULT false,
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
"ruheBis" INTEGER NOT NULL DEFAULT 420,
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "push_geraet" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"endpunkt" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meldungsnachweis" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
-- CreateIndex
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
-- CreateIndex
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
-- CreateIndex
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
-- AddForeignKey
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,62 @@
-- AlterTable
ALTER TABLE "aufgabe" ADD COLUMN "spiegeln" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "benachrichtigungseinstellung" ADD COLUMN "perTelegram" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "integrationszustand" (
"id" TEXT NOT NULL,
"quelle" "Quelle" NOT NULL,
"merker" TEXT,
"letzterLauf" TIMESTAMP(3),
"letzterErfolg" TIMESTAMP(3),
"letzterFehler" TEXT,
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integrationszustand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spiegelbelegung" (
"id" TEXT NOT NULL,
"quelleRef" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "spiegelbelegung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "telegram_chat" (
"id" TEXT NOT NULL,
"chatId" BIGINT NOT NULL,
"bezeichnung" TEXT,
"benutzerId" TEXT,
"meldungen" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
CONSTRAINT "telegram_chat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "integrationszustand_quelle_key" ON "integrationszustand"("quelle");
-- CreateIndex
CREATE UNIQUE INDEX "spiegelbelegung_quelleRef_key" ON "spiegelbelegung"("quelleRef");
-- CreateIndex
CREATE INDEX "spiegelbelegung_beginn_idx" ON "spiegelbelegung"("beginn");
-- CreateIndex
CREATE UNIQUE INDEX "telegram_chat_chatId_key" ON "telegram_chat"("chatId");
-- CreateIndex
CREATE INDEX "telegram_chat_benutzerId_idx" ON "telegram_chat"("benutzerId");
-- AddForeignKey
ALTER TABLE "telegram_chat" ADD CONSTRAINT "telegram_chat_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "telegram_kopplung" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"gueltigBis" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "telegram_kopplung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "telegram_kopplung_code_key" ON "telegram_kopplung"("code");
-- CreateIndex
CREATE INDEX "telegram_kopplung_gueltigBis_idx" ON "telegram_kopplung"("gueltigBis");
+160
View File
@@ -35,6 +35,10 @@ model Benutzer {
passkeys Passkey[] passkeys Passkey[]
audit AuditEintrag[] audit AuditEintrag[]
kalenderkonten Kalenderkonto[] kalenderkonten Kalenderkonto[]
benachrichtigung Benachrichtigungseinstellung?
pushGeraete PushGeraet[]
meldungen Meldungsnachweis[]
telegramChats TelegramChat[]
@@index([aktiv]) @@index([aktiv])
@@map("benutzer") @@map("benutzer")
@@ -219,6 +223,10 @@ model Aufgabe {
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer. /// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
quelleRef String? quelleRef String?
/// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst
/// die Instanz mit Titel – alles andere bleibt, wo es ist.
spiegeln Boolean @default(false)
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
@@ -300,6 +308,9 @@ model Kalenderkonto {
letzterAbgleich DateTime? letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens. /// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String? 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) aktiv Boolean @default(true)
@@ -378,3 +389,152 @@ model Zeitblock {
@@index([aufgabeId]) @@index([aufgabeId])
@@map("zeitblock") @@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)
perTelegram 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")
}
/// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt.
///
/// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede
/// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen
/// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.
model Integrationszustand {
id String @id @default(cuid())
quelle Quelle @unique
/// Woher es weitergeht. Bedeutung haengt an der Quelle.
merker String?
letzterLauf DateTime?
letzterErfolg DateTime?
letzterFehler String?
/// Fehlschlaege in Folge; bei Erfolg wieder null.
fehlversuche Int @default(0)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
@@map("integrationszustand")
}
/// Dienstliche Belegung, die aus der anderen Instanz kommt.
///
/// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht
/// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht.
model Spiegelbelegung {
id String @id @default(cuid())
/// Kennung des Zeitblocks in der sendenden Instanz.
quelleRef String @unique
beginn DateTime
ende DateTime
empfangenAm DateTime @default(now())
@@index([beginn])
@@map("spiegelbelegung")
}
/// Ein Telegram-Chat, der mit dem Planer reden darf.
///
/// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten
/// oder abgreifen, die Chatkennung ist der zweite Riegel.
model TelegramChat {
id String @id @default(cuid())
/// Chatkennung bei Telegram; kann negativ sein (Gruppen).
chatId BigInt @unique
bezeichnung String?
benutzerId String?
/// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben?
meldungen Boolean @default(true)
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
@@index([benutzerId])
@@map("telegram_chat")
}
/// Einmalcode, mit dem sich ein Telegram-Chat anmeldet.
///
/// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie
/// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten.
/// Genau das soll er nicht.
model TelegramKopplung {
id String @id @default(cuid())
code String @unique
benutzerId String
gueltigBis DateTime
erstelltAm DateTime @default(now())
@@index([gueltigBis])
@@map("telegram_kopplung")
}
+53
View File
@@ -0,0 +1,53 @@
/*
* Service Worker des Planers.
*
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
* veralteter Planer wäre schlimmer als gar keiner.
*/
self.addEventListener('install', () => {
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
self.skipWaiting()
})
self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(self.clients.claim())
})
self.addEventListener('push', (ereignis) => {
let daten = { titel: 'Planer', text: '', pfad: '/' }
try {
if (ereignis.data) daten = { ...daten, ...ereignis.data.json() }
} catch {
// Kaputte Nutzlast: lieber eine karge Meldung als gar keine.
}
ereignis.waitUntil(
self.registration.showNotification(daten.titel, {
body: daten.text,
lang: 'de',
// Gleiche Anlässe überschreiben einander, statt sich zu stapeln.
tag: daten.anlass || 'planer',
data: { pfad: daten.pfad },
}),
)
})
self.addEventListener('notificationclick', (ereignis) => {
ereignis.notification.close()
const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/'
ereignis.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => {
// Ist der Planer schon offen, dorthin wechseln statt ein zweites
// Fenster aufzumachen.
for (const f of fenster) {
if (f.url.includes(self.location.origin)) {
return f.focus().then(() => f.navigate(pfad))
}
}
return self.clients.openWindow(pfad)
}),
)
})
+18 -4
View File
@@ -24,6 +24,13 @@ type Aufnahme = {
pfad: string pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */ /** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void> 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[] = [ const AUFNAHMEN: Aufnahme[] = [
@@ -37,6 +44,11 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'bereiche', pfad: '/bereiche' }, { name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' }, { name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'einstellungen', pfad: '/einstellungen' }, { name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
pfad: '/einstellungen',
ausschnitt: '[data-bild="benachrichtigungen"]',
},
{ name: 'protokoll', pfad: '/verwaltung/protokoll' }, { 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. // Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300) await seite.waitForTimeout(300)
await seite.screenshot({ const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`, if (aufnahme.ausschnitt) {
fullPage: true, await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
}) } else {
await seite.screenshot({ path: ziel, fullPage: true })
}
} }
await kontext.close() await kontext.close()
+67
View File
@@ -56,6 +56,8 @@ async function aufraeumen(): Promise<void> {
await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { 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> { async function main(): Promise<void> {
@@ -259,6 +261,61 @@ async function main(): Promise<void> {
}) })
pruefe('Audit-Eintrag anlegen', audit.id.length > 0) 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 } }) await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.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', 'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, (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( pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
+7
View File
@@ -14,6 +14,7 @@
*/ */
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import webpush from 'web-push'
const WURZEL = fileURLToPath(new URL('..', import.meta.url)) const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots' 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 APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455' 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 = { const UMGEBUNG = {
...process.env, ...process.env,
SCREENSHOT_APP_PORT: APP_PORT, SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT, SCREENSHOT_DB_PORT: DB_PORT,
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
} }
function meldung(text: string): void { function meldung(text: string): void {
@@ -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<ArrayBuffer>`
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
*/
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
const roh = atob(base64)
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
return bytes
}
export function Benachrichtigungen({
einstellung,
geraete,
pushMoeglich,
mailMoeglich,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [pushZustand, setPushZustand] = useState<
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
>('unbekannt')
const [pushLaeuft, setPushLaeuft] = useState(false)
useEffect(() => {
let abgebrochen = false
async function zustandPruefen() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
return
}
if (Notification.permission === 'denied') {
if (!abgebrochen) setPushZustand('verweigert')
return
}
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
}
void zustandPruefen()
return () => {
abgebrochen = true
}
}, [])
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
async function pushAnmelden() {
setPushLaeuft(true)
try {
const erlaubnis = await Notification.requestPermission()
if (erlaubnis !== 'granted') {
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
return
}
const antwort = await fetch('/api/push')
const daten = (await antwort.json()) as {
aktiv: boolean
oeffentlicherSchluessel: string | null
}
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
toast.error('Push ist auf diesem Server nicht eingerichtet.')
return
}
const registrierung = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const abo = await registrierung.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
})
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
const gespeichert = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: roh.endpoint,
p256dh: roh.keys?.p256dh,
auth: roh.keys?.auth,
bezeichnung: geraetename(),
}),
})
if (!gespeichert.ok) {
const fehler = (await gespeichert.json()) as { fehler?: string }
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
return
}
setPushZustand('an')
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
router.refresh()
} catch {
toast.error('Die Anmeldung hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
async function pushAbmelden() {
setPushLaeuft(true)
try {
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (abo) {
await fetch('/api/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: abo.endpoint }),
})
await abo.unsubscribe()
}
setPushZustand('aus')
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
router.refresh()
} catch {
toast.error('Das Abmelden hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
{/* Dieses Gerät */}
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
{pushZustand === 'an' ? (
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
) : (
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs">
{/*
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>
{!pushMoeglich ? (
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
) : pushZustand === 'an' ? (
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Abmelden
</Button>
) : (
pushZustand !== 'nicht-unterstuetzt' &&
pushZustand !== 'verweigert' && (
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Anmelden
</Button>
)
)}
</div>
{/* Einstellungen */}
<form
className="flex flex-col gap-5"
onSubmit={(ereignis) => {
ereignis.preventDefault()
const daten = new FormData(ereignis.currentTarget)
mitRueckmeldung(() => einstellungSpeichern(daten))
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Worüber</legend>
{ANLAESSE.map((anlass) => (
<label key={anlass} className="flex items-start gap-3">
<input
type="checkbox"
name="anlaesse"
value={anlass}
defaultChecked={einstellung.anlaesse.includes(anlass)}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{ANLASS_BESCHREIBUNG[anlass]}
</span>
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Wie</legend>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perPush"
defaultChecked={einstellung.perPush}
className="accent-betont size-4"
/>
<span className="text-sm">
Push an angemeldete Geräte
{!pushMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perMail"
defaultChecked={einstellung.perMail}
className="accent-betont size-4"
/>
<span className="text-sm">
E-Mail
{!mailMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
<Input
id="ueberblickUm"
name="ueberblickUm"
type="time"
defaultValue={einstellung.ueberblickUm}
aria-invalid={Boolean(felder.ueberblickUm)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="zeitblockVorlaufMinuten">
Vorlauf vor einem Zeitblock (Minuten)
</Label>
<Input
id="zeitblockVorlaufMinuten"
name="zeitblockVorlaufMinuten"
type="number"
min={0}
max={120}
step={5}
defaultValue={einstellung.zeitblockVorlaufMinuten}
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
/>
{felder.zeitblockVorlaufMinuten && (
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheVon">Ruhe von</Label>
<Input
id="ruheVon"
name="ruheVon"
type="time"
defaultValue={einstellung.ruheVon}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheBis">bis</Label>
<Input
id="ruheBis"
name="ruheBis"
type="time"
defaultValue={einstellung.ruheBis}
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="ruheAmWochenende"
defaultChecked={einstellung.ruheAmWochenende}
className="accent-betont size-4"
/>
<span className="text-sm">Am Wochenende gar nichts melden</span>
</label>
<p className="text-gedaempft-vordergrund text-xs">
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
nie, wenn du früh aufstehst.
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Speichern
</Button>
<Button
type="button"
art="umriss"
disabled={laeuft}
onClick={() => mitRueckmeldung(() => probeSenden())}
>
<Send aria-hidden />
Probe schicken
</Button>
</div>
</form>
{/* Angemeldete Geräte */}
{geraete.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
<ul className="divide-rand divide-y">
{geraete.map((geraet) => (
<li key={geraet.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angemeldet {geraet.erstelltAm}
{geraet.zuletztBenutzt
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
: ' · noch nichts empfangen'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', geraet.id)
mitRueckmeldung(() => geraetEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Abmelden</span>
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
function geraetename(): string {
const kennung = navigator.userAgent
if (/Android/i.test(kennung)) return 'Android-Gerät'
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
if (/Macintosh/i.test(kennung)) return 'Mac'
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
return 'Unbekanntes Gerät'
}
+66
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung' import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import { import {
Card, Card,
@@ -8,6 +9,9 @@ import {
CardTitel, CardTitel,
} from '@/components/ui/card' } from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen' 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 { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung' import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() { export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen') 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({ const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id }, where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' }, orderBy: { erstelltAm: 'asc' },
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
/> />
</CardInhalt> </CardInhalt>
</Card> </Card>
<Card data-bild="benachrichtigungen">
<CardKopf>
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
<CardBeschreibung>
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
dann wertlos.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
ruheAmWochenende: einstellung.ruheAmWochenende,
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
}}
geraete={pushGeraete.map((g) => ({
id: g.id,
bezeichnung: g.bezeichnung,
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
}))}
/>
</CardInhalt>
</Card>
</div> </div>
) )
} }
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
/**
* An- und Abmelden eines Browsers für Push-Nachrichten.
*
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
* Einrichtung nach außen zu zeigen.
*/
const anmeldung = z.object({
endpunkt: z.url(),
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
bezeichnung: z.string().trim().max(60).nullable().default(null),
})
export async function GET() {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
const einst = umgebung()
return NextResponse.json(
{
aktiv: einst.pushAktiv,
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
}
export async function POST(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
if (!umgebung().pushAktiv) {
return NextResponse.json(
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
{ status: 400 },
)
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = anmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json(
{ fehler: 'Die Anmeldung war unvollständig.' },
{ status: 400 },
)
}
const geraet = await geraetAnmelden(
benutzer.id,
{
endpunkt: gelesen.data.endpunkt,
p256dh: gelesen.data.p256dh,
auth: gelesen.data.auth,
},
gelesen.data.bezeichnung,
)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.angemeldet',
objektTyp: 'PushGeraet',
objektId: geraet.id,
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
details: { bezeichnung: gelesen.data.bezeichnung },
})
return NextResponse.json({ ok: true })
}
const abmeldung = z.object({ endpunkt: z.url() })
export async function DELETE(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = abmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
await geraetAbmelden(gelesen.data.endpunkt)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.abgemeldet',
objektTyp: 'PushGeraet',
objektId: null,
})
return NextResponse.json({ ok: true })
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { auditSchreiben } from '@/server/audit'
import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel'
import { jetzt } from '@/server/zeitgeber'
export const dynamic = 'force-dynamic'
/**
* Der Eingang des Spiegels — nur in der Instanz `daheim`.
*
* Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine
* Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet.
*
* Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr
* verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden,
* und genau so soll es sein.
*/
export async function POST(anfrage: Request) {
// Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese
// Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht.
const koerper = await anfrage.text()
const signatur = anfrage.headers.get('X-Planer-Signatur')
const geprueft = sendungPruefen(koerper, signatur, jetzt())
if (!geprueft.ok) {
return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 })
}
const bericht = await sendungAufnehmen(geprueft.sendung, {
id: null,
email: 'spiegel@intern',
})
await auditSchreiben({
akteur: { id: null, email: 'spiegel@intern' },
aktion: 'spiegel.empfangen',
objektTyp: 'Spiegelbelegung',
objektId: null,
// Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst.
details: bericht,
})
return NextResponse.json({ ok: true, ...bericht })
}
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from 'vitest'
import {
VOREINSTELLUNG,
einstellungSchema,
inRuhezeit,
meldungsSchluessel,
ruhezeitText,
sendenEntscheiden,
ueberblickFaellig,
zeitblockErinnerungFaellig,
type Einstellung,
type Meldung,
} from './benachrichtigung'
/** Dienstag, 17. März 2026. Berlin liegt dann auf UTC+1. */
function berlin(stunde: number, minute = 0): Date {
return new Date(Date.UTC(2026, 2, 17, stunde - 1, minute))
}
/** Samstag, 21. März 2026. */
function samstag(stunde: number): Date {
return new Date(Date.UTC(2026, 2, 21, stunde - 1, 0))
}
const einstellung: Einstellung = {
...VOREINSTELLUNG,
anlaesse: ['tagesueberblick', 'faelligkeit', 'zeitblock'],
perPush: true,
}
function meldung(teil: Partial<Meldung> = {}): Meldung {
return {
anlass: 'faelligkeit',
schluessel: 'faelligkeit:a1:2026-03-17',
titel: 'Fällig heute',
text: 'Angebot schreiben',
pfad: '/aufgaben',
...teil,
}
}
describe('VOREINSTELLUNG', () => {
it('meldet ab Werk über keinen Weg', () => {
// Wer beim ersten Start überschüttet wird, schaltet alles ab.
expect(VOREINSTELLUNG.perPush).toBe(false)
expect(VOREINSTELLUNG.perMail).toBe(false)
})
it('hat eine Ruhezeit über Nacht', () => {
expect(VOREINSTELLUNG.ruheVon).toBe(20 * 60)
expect(VOREINSTELLUNG.ruheBis).toBe(7 * 60)
})
})
describe('inRuhezeit', () => {
it('erkennt die Nacht, obwohl sie über Mitternacht läuft', () => {
expect(inRuhezeit(einstellung, berlin(21))).toBe(true)
expect(inRuhezeit(einstellung, berlin(2))).toBe(true)
expect(inRuhezeit(einstellung, berlin(6, 59))).toBe(true)
})
it('lässt den Tag frei', () => {
expect(inRuhezeit(einstellung, berlin(7))).toBe(false)
expect(inRuhezeit(einstellung, berlin(12))).toBe(false)
expect(inRuhezeit(einstellung, berlin(19, 59))).toBe(false)
})
it('beginnt pünktlich mit der Ruhe', () => {
expect(inRuhezeit(einstellung, berlin(20))).toBe(true)
})
it('kennt auch eine Ruhezeit innerhalb eines Tages', () => {
const mittags: Einstellung = { ...einstellung, ruheVon: 12 * 60, ruheBis: 13 * 60 }
expect(inRuhezeit(mittags, berlin(12, 30))).toBe(true)
expect(inRuhezeit(mittags, berlin(11))).toBe(false)
expect(inRuhezeit(mittags, berlin(14))).toBe(false)
})
it('deutet gleiche Zeiten als "keine Ruhezeit"', () => {
const ohne: Einstellung = { ...einstellung, ruheVon: 0, ruheBis: 0 }
expect(inRuhezeit(ohne, berlin(3))).toBe(false)
})
it('schweigt das Wochenende, wenn gewünscht', () => {
const still: Einstellung = { ...einstellung, ruheAmWochenende: true }
expect(inRuhezeit(still, samstag(12))).toBe(true)
expect(inRuhezeit(still, berlin(12))).toBe(false)
})
it('lässt das Wochenende sonst in Ruhe', () => {
expect(inRuhezeit(einstellung, samstag(12))).toBe(false)
})
})
describe('sendenEntscheiden', () => {
const leer = new Set<string>()
it('lässt eine gewöhnliche Meldung durch', () => {
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(10))).toEqual({
senden: true,
})
})
it('schweigt bei abgeschaltetem Anlass', () => {
const ohne: Einstellung = { ...einstellung, anlaesse: ['tagesueberblick'] }
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(10))).toEqual({
senden: false,
grund: 'abgeschaltet',
})
})
it('schweigt, wenn es keinen Weg hinaus gibt', () => {
const stumm: Einstellung = { ...einstellung, perPush: false, perMail: false }
expect(sendenEntscheiden(stumm, meldung(), leer, berlin(10))).toEqual({
senden: false,
grund: 'kein-weg',
})
})
it('schweigt in der Ruhezeit', () => {
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(22))).toEqual({
senden: false,
grund: 'ruhezeit',
})
})
it('meldet dieselbe Sache nicht zweimal', () => {
const gemeldet = new Set(['faelligkeit:a1:2026-03-17'])
expect(sendenEntscheiden(einstellung, meldung(), gemeldet, berlin(10))).toEqual({
senden: false,
grund: 'schon-gemeldet',
})
})
it('lässt den Tagesüberblick auch in der Ruhezeit hinaus', () => {
// Sonst käme er nie, wenn jemand früh aufsteht und die Ruhe bis 07:00 läuft.
const ueberblick = meldung({
anlass: 'tagesueberblick',
schluessel: 'tagesueberblick:tag:2026-03-17',
})
expect(sendenEntscheiden(einstellung, ueberblick, leer, berlin(6, 30))).toEqual({
senden: true,
})
})
it('prüft die Abschaltung vor der Ruhezeit', () => {
// Ein abgeschalteter Anlass ist abgeschaltet, nicht "gerade still".
const ohne: Einstellung = { ...einstellung, anlaesse: [] }
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(22)).senden).toBe(false)
expect(
sendenEntscheiden(ohne, meldung(), leer, berlin(22)) as { grund: string },
).toMatchObject({ grund: 'abgeschaltet' })
})
})
describe('meldungsSchluessel', () => {
it('trennt Anlass, Sache und Tag', () => {
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).toBe(
'faelligkeit:a1:2026-03-17',
)
})
it('erlaubt dieselbe Sache an einem anderen Tag erneut', () => {
// Eine Aufgabe, die heute fällig war, ist es morgen immer noch.
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).not.toBe(
meldungsSchluessel('faelligkeit', 'a1', '2026-03-18'),
)
})
})
describe('ueberblickFaellig', () => {
it('trifft das Zeitfenster', () => {
expect(ueberblickFaellig(einstellung, berlin(7))).toBe(true)
expect(ueberblickFaellig(einstellung, berlin(7, 14))).toBe(true)
})
it('verpasst das Fenster davor und danach', () => {
expect(ueberblickFaellig(einstellung, berlin(6, 59))).toBe(false)
expect(ueberblickFaellig(einstellung, berlin(7, 15))).toBe(false)
})
})
describe('zeitblockErinnerungFaellig', () => {
const beginn = berlin(10)
it('erinnert im Vorlauffenster', () => {
// Vorlauf 10 Minuten, Toleranz 15: von 25 bis 10 Minuten vorher.
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 55))).toBe(true)
})
it('erinnert nicht zu früh', () => {
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 30))).toBe(false)
})
it('erinnert nicht mehr, wenn der Block schon läuft', () => {
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(10, 5))).toBe(false)
})
})
describe('einstellungSchema', () => {
const gueltig = {
anlaesse: ['tagesueberblick'],
perPush: 'true',
perMail: '',
ueberblickUm: '07:00',
ruheVon: '20:00',
ruheBis: '07:00',
ruheAmWochenende: '',
zeitblockVorlaufMinuten: '10',
}
it('liest Uhrzeiten in Minuten um', () => {
const ergebnis = einstellungSchema.parse(gueltig)
expect(ergebnis.ueberblickUm).toBe(420)
expect(ergebnis.ruheVon).toBe(1200)
})
it('weist unsinnige Uhrzeiten ab', () => {
expect(einstellungSchema.safeParse({ ...gueltig, ruheVon: '25:00' }).success).toBe(
false,
)
})
it('begrenzt den Vorlauf', () => {
expect(
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '121' }).success,
).toBe(false)
expect(
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '120' }).success,
).toBe(true)
})
it('weist unbekannte Anlässe ab', () => {
expect(
einstellungSchema.safeParse({ ...gueltig, anlaesse: ['geburtstag'] }).success,
).toBe(false)
})
})
describe('ruhezeitText', () => {
it('beschreibt die Nachtruhe', () => {
expect(ruhezeitText(einstellung)).toBe('Still von 20:00 bis 07:00.')
})
it('nennt das Wochenende mit', () => {
expect(ruhezeitText({ ...einstellung, ruheAmWochenende: true })).toBe(
'Still von 20:00 bis 07:00 und am ganzen Wochenende.',
)
})
it('sagt es auch, wenn es keine Ruhezeit gibt', () => {
expect(ruhezeitText({ ...einstellung, ruheVon: 0, ruheBis: 0 })).toBe(
'Keine Ruhezeit.',
)
})
})
+239
View File
@@ -0,0 +1,239 @@
import { z } from 'zod'
import {
alsUhrzeit,
ausUhrzeit,
minutenSeitMitternacht,
wochentagIndex,
} from '@/lib/zeit'
/**
* Regeln für Benachrichtigungen.
*
* Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die
* zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die
* Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird
* nie zweimal gemeldet.
*
* Reine Logik ohne Datenbank und ohne Systemzeit.
*/
export const ANLAESSE = [
'tagesueberblick',
'faelligkeit',
'zeitblock',
'abgleichfehler',
] as const
export type Anlass = (typeof ANLAESSE)[number]
export const ANLASS_NAME: Record<Anlass, string> = {
tagesueberblick: 'Tagesüberblick',
faelligkeit: 'Fällige Aufgaben',
zeitblock: 'Gleich beginnt ein Zeitblock',
abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen',
}
export const ANLASS_BESCHREIBUNG: Record<Anlass, string> = {
tagesueberblick:
'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.',
faelligkeit:
'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.',
zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.',
abgleichfehler:
'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.',
}
export type Einstellung = {
/** Welche Anlässe überhaupt melden. */
anlaesse: readonly Anlass[]
perPush: boolean
perMail: boolean
perTelegram: 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,
perTelegram: 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
/**
* Die Aufgabe, um die es geht — sofern es genau eine ist.
*
* Nur dann lässt sich unter die Meldung ein Knopf hängen, der sie abhakt.
* Ein Tagesüberblick hat keine.
*/
objektId?: string
}
/**
* Soll diese Meldung jetzt hinaus?
*
* Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit,
* oder genau diese Meldung ging schon einmal hinaus.
*/
export type Entscheidung =
| { senden: true }
| { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' }
export function sendenEntscheiden(
einstellung: Einstellung,
meldung: Meldung,
bereitsGemeldet: ReadonlySet<string>,
zeitpunkt: Date,
): Entscheidung {
if (!einstellung.anlaesse.includes(meldung.anlass)) {
return { senden: false, grund: 'abgeschaltet' }
}
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
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),
perTelegram: z.coerce.boolean().default(false),
ueberblickUm: uhrzeit,
ruheVon: uhrzeit,
ruheBis: uhrzeit,
ruheAmWochenende: z.coerce.boolean().default(false),
zeitblockVorlaufMinuten: z.coerce
.number()
.int()
.min(0, 'Nicht weniger als null Minuten.')
.max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'),
})
export type EinstellungEingabe = z.infer<typeof einstellungSchema>
/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */
export function ruhezeitText(einstellung: Einstellung): string {
if (einstellung.ruheVon === einstellung.ruheBis) {
return einstellung.ruheAmWochenende
? 'Keine Ruhezeit, aber am Wochenende still.'
: 'Keine Ruhezeit.'
}
const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}`
return einstellung.ruheAmWochenende
? `Still von ${zeiten} und am ganzen Wochenende.`
: `Still von ${zeiten}.`
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, test } from 'vitest'
import {
alsTitel,
sendungFrisch,
spiegelSendung,
TITEL_MAX,
uebernahmeEntscheiden,
type Bestand,
type Fund,
} from '@/lib/integration'
function fund(teile: Partial<Fund> = {}): Fund {
return { ref: 'T-1', titel: 'Drucker klemmt', ...teile }
}
function bestand(teile: Partial<Bestand> = {}): Bestand {
return {
titel: 'Drucker klemmt',
notiz: null,
quelleUrl: null,
faelligAm: null,
prioritaet: 3,
aufwandMinuten: null,
erledigt: false,
...teile,
}
}
describe('uebernahmeEntscheiden', () => {
test('legt an, was es hier noch nicht gibt', () => {
expect(uebernahmeEntscheiden(fund(), null)).toEqual({ tun: 'anlegen' })
})
test('legt nichts an, was drüben schon erledigt ist', () => {
expect(uebernahmeEntscheiden(fund({ erledigt: true }), null)).toEqual({
tun: 'nichts',
grund: 'schon-erledigt',
})
})
test('erkennt den unveränderten Fall', () => {
expect(uebernahmeEntscheiden(fund(), bestand())).toEqual({
tun: 'nichts',
grund: 'unveraendert',
})
})
test('übernimmt einen geänderten Titel', () => {
const entscheidung = uebernahmeEntscheiden(
fund({ titel: 'Drucker klemmt weiterhin' }),
bestand(),
)
expect(entscheidung).toEqual({
tun: 'aktualisieren',
felder: { titel: 'Drucker klemmt weiterhin' },
})
})
test('übernimmt eine neue Fälligkeit', () => {
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
expect(uebernahmeEntscheiden(fund({ faelligAm }), bestand())).toEqual({
tun: 'aktualisieren',
felder: { faelligAm },
})
})
test('vergleicht Datumswerte nach Zeitpunkt, nicht nach Objekt', () => {
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
const entscheidung = uebernahmeEntscheiden(
fund({ faelligAm: new Date(faelligAm.getTime()) }),
bestand({ faelligAm }),
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
test('rührt Priorität und Aufwand nicht an', () => {
// Beides wird hier geschätzt, nicht drüben. Ein Abgleich, der die
// Schätzung wegräumt, macht die Wochenplanung kaputt.
const entscheidung = uebernahmeEntscheiden(
fund({ prioritaet: 1, aufwandMinuten: 30 }),
bestand({ prioritaet: 4, aufwandMinuten: 90 }),
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
test('schließt, was drüben erledigt wurde', () => {
expect(uebernahmeEntscheiden(fund({ erledigt: true }), bestand())).toEqual({
tun: 'schliessen',
})
})
test('schließt nicht zweimal', () => {
expect(
uebernahmeEntscheiden(fund({ erledigt: true }), bestand({ erledigt: true })),
).toEqual({ tun: 'nichts', grund: 'schon-erledigt' })
})
test('holt eine hier erledigte Aufgabe zurück, wenn sie drüben wieder offen ist', () => {
const entscheidung = uebernahmeEntscheiden(fund(), bestand({ erledigt: true }))
// Das Ursprungssystem führt den Zustand; wer dort neu öffnet, meint es so.
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
})
describe('alsTitel', () => {
test('lässt kurze Texte in Ruhe', () => {
expect(alsTitel('Angebot schreiben')).toEqual({
titel: 'Angebot schreiben',
rest: null,
})
})
test('macht aus Zeilenumbrüchen Leerzeichen', () => {
expect(alsTitel('Angebot\n\nschreiben ')).toEqual({
titel: 'Angebot schreiben',
rest: null,
})
})
test('kürzt lange Texte und hebt den Rest auf', () => {
const lang = `${'wort '.repeat(60)}ende`
const { titel, rest } = alsTitel(lang)
expect(titel.length).toBeLessThanOrEqual(TITEL_MAX + 1)
expect(titel.endsWith('…')).toBe(true)
expect(rest).not.toBeNull()
expect(rest?.endsWith('ende')).toBe(true)
})
test('trennt nicht mitten im Wort, wenn eine Fuge in der Nähe ist', () => {
const lang = `${'a'.repeat(60)} ${'b'.repeat(80)}`
const { titel } = alsTitel(lang)
expect(titel).toBe(`${'a'.repeat(60)}…`)
})
test('trennt hart, wenn es keine Fuge gibt', () => {
const { titel } = alsTitel('x'.repeat(200))
expect(titel).toBe(`${'x'.repeat(TITEL_MAX)}…`)
})
})
describe('sendungFrisch', () => {
const jetzt = new Date('2026-03-17T08:20:00.000Z')
test('nimmt an, was gerade erst losgeschickt wurde', () => {
expect(sendungFrisch(new Date('2026-03-17T08:18:00.000Z'), jetzt)).toBe(true)
})
test('lehnt ein altes Paket ab', () => {
expect(sendungFrisch(new Date('2026-03-17T08:10:00.000Z'), jetzt)).toBe(false)
})
test('lehnt auch ein Paket aus der Zukunft ab', () => {
// Eine verstellte Uhr auf der Gegenseite ist genauso verdächtig.
expect(sendungFrisch(new Date('2026-03-17T08:40:00.000Z'), jetzt)).toBe(false)
})
})
describe('spiegelSendung', () => {
test('nimmt ein vollständiges Paket an', () => {
const gelesen = spiegelSendung.safeParse({
gesendetAm: '2026-03-17T08:20:00.000Z',
belegungen: [
{
ref: 'zb-1',
beginn: '2026-03-17T09:00:00.000Z',
ende: '2026-03-17T10:00:00.000Z',
},
],
aufgaben: [{ ref: 'a-1', titel: 'Angebot schreiben' }],
})
expect(gelesen.success).toBe(true)
expect(gelesen.success && gelesen.data.aufgaben[0]?.erledigt).toBe(false)
})
test('weist ein Paket ohne Zeitstempel ab', () => {
const gelesen = spiegelSendung.safeParse({ belegungen: [], aufgaben: [] })
expect(gelesen.success).toBe(false)
})
test('weist eine Belegung mit kaputtem Datum ab', () => {
const gelesen = spiegelSendung.safeParse({
gesendetAm: '2026-03-17T08:20:00.000Z',
belegungen: [{ ref: 'zb-1', beginn: 'morgen', ende: 'später' }],
aufgaben: [],
})
expect(gelesen.success).toBe(false)
})
})
+177
View File
@@ -0,0 +1,177 @@
import { z } from 'zod'
/**
* Gemeinsame Regeln für alle Integrationen.
*
* Der Kern jeder Integration ist derselbe: ein fremdes System liefert Zeug,
* daraus wird eine Aufgabe, und beim nächsten Lauf darf dieselbe Sache keine
* zweite Aufgabe ergeben. Was sich unterscheidet, ist allein das Abholen.
*
* Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit.
*/
/** Was eine Integration liefert, bevor daraus eine Aufgabe wird. */
export type Fund = {
/** Kennung beim Ursprungssystem. Bleibt über die Lebensdauer gleich. */
ref: string
titel: string
notiz?: string | null
url?: string | null
faelligAm?: Date | null
/** 1 = dringend, 5 = irgendwann. */
prioritaet?: number | null
aufwandMinuten?: number | null
/** Im Ursprungssystem abgeschlossen: die Aufgabe darf zu. */
erledigt?: boolean
}
/**
* Was beim Übernehmen eines Fundes passieren soll.
*
* Der heikle Teil ist nicht das Anlegen, sondern das zweite Mal: Eine Aufgabe,
* an der hier gearbeitet wurde, darf ein Abgleich nicht zurücksetzen.
*/
export type Uebernahme =
| { tun: 'anlegen' }
| { tun: 'aktualisieren'; felder: Partial<Bestand> }
| { tun: 'schliessen' }
| { tun: 'nichts'; grund: 'unveraendert' | 'von-hand-geaendert' | 'schon-erledigt' }
/** Der Stand, wie er in der Datenbank liegt. */
export type Bestand = {
titel: string
notiz: string | null
quelleUrl: string | null
faelligAm: Date | null
prioritaet: number
aufwandMinuten: number | null
erledigt: boolean
}
/**
* Felder, die das Ursprungssystem führt — und nur diese.
*
* Bereich, Projekt und Zeitblöcke gehören der Planung und werden nie aus
* einer Integration überschrieben. Wer ein Ticket einem Bereich zuordnet,
* will nicht, dass der nächste Lauf das wieder wegräumt.
*/
const GEFUEHRT = ['titel', 'notiz', 'quelleUrl', 'faelligAm'] as const
function gleich(a: unknown, b: unknown): boolean {
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
return a === b
}
export function uebernahmeEntscheiden(fund: Fund, bestand: Bestand | null): Uebernahme {
if (!bestand) {
// Ein Fund, der drüben schon erledigt ist, braucht hier nicht erst zu
// entstehen, nur um sofort wieder zuzugehen.
return fund.erledigt ? { tun: 'nichts', grund: 'schon-erledigt' } : { tun: 'anlegen' }
}
if (fund.erledigt) {
return bestand.erledigt
? { tun: 'nichts', grund: 'schon-erledigt' }
: { tun: 'schliessen' }
}
const neu: Partial<Bestand> = {}
const gewuenscht: Bestand = {
titel: fund.titel,
notiz: fund.notiz ?? null,
quelleUrl: fund.url ?? null,
faelligAm: fund.faelligAm ?? null,
prioritaet: bestand.prioritaet,
aufwandMinuten: bestand.aufwandMinuten,
erledigt: false,
}
for (const feld of GEFUEHRT) {
if (!gleich(bestand[feld], gewuenscht[feld])) {
// @ts-expect-error – der Index ist über GEFUEHRT eingegrenzt.
neu[feld] = gewuenscht[feld]
}
}
return Object.keys(neu).length === 0
? { tun: 'nichts', grund: 'unveraendert' }
: { tun: 'aktualisieren', felder: neu }
}
/**
* Ab wann ein Fehlschlag gemeldet wird — dieselbe Schwelle wie beim
* Kalenderabgleich. Ein Aussetzer ist keine Nachricht wert.
*/
export const INTEGRATION_FEHLER_AB_VERSUCH = 3
/**
* Kürzt einen Text auf eine Länge, die als Aufgabentitel taugt.
*
* Fremde Systeme kennen keine Grenze; ein Titel über mehrere Zeilen macht
* jede Liste unlesbar. Der Rest wandert in die Notiz, geht also nicht
* verloren.
*/
export const TITEL_MAX = 120
export function alsTitel(text: string): { titel: string; rest: string | null } {
const sauber = text.replace(/\s+/g, ' ').trim()
if (sauber.length <= TITEL_MAX) return { titel: sauber, rest: null }
// Lieber an einem Wort trennen als mitten hinein – aber nur, solange
// dabei nicht mehr als die Haelfte des Titels wegfaellt.
const schnitt = sauber.lastIndexOf(' ', TITEL_MAX)
const bei = schnitt >= TITEL_MAX / 2 ? schnitt : TITEL_MAX
return { titel: `${sauber.slice(0, bei).trimEnd()}…`, rest: sauber.slice(bei).trim() }
}
// --- Spiegel ---------------------------------------------------------------
/**
* Was von einer Instanz zur anderen geht.
*
* Belegungen tragen keinen Titel: Daheim soll sehen, *dass* die Zeit weg ist,
* nicht woran gearbeitet wird. Aufgaben gehen nur mit, wenn sie ausdrücklich
* dafür markiert sind.
*/
export const spiegelBelegung = z.object({
ref: z.string().min(1).max(64),
beginn: z.iso.datetime(),
ende: z.iso.datetime(),
})
export const spiegelAufgabe = z.object({
ref: z.string().min(1).max(64),
titel: z
.string()
.min(1)
.max(TITEL_MAX + 1),
faelligAm: z.iso.datetime().nullable().default(null),
erledigt: z.boolean().default(false),
})
export const spiegelSendung = z.object({
/** Wann der Sender das Paket geschnürt hat, für die Frische. */
gesendetAm: z.iso.datetime(),
belegungen: z.array(spiegelBelegung).max(500),
aufgaben: z.array(spiegelAufgabe).max(500),
})
export type SpiegelSendung = z.infer<typeof spiegelSendung>
/**
* Wie alt eine Sendung höchstens sein darf.
*
* Ohne Frist ließe sich ein abgefangenes Paket beliebig oft erneut
* einspielen. Fünf Minuten sind genug für eine träge Leitung und zu wenig
* für ein Archiv.
*/
export const SENDUNG_FRIST_MINUTEN = 5
export function sendungFrisch(
gesendetAm: Date,
jetzt: Date,
fristMinuten = SENDUNG_FRIST_MINUTEN,
): boolean {
const abstandMinuten = Math.abs(jetzt.getTime() - gesendetAm.getTime()) / 60_000
return abstandMinuten <= fristMinuten
}
+195
View File
@@ -0,0 +1,195 @@
'use server'
import { revalidatePath } from 'next/cache'
import { einstellungSchema } from '@/lib/benachrichtigung'
import { alsTagesschluessel } from '@/lib/zeit'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
export type Rueckmeldung =
| { ok: true; meldung: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
async function akteurOderFehler() {
try {
return { akteur: await pruefeRolle('BENUTZER') }
} catch (fehler) {
if (fehler instanceof NichtErlaubt) {
return { antwort: { ok: false as const, fehler: fehler.message } }
}
throw fehler
}
}
/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */
export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const gelesen = einstellungSchema.safeParse({
anlaesse: daten.getAll('anlaesse'),
perPush: daten.get('perPush') !== null,
perMail: daten.get('perMail') !== null,
ueberblickUm: daten.get('ueberblickUm'),
ruheVon: daten.get('ruheVon'),
ruheBis: daten.get('ruheBis'),
ruheAmWochenende: daten.get('ruheAmWochenende') !== null,
zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'),
})
if (!gelesen.success) {
const felder: Record<string, string> = {}
for (const problem of gelesen.error.issues) {
const feld = problem.path.join('.')
felder[feld] ??= problem.message
}
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
}
const werte = {
anlaesse: gelesen.data.anlaesse.join(','),
perPush: gelesen.data.perPush,
perMail: gelesen.data.perMail,
ueberblickUm: gelesen.data.ueberblickUm,
ruheVon: gelesen.data.ruheVon,
ruheBis: gelesen.data.ruheBis,
ruheAmWochenende: gelesen.data.ruheAmWochenende,
zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten,
}
await db.benachrichtigungseinstellung.upsert({
where: { benutzerId: akteur.id },
update: werte,
create: { benutzerId: akteur.id, ...werte },
})
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.geaendert',
objektTyp: 'Benachrichtigungseinstellung',
objektId: akteur.id,
details: {
anlaesse: werte.anlaesse,
perPush: werte.perPush,
perMail: werte.perMail,
},
})
revalidatePath('/einstellungen')
return { ok: true, meldung: 'Einstellungen gespeichert.' }
}
/**
* Schickt eine Probemeldung über alle eingeschalteten Wege.
*
* Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf
* drückt, will jetzt wissen, ob es ankommt.
*/
export async function probeSenden(): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const zeile = await db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: akteur.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
})
const einstellung = alsEinstellung(zeile)
if (!einstellung.perPush && !einstellung.perMail) {
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
}
const meldung = {
anlass: 'tagesueberblick' as const,
// Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form.
schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`,
titel: 'Probemeldung',
text: 'Wenn du das liest, kommen Benachrichtigungen an.',
pfad: '/einstellungen',
}
const wege: string[] = []
const einst = umgebung()
if (einstellung.perPush) {
if (!einst.pushAktiv) {
wege.push('Push ist auf diesem Server nicht eingerichtet')
} else {
const bericht = await pushSenden(akteur.id, meldung)
wege.push(
bericht.zugestellt > 0
? `Push an ${bericht.zugestellt} Gerät(e)`
: 'kein Gerät für Push angemeldet',
)
}
}
if (einstellung.perMail) {
if (!einst.mailAktiv) {
wege.push('E-Mail ist auf diesem Server nicht eingerichtet')
} else {
wege.push(
(await mailSenden(akteur.email, meldung))
? 'E-Mail verschickt'
: 'E-Mail fehlgeschlagen',
)
}
}
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.probe',
objektTyp: 'Benachrichtigungseinstellung',
objektId: akteur.id,
})
return { ok: true, meldung: wege.join(' · ') }
}
/** Entfernt ein angemeldetes Push-Gerät. */
export async function geraetEntfernen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const id = daten.get('id')
if (typeof id !== 'string' || id.length === 0) {
return { ok: false, fehler: 'Ungültige Anfrage.' }
}
const geraet = await db.pushGeraet.findFirst({
where: { id, benutzerId: akteur.id },
select: { id: true, bezeichnung: true },
})
if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' }
await db.pushGeraet.delete({ where: { id: geraet.id } })
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'push.abgemeldet',
objektTyp: 'PushGeraet',
objektId: geraet.id,
details: { bezeichnung: geraet.bezeichnung },
})
revalidatePath('/einstellungen')
return { ok: true, meldung: 'Gerät abgemeldet.' }
}
+110
View File
@@ -0,0 +1,110 @@
import 'server-only'
import nodemailer, { type Transporter } from 'nodemailer'
import type { Meldung } from '@/lib/benachrichtigung'
import { umgebung } from '@/server/umgebung'
/**
* E-Mail-Versand.
*
* Jede Mail geht als reiner Text **und** als HTML hinaus. Wer in einem
* Textclient liest, bekommt etwas Lesbares; wer HTML liest, etwas
* Aufgeräumtes.
*
* Der Inhalt bleibt knapp: Titel, ein paar Zeilen, ein Link. Eine Mail, die
* alles wiederholt, was ohnehin in der Anwendung steht, liest niemand.
*/
let versand: Transporter | null = null
function transporter(): Transporter | null {
const einst = umgebung()
if (!einst.mailAktiv) return null
if (versand) return versand
versand = nodemailer.createTransport({
host: einst.SMTP_HOST,
port: einst.SMTP_PORT,
secure: einst.SMTP_TLS,
...(einst.SMTP_BENUTZER
? { auth: { user: einst.SMTP_BENUTZER, pass: einst.SMTP_PASSWORT } }
: {}),
})
return versand
}
/** Nur für Tests und nach einer Konfigurationsänderung. */
export function versandVergessen(): void {
versand = null
}
function alsHtml(meldung: Meldung, adresse: string): string {
// Bewusst schlichtes HTML ohne Bilder und ohne externe Stile: Mailclients
// blockieren beides ohnehin, und ein Text, der nur mit CSS lesbar ist,
// ist kaputt.
const zeilen = meldung.text
.split('\n')
.filter((z) => z.trim().length > 0)
.map((z) => `<p style="margin:0 0 8px">${maskieren(z)}</p>`)
.join('')
return [
'<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2430">',
`<h1 style="font-size:18px;margin:0 0 12px">${maskieren(meldung.titel)}</h1>`,
zeilen,
`<p style="margin:16px 0 0"><a href="${adresse}" style="color:#2563eb">Im Planer ansehen</a></p>`,
'</div>',
].join('')
}
function maskieren(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** Schickt eine Meldung per Mail. Gibt zurück, ob es geklappt hat. */
export async function mailSenden(empfaenger: string, meldung: Meldung): Promise<boolean> {
const versender = transporter()
if (!versender) return false
const einst = umgebung()
const adresse = `${einst.APP_URL}${meldung.pfad}`
try {
await versender.sendMail({
from: einst.SMTP_ABSENDER,
to: empfaenger,
subject: `Planer: ${meldung.titel}`,
text: `${meldung.text}\n\nIm Planer ansehen: ${adresse}\n`,
html: alsHtml(meldung, adresse),
})
return true
} catch (fehler) {
// Nie den Mailinhalt ins Protokoll – nur, dass es nicht ging.
console.error(
'Mailversand fehlgeschlagen:',
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
)
return false
}
}
/** Prüft die Verbindung, ohne etwas zu verschicken. */
export async function mailVerbindungPruefen(): Promise<
{ ok: true } | { ok: false; fehler: string }
> {
const versender = transporter()
if (!versender) return { ok: false, fehler: 'SMTP ist nicht eingerichtet.' }
try {
await versender.verify()
return { ok: true }
} catch (fehler) {
return {
ok: false,
fehler: fehler instanceof Error ? fehler.message : 'Verbindung fehlgeschlagen.',
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'server-only'
import webpush from 'web-push'
import type { Meldung } from '@/lib/benachrichtigung'
import { db } from '@/server/db'
import { entschluesseln, verschluesseln } from '@/server/krypto'
import { umgebung } from '@/server/umgebung'
/**
* Web-Push.
*
* Die Schlüssel eines Push-Abos liegen verschlüsselt in der Datenbank: Wer
* sie hat, kann Nachrichten an fremde Browser schicken. Das ist kein
* Vollzugriff, aber auch nichts, was offen in einer Sicherung liegen sollte.
*/
/** Ab so vielen Fehlversuchen hintereinander wird das Gerät entfernt. */
const FEHLVERSUCHE_GRENZE = 5
let eingerichtet = false
function vapidEinrichten(): boolean {
const einst = umgebung()
if (!einst.pushAktiv) return false
if (eingerichtet) return true
webpush.setVapidDetails(
einst.VAPID_KONTAKT,
einst.VAPID_OEFFENTLICH,
einst.VAPID_PRIVAT,
)
eingerichtet = true
return true
}
export type PushAbo = {
endpunkt: string
p256dh: string
auth: string
}
/** Legt ein Gerät an oder frischt es auf. */
export async function geraetAnmelden(
benutzerId: string,
abo: PushAbo,
bezeichnung: string | null,
): Promise<{ id: string }> {
return db.pushGeraet.upsert({
where: { endpunkt: abo.endpunkt },
update: {
benutzerId,
p256dh: verschluesseln(abo.p256dh),
auth: verschluesseln(abo.auth),
fehlversuche: 0,
...(bezeichnung ? { bezeichnung } : {}),
},
create: {
benutzerId,
endpunkt: abo.endpunkt,
p256dh: verschluesseln(abo.p256dh),
auth: verschluesseln(abo.auth),
bezeichnung,
},
select: { id: true },
})
}
export async function geraetAbmelden(endpunkt: string): Promise<void> {
await db.pushGeraet.deleteMany({ where: { endpunkt } })
}
export type PushBericht = { zugestellt: number; entfernt: number; gescheitert: number }
/**
* Schickt eine Meldung an alle Geräte eines Benutzers.
*
* Antwortet der Push-Dienst mit 404 oder 410, gibt es das Abo nicht mehr —
* das Gerät wird sofort entfernt. Andere Fehler werden gezählt; erst nach
* mehreren hintereinander fliegt es raus, denn ein Aussetzer ist kein Grund,
* jemanden abzumelden.
*/
export async function pushSenden(
benutzerId: string,
meldung: Meldung,
): Promise<PushBericht> {
if (!vapidEinrichten()) return { zugestellt: 0, entfernt: 0, gescheitert: 0 }
const geraete = await db.pushGeraet.findMany({
where: { benutzerId },
select: { id: true, endpunkt: true, p256dh: true, auth: true, fehlversuche: true },
})
const inhalt = JSON.stringify({
titel: meldung.titel,
text: meldung.text,
pfad: meldung.pfad,
anlass: meldung.anlass,
})
let zugestellt = 0
let entfernt = 0
let gescheitert = 0
for (const geraet of geraete) {
const p256dh = entschluesseln(geraet.p256dh)
const auth = entschluesseln(geraet.auth)
if (!p256dh || !auth) {
// Mit gewechseltem AUTH_SECRET sind die Schlüssel nicht mehr lesbar.
await db.pushGeraet.delete({ where: { id: geraet.id } })
entfernt++
continue
}
try {
await webpush.sendNotification(
{ endpoint: geraet.endpunkt, keys: { p256dh, auth } },
inhalt,
{ TTL: 60 * 60 },
)
await db.pushGeraet.update({
where: { id: geraet.id },
data: { zuletztBenutzt: new Date(), fehlversuche: 0 },
})
zugestellt++
} catch (fehler) {
const status =
typeof fehler === 'object' && fehler !== null && 'statusCode' in fehler
? Number(fehler.statusCode)
: 0
if (status === 404 || status === 410) {
await db.pushGeraet.delete({ where: { id: geraet.id } })
entfernt++
continue
}
const neu = geraet.fehlversuche + 1
if (neu >= FEHLVERSUCHE_GRENZE) {
await db.pushGeraet.delete({ where: { id: geraet.id } })
entfernt++
} else {
await db.pushGeraet.update({
where: { id: geraet.id },
data: { fehlversuche: neu },
})
gescheitert++
}
}
}
return { zugestellt, entfernt, gescheitert }
}
+389
View File
@@ -0,0 +1,389 @@
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 type { Quelle } from '@/generated/prisma/enums'
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
import { db } from '@/server/db'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { telegramMeldung } from '@/server/integration/telegram'
import { umgebung } from '@/server/umgebung'
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
perTelegram: 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,
perTelegram: zeile.perTelegram,
ueberblickUm: zeile.ueberblickUm,
ruheVon: zeile.ruheVon,
ruheBis: zeile.ruheBis,
ruheAmWochenende: zeile.ruheAmWochenende,
zeitblockVorlaufMinuten: zeile.zeitblockVorlaufMinuten,
}
}
async function empfaengerLaden(): Promise<BenutzerMitEinstellung[]> {
const zeilen = await db.benutzer.findMany({
where: { aktiv: true },
select: {
id: true,
email: true,
name: true,
benachrichtigung: {
select: {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
},
},
})
return zeilen.map((z) => ({
id: z.id,
email: z.email,
name: z.name,
einstellung: alsEinstellung(z.benachrichtigung),
}))
}
/**
* Stellt eine Meldung zu, wenn sie darf, und hält den Nachweis fest.
*
* Der Nachweis wird **vor** dem Versand geschrieben. Bricht der Versand ab,
* gilt die Meldung trotzdem als erledigt — lieber eine verpasste Nachricht
* als eine, die im Minutentakt erneut versucht wird.
*/
export async function meldungZustellen(
benutzer: BenutzerMitEinstellung,
meldung: Meldung,
zeitpunkt: Date,
): Promise<boolean> {
const gemeldet = await db.meldungsnachweis.findUnique({
where: {
benutzerId_schluessel: { benutzerId: benutzer.id, schluessel: meldung.schluessel },
},
select: { id: true },
})
const entscheidung = sendenEntscheiden(
benutzer.einstellung,
meldung,
new Set(gemeldet ? [meldung.schluessel] : []),
zeitpunkt,
)
if (!entscheidung.senden) return false
await db.meldungsnachweis.create({
data: {
benutzerId: benutzer.id,
schluessel: meldung.schluessel,
gesendetAm: zeitpunkt,
},
})
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
if (benutzer.einstellung.perTelegram && umgebung().telegramAktiv) {
await telegramMeldung(benutzer.id, meldung)
}
return true
}
// --- Die einzelnen Anlässe -------------------------------------------------
/** Baut den Tagesüberblick: was heute ansteht. */
async function tagesueberblickBauen(
benutzerId: string,
zeitpunkt: Date,
): Promise<Meldung | null> {
const heute = tagesBeginn(zeitpunkt)
const heuteEnde = tagesEnde(zeitpunkt)
const [ueberfaellig, heuteFaellig, zeitbloecke] = await Promise.all([
db.aufgabe.count({
where: {
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
typ: { not: 'IDEE' },
faelligAm: { lt: heute },
},
}),
db.aufgabe.count({
where: {
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
typ: { not: 'IDEE' },
faelligAm: { gte: heute, lte: heuteEnde },
},
}),
db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId },
beginn: { gte: heute, lte: heuteEnde },
},
orderBy: { beginn: 'asc' },
select: { beginn: true, ende: true, aufgabe: { select: { titel: true } } },
}),
])
// Ein Überblick, der nichts zu sagen hat, ist keine Nachricht wert.
if (ueberfaellig === 0 && heuteFaellig === 0 && zeitbloecke.length === 0) return null
const teile: string[] = []
if (ueberfaellig > 0) teile.push(`${ueberfaellig} überfällig`)
if (heuteFaellig > 0) teile.push(`${heuteFaellig} heute fällig`)
const geplanteMinuten = zeitbloecke.reduce(
(summe, z) => summe + (z.ende.getTime() - z.beginn.getTime()) / 60_000,
0,
)
if (zeitbloecke.length > 0) {
teile.push(`${zeitbloecke.length} Zeitblöcke (${alsDauer(geplanteMinuten)})`)
}
const zeilen = [teile.join(', ') + '.']
for (const block of zeitbloecke.slice(0, 5)) {
zeilen.push(`${alsUhrzeitAnzeige(block.beginn)} ${block.aufgabe.titel}`)
}
if (zeitbloecke.length > 5) zeilen.push(`… und ${zeitbloecke.length - 5} weitere.`)
return {
anlass: 'tagesueberblick',
schluessel: meldungsSchluessel(
'tagesueberblick',
'tag',
alsTagesschluessel(zeitpunkt),
),
titel: 'Dein Tag',
text: zeilen.join('\n'),
pfad: '/',
}
}
/** Aufgaben, die heute fällig werden. */
async function faelligkeitenBauen(zeitpunkt: Date): Promise<Meldung[]> {
const aufgaben = await db.aufgabe.findMany({
where: {
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
typ: { not: 'IDEE' },
faelligAm: { gte: tagesBeginn(zeitpunkt), lte: tagesEnde(zeitpunkt) },
},
select: { id: true, titel: true },
})
const tag = alsTagesschluessel(zeitpunkt)
return aufgaben.map((aufgabe) => ({
anlass: 'faelligkeit' as const,
schluessel: meldungsSchluessel('faelligkeit', aufgabe.id, tag),
titel: 'Heute fällig',
text: aufgabe.titel,
pfad: '/aufgaben',
// Genau eine Aufgabe: Telegram kann einen Knopf zum Abhaken anbieten.
objektId: aufgabe.id,
}))
}
/** Zeitblöcke, die gleich beginnen. */
async function zeitblockErinnerungenBauen(
benutzer: BenutzerMitEinstellung,
zeitpunkt: Date,
): Promise<Meldung[]> {
const bloecke = await db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId: benutzer.id },
beginn: {
gte: zeitpunkt,
lte: new Date(
zeitpunkt.getTime() +
(benutzer.einstellung.zeitblockVorlaufMinuten + 15) * 60_000,
),
},
},
select: { id: true, beginn: true, aufgabe: { select: { titel: true } } },
})
const tag = alsTagesschluessel(zeitpunkt)
return bloecke
.filter((b) => zeitblockErinnerungFaellig(benutzer.einstellung, b.beginn, zeitpunkt))
.map((block) => ({
anlass: 'zeitblock' as const,
schluessel: meldungsSchluessel('zeitblock', block.id, tag),
titel: `${alsUhrzeitAnzeige(block.beginn)} geht es los`,
text: block.aufgabe.titel,
pfad: '/woche',
}))
}
/** Lesbare Namen für die Meldung; die Aufzählung selbst ist technisch. */
const INTEGRATION_NAME: Partial<Record<Quelle, string>> = {
ZAMMAD: 'Zammad',
WARTUNGSPLANER: 'Wartungsplaner',
TELEGRAM: 'Telegram',
SPIEGEL: 'Spiegel',
}
/** Kalender und Integrationen, die mehrfach hintereinander klemmten. */
async function abgleichfehlerBauen(
benutzerId: string,
zeitpunkt: Date,
): Promise<Meldung[]> {
const konten = await db.kalenderkonto.findMany({
where: {
benutzerId,
aktiv: true,
letzterFehler: { not: null },
// Ein Aussetzer ist keine Nachricht wert – ein Netz hustet, ein
// Anbieter hat Wartung. Erst mehrere Fehlschläge hintereinander.
fehlversuche: { gte: ABGLEICHFEHLER_AB_VERSUCH },
},
select: { id: true, anzeigename: true, letzterFehler: true },
})
// Eine klemmende Integration wiegt genauso schwer wie ein klemmender
// Kalender: In beiden Fällen steht die Liste auf einem alten Stand, ohne
// dass es jemandem auffiele.
const integrationen = await db.integrationszustand.findMany({
where: {
letzterFehler: { not: null },
fehlversuche: { gte: INTEGRATION_FEHLER_AB_VERSUCH },
},
select: { quelle: 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',
})),
...integrationen.map((i) => ({
anlass: 'abgleichfehler' as const,
schluessel: meldungsSchluessel('abgleichfehler', i.quelle, tag),
titel: 'Eine Integration klemmt',
text: `${INTEGRATION_NAME[i.quelle] ?? i.quelle}: ${i.letzterFehler ?? 'unbekannter Fehler'}`,
pfad: '/integrationen',
})),
]
}
export type VersandBericht = { geprueft: number; gesendet: number }
/**
* Ein Durchlauf: für jeden Benutzer alles zusammentragen, was jetzt fällig
* wäre, und zustellen, was darf.
*/
export async function benachrichtigungenVerschicken(
zeitpunkt: Date = jetzt(),
): Promise<VersandBericht> {
const empfaenger = await empfaengerLaden()
let gesendet = 0
for (const benutzer of empfaenger) {
// Wer keinen Weg hinaus eingerichtet hat, braucht auch nichts gebaut.
if (!benutzer.einstellung.perPush && !benutzer.einstellung.perMail) continue
const meldungen: Meldung[] = []
if (
benutzer.einstellung.anlaesse.includes('tagesueberblick') &&
ueberblickFaellig(benutzer.einstellung, zeitpunkt)
) {
const ueberblick = await tagesueberblickBauen(benutzer.id, zeitpunkt)
if (ueberblick) meldungen.push(ueberblick)
}
if (benutzer.einstellung.anlaesse.includes('faelligkeit')) {
meldungen.push(...(await faelligkeitenBauen(zeitpunkt)))
}
if (benutzer.einstellung.anlaesse.includes('zeitblock')) {
meldungen.push(...(await zeitblockErinnerungenBauen(benutzer, zeitpunkt)))
}
if (benutzer.einstellung.anlaesse.includes('abgleichfehler')) {
meldungen.push(...(await abgleichfehlerBauen(benutzer.id, zeitpunkt)))
}
for (const meldung of meldungen) {
if (await meldungZustellen(benutzer, meldung, zeitpunkt)) gesendet++
}
}
return { geprueft: empfaenger.length, gesendet }
}
/** Alte Nachweise wegräumen; sonst wächst die Tabelle unbegrenzt. */
export async function alteNachweiseEntfernen(zeitpunkt: Date = jetzt()): Promise<number> {
const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000)
const ergebnis = await db.meldungsnachweis.deleteMany({
where: { gesendetAm: { lt: grenze } },
})
return ergebnis.count
}
+197
View File
@@ -0,0 +1,197 @@
import 'server-only'
import type { Quelle } from '@/generated/prisma/enums'
import { uebernahmeEntscheiden, type Bestand, type Fund } from '@/lib/integration'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Die gemeinsame Aufnahmestelle für alle Integrationen.
*
* Jede Integration liefert eine Liste von Funden; was daraus wird, entscheidet
* für alle dieselbe Stelle. Damit verhält sich Zammad wie Telegram wie der
* Spiegel — und eine neue Integration muss nur noch abholen können.
*
* Hier hängt später auch der Wartungsplaner ein: Er braucht eine Funktion, die
* seine Daten in `Fund` übersetzt, sonst nichts.
*/
export type Aufnahmebericht = {
angelegt: number
aktualisiert: number
geschlossen: number
unveraendert: number
}
const LEER: Aufnahmebericht = {
angelegt: 0,
aktualisiert: 0,
geschlossen: 0,
unveraendert: 0,
}
/** Wer die Aufnahme angestoßen hat – für das Protokoll. */
export type Anstoss = { id: string | null; email: string }
/**
* Nimmt eine Runde Funde auf.
*
* Anschließend gilt: Zu jedem Fund gibt es genau eine Aufgabe mit
* `[quelle, ref]`, und keine Aufgabe wurde in Feldern angefasst, die hier
* gepflegt werden statt drüben.
*/
export async function fundeAufnehmen(
quelle: Quelle,
funde: readonly Fund[],
anstoss: Anstoss,
): Promise<Aufnahmebericht> {
const bericht = { ...LEER }
const zeitpunkt = jetzt()
for (const fund of funde) {
const vorhanden = await db.aufgabe.findUnique({
where: { quelle_quelleRef: { quelle, quelleRef: fund.ref } },
select: {
id: true,
titel: true,
notiz: true,
quelleUrl: true,
faelligAm: true,
prioritaet: true,
aufwandMinuten: true,
status: true,
},
})
const bestand: Bestand | null = vorhanden
? {
titel: vorhanden.titel,
notiz: vorhanden.notiz,
quelleUrl: vorhanden.quelleUrl,
faelligAm: vorhanden.faelligAm,
prioritaet: vorhanden.prioritaet,
aufwandMinuten: vorhanden.aufwandMinuten,
erledigt: vorhanden.status === 'ERLEDIGT' || vorhanden.status === 'VERWORFEN',
}
: null
const entscheidung = uebernahmeEntscheiden(fund, bestand)
if (entscheidung.tun === 'nichts') {
bericht.unveraendert++
continue
}
if (entscheidung.tun === 'anlegen') {
const angelegt = await db.aufgabe.create({
data: {
titel: fund.titel,
notiz: fund.notiz ?? null,
quelle,
quelleRef: fund.ref,
quelleUrl: fund.url ?? null,
faelligAm: fund.faelligAm ?? null,
...(fund.prioritaet ? { prioritaet: fund.prioritaet } : {}),
...(fund.aufwandMinuten ? { aufwandMinuten: fund.aufwandMinuten } : {}),
},
select: { id: true },
})
bericht.angelegt++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.angelegt',
objektTyp: 'Aufgabe',
objektId: angelegt.id,
// Der Titel steht schon in der Aufgabe; hier genügt die Herkunft.
details: { quelle, ref: fund.ref },
})
continue
}
if (entscheidung.tun === 'schliessen') {
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: { status: 'ERLEDIGT', erledigtAm: zeitpunkt },
})
bericht.geschlossen++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.geschlossen',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref },
})
continue
}
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: entscheidung.felder,
})
bericht.aktualisiert++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.aktualisiert',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref, felder: Object.keys(entscheidung.felder) },
})
}
return bericht
}
// --- Zustand je Integration ------------------------------------------------
/** Liest den Merker, an dem eine Integration weitermacht. */
export async function merkerLesen(quelle: Quelle): Promise<string | null> {
const zeile = await db.integrationszustand.findUnique({
where: { quelle },
select: { merker: true },
})
return zeile?.merker ?? null
}
/** Hält fest, dass ein Lauf geklappt hat. */
export async function laufGeglueckt(quelle: Quelle, merker?: string): Promise<void> {
const zeitpunkt = jetzt()
const werte = {
letzterLauf: zeitpunkt,
letzterErfolg: zeitpunkt,
letzterFehler: null,
fehlversuche: 0,
...(merker === undefined ? {} : { merker }),
}
await db.integrationszustand.upsert({
where: { quelle },
update: werte,
create: { quelle, ...werte },
})
}
/**
* Hält einen Fehlschlag fest und zählt hoch.
*
* Nur die Meldung, nie die Antwort des fremden Systems: Darin stehen gern
* Tokens, Kopfzeilen und ganze Datensätze.
*/
export async function laufGescheitert(quelle: Quelle, fehler: unknown): Promise<number> {
const meldung = fehler instanceof Error ? fehler.message : 'unbekannter Fehler'
const zeitpunkt = jetzt()
const zeile = await db.integrationszustand.upsert({
where: { quelle },
update: {
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: { increment: 1 },
},
create: {
quelle,
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: 1,
},
select: { fehlversuche: true },
})
return zeile.fehlversuche
}
+213
View File
@@ -0,0 +1,213 @@
import 'server-only'
import { createHmac, timingSafeEqual } from 'node:crypto'
import {
sendungFrisch,
spiegelSendung,
type Fund,
type SpiegelSendung,
} from '@/lib/integration'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
type Anstoss,
} from '@/server/integration/aufnahme'
/**
* Der Spiegel: `firma` sendet, `daheim` empfängt.
*
* Zwei Dinge gehen hinüber. **Belegungen** sagen nur, wann Zeit weg ist —
* ohne Titel, ohne Aufgabe, ohne Kunde. So kollidiert die private Planung
* nicht mit der dienstlichen, ohne dass Firmeninhalte das Haus verlassen.
* **Aufgaben** gehen nur mit, wenn sie ausdrücklich dafür markiert sind; das
* ist eine bewusste Einzelentscheidung, kein Schalter für alles.
*
* Die Richtung ist fest verdrahtet: Was in der Firma passiert, darf daheim
* sichtbar sein, umgekehrt nicht. Das ist keine Technik, sondern eine
* Hausregel — und deshalb steckt sie in den Instanzfähigkeiten, nicht in
* einer Einstellung, die sich verstellen ließe.
*/
/** Wie weit voraus gespiegelt wird. Weiter zurück interessiert niemanden. */
const FENSTER_TAGE_VORAUS = 30
export function signieren(koerper: string, geheimnis: string): string {
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
}
/**
* Prüft die Signatur in gleichbleibender Zeit.
*
* Ein einfacher Vergleich verrät über die Laufzeit, wie viele Zeichen schon
* stimmen — damit ließe sich die Signatur Stück für Stück erraten.
*/
export function signaturStimmt(
koerper: string,
signatur: string,
geheimnis: string,
): boolean {
const erwartet = Buffer.from(signieren(koerper, geheimnis), 'utf8')
const bekommen = Buffer.from(signatur, 'utf8')
if (erwartet.length !== bekommen.length) return false
return timingSafeEqual(erwartet, bekommen)
}
// --- Senden (firma) --------------------------------------------------------
/** Stellt zusammen, was hinübergeht. */
export async function sendungBauen(zeitpunkt: Date): Promise<SpiegelSendung> {
const bis = new Date(zeitpunkt.getTime() + FENSTER_TAGE_VORAUS * 86_400_000)
const [zeitbloecke, aufgaben] = await Promise.all([
db.zeitblock.findMany({
where: { beginn: { gte: zeitpunkt, lte: bis } },
orderBy: { beginn: 'asc' },
take: 500,
// Kein Titel, keine Aufgabe: Nur wann die Zeit weg ist.
select: { id: true, beginn: true, ende: true },
}),
db.aufgabe.findMany({
where: { spiegeln: true },
orderBy: { erstelltAm: 'asc' },
take: 500,
select: { id: true, titel: true, faelligAm: true, status: true },
}),
])
return {
gesendetAm: zeitpunkt.toISOString(),
belegungen: zeitbloecke.map((z) => ({
ref: z.id,
beginn: z.beginn.toISOString(),
ende: z.ende.toISOString(),
})),
aufgaben: aufgaben.map((a) => ({
ref: a.id,
titel: a.titel,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
erledigt: a.status === 'ERLEDIGT' || a.status === 'VERWORFEN',
})),
}
}
/** Schickt eine Runde an die andere Instanz. */
export async function spiegelSenden(): Promise<{ belegungen: number; aufgaben: number }> {
const einst = umgebung()
try {
const sendung = await sendungBauen(jetzt())
const koerper = JSON.stringify(sendung)
const gelesen = await fetch(`${einst.SPIEGEL_ZIEL.replace(/\/+$/, '')}/api/spiegel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper, einst.SPIEGEL_GEHEIMNIS),
},
body: koerper,
signal: AbortSignal.timeout(20_000),
})
if (!gelesen.ok) {
throw new Error(`Der Spiegel antwortete mit HTTP ${gelesen.status}.`)
}
await laufGeglueckt('SPIEGEL')
return { belegungen: sendung.belegungen.length, aufgaben: sendung.aufgaben.length }
} catch (fehler) {
await laufGescheitert('SPIEGEL', fehler)
throw fehler
}
}
// --- Empfangen (daheim) ----------------------------------------------------
export type Empfangsbericht = {
belegungen: number
entfernt: number
aufgaben: number
}
/**
* Nimmt eine geprüfte Sendung auf.
*
* Belegungen werden abgeglichen, nicht angehäuft: Was der Sender nicht mehr
* schickt, ist drüben ausgeplant worden und verschwindet auch hier. Sonst
* stünde daheim für immer eine Sperre, die es längst nicht mehr gibt.
*/
export async function sendungAufnehmen(
sendung: SpiegelSendung,
anstoss: Anstoss,
): Promise<Empfangsbericht> {
const gesehen = new Set(sendung.belegungen.map((b) => b.ref))
for (const belegung of sendung.belegungen) {
const werte = {
beginn: new Date(belegung.beginn),
ende: new Date(belegung.ende),
empfangenAm: jetzt(),
}
await db.spiegelbelegung.upsert({
where: { quelleRef: belegung.ref },
update: werte,
create: { quelleRef: belegung.ref, ...werte },
})
}
const veraltet = await db.spiegelbelegung.findMany({
where: { quelleRef: { notIn: [...gesehen] } },
select: { id: true },
})
if (veraltet.length > 0) {
await db.spiegelbelegung.deleteMany({
where: { id: { in: veraltet.map((v) => v.id) } },
})
}
const funde: Fund[] = sendung.aufgaben.map((a) => ({
ref: a.ref,
titel: a.titel,
faelligAm: a.faelligAm ? new Date(a.faelligAm) : null,
erledigt: a.erledigt,
}))
const bericht = await fundeAufnehmen('SPIEGEL', funde, anstoss)
await laufGeglueckt('SPIEGEL')
return {
belegungen: sendung.belegungen.length,
entfernt: veraltet.length,
aufgaben: bericht.angelegt + bericht.aktualisiert + bericht.geschlossen,
}
}
/**
* Prüft Signatur und Frische und gibt die Sendung frei.
*
* Gibt bei jedem Fehlschlag dieselbe knappe Auskunft: Wer von außen probiert,
* soll nicht erfahren, ob das Geheimnis stimmte und nur die Uhr falsch ging.
*/
export function sendungPruefen(
koerper: string,
signatur: string | null,
zeitpunkt: Date,
): { ok: true; sendung: SpiegelSendung } | { ok: false } {
const einst = umgebung()
if (!einst.spiegelEmpfangenAktiv || !signatur) return { ok: false }
if (!signaturStimmt(koerper, signatur, einst.SPIEGEL_GEHEIMNIS)) return { ok: false }
let inhalt: unknown
try {
inhalt = JSON.parse(koerper)
} catch {
return { ok: false }
}
const gelesen = spiegelSendung.safeParse(inhalt)
if (!gelesen.success) return { ok: false }
if (!sendungFrisch(new Date(gelesen.data.gesendetAm), zeitpunkt)) return { ok: false }
return { ok: true, sendung: gelesen.data }
}
+392
View File
@@ -0,0 +1,392 @@
import 'server-only'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { alsTitel, type Fund } from '@/lib/integration'
import type { Meldung } from '@/lib/benachrichtigung'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
merkerLesen,
type Anstoss,
} from '@/server/integration/aufnahme'
/**
* Telegram – nur in der Instanz „daheim".
*
* Drei Dinge in einem Kanal: erfassen (Nachricht wird Aufgabe), melden
* (dritter Weg neben Push und Mail) und abhaken (Knopf unter der Meldung).
*
* Geholt wird per `getUpdates`, nicht per Webhook. Ein Webhook verlangt eine
* von außen erreichbare Adresse mit gültigem Zertifikat; der Planer soll auch
* hinter einem Anschluss ohne feste Adresse laufen.
*
* **Wer nicht im Adressbuch steht, wird verworfen.** Ein Bot-Token lässt sich
* abgreifen, und wer die Kennung des Bots kennt, kann ihm schreiben. Die
* erlaubten Chats stehen in der Datenbank; alles andere wird stillschweigend
* weggeworfen — eine Antwort wäre schon die Auskunft, dass es den Bot gibt.
*/
const HOECHSTENS = 50
const nachricht = z.object({
message_id: z.number().int(),
date: z.number().int(),
chat: z.object({ id: z.number().int() }),
text: z.string().optional(),
})
const rueckfrage = z.object({
id: z.string(),
data: z.string().optional(),
message: z
.object({ message_id: z.number().int(), chat: z.object({ id: z.number().int() }) })
.optional(),
})
const aktualisierung = z.object({
update_id: z.number().int(),
message: nachricht.optional(),
callback_query: rueckfrage.optional(),
})
const antwort = z.object({
ok: z.boolean(),
result: z.array(z.unknown()).optional(),
description: z.string().optional(),
})
async function rufen(
methode: string,
koerper: Record<string, unknown>,
): Promise<unknown> {
const einst = umgebung()
const gelesen = await fetch(
`https://api.telegram.org/bot${einst.TELEGRAM_TOKEN}/${methode}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
signal: AbortSignal.timeout(70_000),
},
)
if (!gelesen.ok) {
// Der Körper enthält im Fehlerfall den Token nicht, wohl aber die
// Nachricht – deshalb nur der Status.
throw new Error(`Telegram antwortete mit HTTP ${gelesen.status}.`)
}
const inhalt: unknown = await gelesen.json()
const geprueft = antwort.safeParse(inhalt)
if (!geprueft.success || !geprueft.data.ok) {
throw new Error('Telegram lehnte die Anfrage ab.')
}
return geprueft.data.result
}
// --- Senden ----------------------------------------------------------------
export type Knopf = { text: string; daten: string }
/** Schickt eine Nachricht in einen Chat. Gibt zurück, ob es geklappt hat. */
export async function telegramSenden(
chatId: bigint,
text: string,
knoepfe: readonly Knopf[] = [],
): Promise<boolean> {
try {
await rufen('sendMessage', {
chat_id: Number(chatId),
text,
...(knoepfe.length > 0
? {
reply_markup: {
inline_keyboard: [
knoepfe.map((k) => ({ text: k.text, callback_data: k.daten })),
],
},
}
: {}),
})
return true
} catch (fehler) {
console.error(
'Telegram-Versand fehlgeschlagen:',
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
)
return false
}
}
/**
* Schickt eine Meldung an alle Chats eines Benutzers, die Meldungen wollen.
*
* Hängt eine Aufgabe daran, kommt ein Knopf mit: abhaken, ohne die Anwendung
* zu öffnen. Genau dafür lohnt sich der Umweg über Telegram.
*/
export async function telegramMeldung(
benutzerId: string,
meldung: Meldung,
): Promise<number> {
const chats = await db.telegramChat.findMany({
where: { benutzerId, meldungen: true },
select: { id: true, chatId: true },
})
const einst = umgebung()
const text = `${meldung.titel}\n\n${meldung.text}\n\n${einst.APP_URL}${meldung.pfad}`
const knoepfe: Knopf[] = meldung.objektId
? [{ text: '✓ Erledigt', daten: `erledigt:${meldung.objektId}` }]
: []
let zugestellt = 0
for (const chat of chats) {
if (await telegramSenden(chat.chatId, text, knoepfe)) {
zugestellt++
await db.telegramChat.update({
where: { id: chat.id },
data: { zuletztBenutzt: jetzt() },
})
}
}
return zugestellt
}
// --- Kopplung --------------------------------------------------------------
/**
* Wie lange ein Kopplungscode gilt.
*
* Kurz genug, dass ein abgeschriebener Code nichts mehr nützt; lang genug,
* um in Ruhe das Handy zu suchen.
*/
export const KOPPLUNG_MINUTEN = 10
/** Ziffern und Großbuchstaben ohne die, die sich verwechseln lassen. */
const ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
/**
* Der Code, allein oder hinter `/start`.
*
* Telegram schickt beim ersten Öffnen eines Bots `/start` mit; wer den Code
* abtippt, schickt ihn nackt. Beides muss gehen.
*/
const KOPPLUNGSBEFEHL = /^(?:\/start\s+)?([A-Za-z0-9]{8})$/
export function kopplungscodeBauen(): string {
const bytes = randomBytes(8)
return [...bytes]
.map((b) => ZEICHEN[b % ZEICHEN.length])
.join('')
.slice(0, 8)
}
/**
* Löst einen Code ein und trägt den Chat ein.
*
* Der Code gilt genau einmal: Er verschwindet mit dem ersten Einlösen, auch
* wenn dabei etwas schiefgeht.
*/
async function kopplungEinloesen(
code: string,
chatId: number,
): Promise<'gekoppelt' | 'schon-da' | 'unbekannt'> {
const kopplung = await db.telegramKopplung.findUnique({
where: { code },
select: { id: true, benutzerId: true, gueltigBis: true },
})
if (!kopplung) return 'unbekannt'
await db.telegramKopplung.delete({ where: { id: kopplung.id } })
if (kopplung.gueltigBis.getTime() < jetzt().getTime()) return 'unbekannt'
const vorhanden = await db.telegramChat.findUnique({
where: { chatId: BigInt(chatId) },
select: { id: true },
})
if (vorhanden) return 'schon-da'
const chat = await db.telegramChat.create({
data: { chatId: BigInt(chatId), benutzerId: kopplung.benutzerId },
select: { id: true },
})
await auditSchreiben({
akteur: { id: kopplung.benutzerId, email: 'telegram@intern' },
aktion: 'telegram.gekoppelt',
objektTyp: 'TelegramChat',
objektId: chat.id,
// Die Chatkennung gehört zu einer Person und bleibt draußen.
})
return 'gekoppelt'
}
/** Entfernt abgelaufene Codes. */
export async function alteKopplungenEntfernen(zeitpunkt: Date): Promise<number> {
const weg = await db.telegramKopplung.deleteMany({
where: { gueltigBis: { lt: zeitpunkt } },
})
return weg.count
}
// --- Holen -----------------------------------------------------------------
/** Ein bekannter Chat, oder null. Unbekannte werden nicht beantwortet. */
async function chatPruefen(chatId: number) {
return db.telegramChat.findUnique({
where: { chatId: BigInt(chatId) },
select: { id: true, benutzerId: true },
})
}
/**
* Hakt eine Aufgabe über den Knopf unter einer Meldung ab.
*
* Nur abhaken, nichts anderes: Ein Knopf, der löscht oder verschiebt, wäre
* aus einem Chat heraus zu leicht versehentlich getroffen.
*/
async function erledigtMelden(aufgabeId: string, anstoss: Anstoss): Promise<string> {
const aufgabe = await db.aufgabe.findUnique({
where: { id: aufgabeId },
select: { id: true, titel: true, status: true },
})
if (!aufgabe) return 'Diese Aufgabe gibt es nicht mehr.'
if (aufgabe.status === 'ERLEDIGT') return 'War schon erledigt.'
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
})
await auditSchreiben({
akteur: anstoss,
aktion: 'aufgabe.erledigt',
objektTyp: 'Aufgabe',
objektId: aufgabe.id,
details: { ueber: 'telegram' },
})
return 'Erledigt.'
}
/**
* Holt alles Neue ab und arbeitet es ab.
*
* Der Merker ist die Kennung der zuletzt geholten Aktualisierung. Telegram
* löscht alles darunter, sobald wir mit dem nächsten Offset fragen — das ist
* zugleich die Bestätigung, dass wir es haben.
*/
export async function telegramAbholen(anstoss: Anstoss): Promise<{
aufgaben: number
abgehakt: number
gekoppelt: number
verworfen: number
}> {
try {
const merker = await merkerLesen('TELEGRAM')
const offset = merker ? Number(merker) : undefined
const roh = await rufen('getUpdates', {
...(offset === undefined || Number.isNaN(offset) ? {} : { offset }),
limit: HOECHSTENS,
// Kein Long-Polling: Der Job läuft ohnehin im Takt und soll nicht
// minutenlang eine Verbindung offen halten.
timeout: 0,
allowed_updates: ['message', 'callback_query'],
})
const eintraege = z.array(aktualisierung).safeParse(roh)
if (!eintraege.success) throw new Error('Telegram antwortete in unbekannter Form.')
const funde: Fund[] = []
let abgehakt = 0
let gekoppelt = 0
let verworfen = 0
let hoechste = offset ? offset - 1 : 0
for (const eintrag of eintraege.data) {
hoechste = Math.max(hoechste, eintrag.update_id)
if (eintrag.callback_query) {
const chatId = eintrag.callback_query.message?.chat.id
const bekannt = chatId === undefined ? null : await chatPruefen(chatId)
if (!bekannt) {
verworfen++
continue
}
const daten = eintrag.callback_query.data ?? ''
const [was, id] = daten.split(':')
const meldung =
was === 'erledigt' && id
? await erledigtMelden(id, {
id: bekannt.benutzerId,
email: anstoss.email,
})
: 'Unbekannter Knopf.'
abgehakt += meldung === 'Erledigt.' ? 1 : 0
await rufen('answerCallbackQuery', {
callback_query_id: eintrag.callback_query.id,
text: meldung,
}).catch(() => undefined)
continue
}
const nachricht = eintrag.message
if (!nachricht?.text) continue
const bekannt = await chatPruefen(nachricht.chat.id)
if (!bekannt) {
// Die einzige Ausnahme: ein gültiger Kopplungscode. Er ist der
// Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
const code = KOPPLUNGSBEFEHL.exec(nachricht.text.trim())?.[1]?.toUpperCase()
if (code) {
const ergebnis = await kopplungEinloesen(code, nachricht.chat.id)
if (ergebnis !== 'unbekannt') {
gekoppelt++
await telegramSenden(
BigInt(nachricht.chat.id),
ergebnis === 'gekoppelt'
? 'Verbunden. Schick mir eine Nachricht, und daraus wird eine Aufgabe.'
: 'Dieser Chat war schon verbunden.',
)
continue
}
}
// Sonst kein Wort zurück. Wer nicht eingetragen ist, soll nicht
// erfahren, dass hier etwas läuft.
verworfen++
continue
}
const { titel, rest } = alsTitel(nachricht.text)
if (titel.length === 0) continue
funde.push({
ref: `${nachricht.chat.id}:${nachricht.message_id}`,
titel,
notiz: rest,
})
await db.telegramChat.update({
where: { id: bekannt.id },
data: { zuletztBenutzt: jetzt() },
})
}
const bericht = await fundeAufnehmen('TELEGRAM', funde, anstoss)
// Erst bestätigen, wenn alles verarbeitet ist: Ein Offset, der zu früh
// hochgezählt wird, verliert Nachrichten unwiederbringlich.
await laufGeglueckt('TELEGRAM', String(hoechste + 1))
return { aufgaben: bericht.angelegt, abgehakt, gekoppelt, verworfen }
} catch (fehler) {
await laufGescheitert('TELEGRAM', fehler)
throw fehler
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'server-only'
import { z } from 'zod'
import { alsTitel, type Fund } from '@/lib/integration'
import { umgebung } from '@/server/umgebung'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
type Anstoss,
type Aufnahmebericht,
} from '@/server/integration/aufnahme'
/**
* Zammad – nur in der Instanz „firma".
*
* Gelesen wird über die Suche mit einem Ausdruck aus der Konfiguration; was
* darauf passt, wird zur Aufgabe. Der Token gehört einem Benutzer und sieht
* genau das, was dieser Benutzer sehen darf — der Planer umgeht keine Rechte.
*
* Geschrieben wird nach Zammad **nicht**. Ein Ticket schließt man im
* Ticketsystem, sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
*/
/** Wie viele Tickets höchstens je Lauf. Mehr wäre ohnehin keine Planung. */
const HOECHSTENS = 100
/**
* Zammads Zuständen ist nicht anzusehen, ob sie „fertig" bedeuten. Der
* `state_type` sagt es; die Suche liefert ihn nicht immer mit, deshalb
* zusätzlich die üblichen Namen.
*/
const ERLEDIGT_ZUSTAENDE = ['closed', 'merged', 'removed']
const ticket = z.object({
id: z.number().int(),
number: z.string(),
title: z.string(),
state: z.string().optional(),
/** 1 low, 2 normal, 3 high – Zammads Voreinstellung. */
priority: z.string().optional(),
pending_time: z.string().nullable().optional(),
})
type Ticket = z.infer<typeof ticket>
const antwort = z.object({
assets: z.object({ Ticket: z.record(z.string(), z.unknown()).optional() }).optional(),
tickets: z.array(z.number().int()).optional(),
})
/**
* Übersetzt Zammads Priorität in unsere Skala.
*
* Zammad: 1 niedrig, 2 normal, 3 hoch. Hier: 1 dringend … 5 irgendwann.
* Die Zahlen laufen also gegenläufig, was beim Lesen gern übersehen wird.
*/
function prioritaetVon(zammad: string | undefined): number | null {
if (!zammad) return null
if (zammad.startsWith('3')) return 2
if (zammad.startsWith('1')) return 4
return 3
}
function alsFund(t: Ticket, basis: string): Fund {
const { titel, rest } = alsTitel(`#${t.number} ${t.title}`)
const zustand = (t.state ?? '').toLowerCase()
return {
ref: String(t.id),
titel,
notiz: rest,
url: `${basis}/#ticket/zoom/${t.id}`,
faelligAm: t.pending_time ? new Date(t.pending_time) : null,
prioritaet: prioritaetVon(t.priority),
erledigt: ERLEDIGT_ZUSTAENDE.some((z) => zustand.includes(z)),
}
}
/** Holt die Tickets, auf die der Suchausdruck passt. */
export async function ticketsHolen(): Promise<Fund[]> {
const einst = umgebung()
const basis = einst.ZAMMAD_URL.replace(/\/+$/, '')
const adresse = new URL(`${basis}/api/v1/tickets/search`)
adresse.searchParams.set('query', einst.ZAMMAD_ABFRAGE)
adresse.searchParams.set('limit', String(HOECHSTENS))
// Ohne "full" liefert die Suche nur Kennungen und wir müssten jedes Ticket
// einzeln nachladen.
adresse.searchParams.set('expand', 'true')
const gelesen = await fetch(adresse, {
headers: {
Authorization: `Token token=${einst.ZAMMAD_TOKEN}`,
Accept: 'application/json',
},
// Ein hängendes Ticketsystem darf den Job nicht festhalten.
signal: AbortSignal.timeout(20_000),
})
if (!gelesen.ok) {
// Nie den Antwortkörper: Zammad schickt im Fehlerfall gern den ganzen
// Datensatz mit.
throw new Error(`Zammad antwortete mit HTTP ${gelesen.status}.`)
}
const inhalt: unknown = await gelesen.json()
// Mit expand=true ist die Antwort eine Liste, ohne ein Objekt mit assets.
if (Array.isArray(inhalt)) {
return inhalt
.map((eintrag) => ticket.safeParse(eintrag))
.filter((g) => g.success)
.map((g) => alsFund(g.data, basis))
}
const huelle = antwort.safeParse(inhalt)
if (!huelle.success) throw new Error('Zammad antwortete in unbekannter Form.')
const werte = Object.values(huelle.data.assets?.Ticket ?? {})
return werte
.map((eintrag) => ticket.safeParse(eintrag))
.filter((g) => g.success)
.map((g) => alsFund(g.data, basis))
}
/**
* Ein Durchlauf: holen, aufnehmen, Zustand fortschreiben.
*
* Scheitert das Holen, bleibt der bisherige Stand stehen. Eine leere Antwort
* darf niemals als „alles erledigt" durchgehen — deshalb schließt diese
* Integration nur, was Zammad ausdrücklich als geschlossen liefert.
*/
export async function zammadAbgleichen(anstoss: Anstoss): Promise<Aufnahmebericht> {
try {
const funde = await ticketsHolen()
const bericht = await fundeAufnehmen('ZAMMAD', funde, anstoss)
await laufGeglueckt('ZAMMAD')
return bericht
} catch (fehler) {
await laufGescheitert('ZAMMAD', fehler)
throw fehler
}
}
+94 -1
View File
@@ -3,6 +3,14 @@ import cron from 'node-cron'
import { db } from '@/server/db' import { db } from '@/server/db'
import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich'
import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey'
import {
alteNachweiseEntfernen,
benachrichtigungenVerschicken,
} from '@/server/benachrichtigung/versand'
import { zammadAbgleichen } from '@/server/integration/zammad'
import { alteKopplungenEntfernen, telegramAbholen } from '@/server/integration/telegram'
import { spiegelSenden } from '@/server/integration/spiegel'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
/** /**
@@ -19,6 +27,32 @@ const globalerBereich = globalThis as unknown as { planerJobs?: boolean }
const ABGLEICH_PLAN = '*/15 * * * *' const ABGLEICH_PLAN = '*/15 * * * *'
/** Einmal nachts aufräumen. */ /** Einmal nachts aufräumen. */
const AUFRAEUM_PLAN = '17 3 * * *' 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 * * * *'
/**
* Integrationen laufen im eigenen Takt.
*
* Telegram oefter als der Rest: Wer dem Bot etwas schickt, erwartet die
* Aufgabe gleich in der Liste, nicht in einer Viertelstunde. Zammad und der
* Spiegel duerfen traeger sein — dort aendert sich nichts im Minutentakt.
*/
const TELEGRAM_PLAN = '*/2 * * * *'
const ZAMMAD_PLAN = '*/15 * * * *'
const SPIEGEL_PLAN = '*/15 * * * *'
/**
* Wer im Protokoll steht, wenn niemand geklickt hat.
*
* Ein Job hat keinen Benutzer. Statt den Eintrag wegzulassen, traegt er
* sichtbar den Dienst ein — sonst sieht es aus, als sei nichts passiert.
*/
const DIENST = { id: null, email: 'dienst@intern' }
async function alleKontenAbgleichen(): Promise<void> { async function alleKontenAbgleichen(): Promise<void> {
const zeitpunkt = jetzt() const zeitpunkt = jetzt()
@@ -62,8 +96,67 @@ 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)
})
})
const einst = umgebung()
if (einst.zammadAktiv) {
cron.schedule(ZAMMAD_PLAN, () => {
void zammadAbgleichen(DIENST)
.then((bericht) => {
if (bericht.angelegt > 0 || bericht.geschlossen > 0) {
console.warn(
`Zammad: ${bericht.angelegt} neu, ${bericht.geschlossen} geschlossen.`,
)
}
})
.catch((fehler: unknown) => {
console.error('Zammad-Abgleich abgebrochen:', fehler)
})
})
}
if (einst.telegramAktiv) {
cron.schedule(TELEGRAM_PLAN, () => {
void telegramAbholen(DIENST)
.then((bericht) => {
if (bericht.verworfen > 0) {
// Nie die Chatkennung: Sie gehoert zu einer Person.
console.warn(`Telegram: ${bericht.verworfen} Nachricht(en) verworfen.`)
}
})
.catch((fehler: unknown) => {
console.error('Telegram-Abholen abgebrochen:', fehler)
})
})
}
if (einst.spiegelSendenAktiv) {
cron.schedule(SPIEGEL_PLAN, () => {
void spiegelSenden().catch((fehler: unknown) => {
console.error('Spiegel-Versand abgebrochen:', fehler)
})
})
}
cron.schedule(AUFRAEUM_PLAN, () => { cron.schedule(AUFRAEUM_PLAN, () => {
void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) void Promise.all([
alteTermineEntfernen(),
abgelaufeneChallengesEntfernen(jetzt()),
alteNachweiseEntfernen(jetzt()),
alteKopplungenEntfernen(jetzt()),
])
.then(() => undefined) .then(() => undefined)
.catch((fehler: unknown) => { .catch((fehler: unknown) => {
console.error('Aufräumen abgebrochen:', fehler) console.error('Aufräumen abgebrochen:', fehler)
+6 -1
View File
@@ -339,6 +339,7 @@ export async function kontoAbgleichen(
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}), ...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
letzterAbgleich: zeitpunkt, letzterAbgleich: zeitpunkt,
letzterFehler: null, letzterFehler: null,
fehlversuche: 0,
}, },
}) })
@@ -355,7 +356,11 @@ export async function kontoAbgleichen(
await db.kalenderkonto.update({ await db.kalenderkonto.update({
where: { id: konto.id }, where: { id: konto.id },
data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung }, data: {
letzterAbgleich: zeitpunkt,
letzterFehler: meldung,
fehlversuche: { increment: 1 },
},
}) })
return { return {
+62 -1
View File
@@ -1,6 +1,6 @@
import 'server-only' import 'server-only'
import { z } from 'zod' import { z } from 'zod'
import { INSTANZEN, type Instanz } from '@/lib/instanz' import { INSTANZEN, istErlaubt, type Instanz } from '@/lib/instanz'
/** /**
* Jede Einstellung von aussen wird geprueft, bevor sie benutzt wird. Fehlt * Jede Einstellung von aussen wird geprueft, bevor sie benutzt wird. Fehlt
@@ -41,6 +41,42 @@ const schema = z.object({
GOOGLE_CLIENT_ID: z.string().trim().default(''), GOOGLE_CLIENT_ID: z.string().trim().default(''),
GOOGLE_CLIENT_SECRET: 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),
// --- Integrationen (M6) ---
// Zammad, nur in der Instanz "firma". Der Token gehoert einem Benutzer und
// sieht genau das, was dieser Benutzer sehen darf.
ZAMMAD_URL: z.string().trim().default(''),
ZAMMAD_TOKEN: z.string().trim().default(''),
/// Suchausdruck in Zammads Syntax. Was hier passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE: z
.string()
.trim()
.default('owner_id:me AND (state.name:new OR state.name:open)'),
// Telegram, nur in der Instanz "daheim".
TELEGRAM_TOKEN: z.string().trim().default(''),
// Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
// Seiten; ohne gueltige Signatur nimmt der Empfaenger nichts an.
SPIEGEL_GEHEIMNIS: z.string().trim().default(''),
/// Nur auf der sendenden Seite: wohin geschickt wird.
SPIEGEL_ZIEL: z.string().trim().default(''),
DEMO_MODUS: jaNein.default(false), DEMO_MODUS: jaNein.default(false),
}) })
@@ -51,6 +87,15 @@ export type Umgebung = z.infer<typeof schema> & {
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */ /** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
graphAktiv: boolean graphAktiv: boolean
googleAktiv: boolean googleAktiv: boolean
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
pushAktiv: boolean
mailAktiv: boolean
/** Zammad braucht Adresse und Token – und die Instanz muss es erlauben. */
zammadAktiv: boolean
telegramAktiv: boolean
/** Senden geht nur mit Ziel, Empfangen schon mit dem Geheimnis allein. */
spiegelSendenAktiv: boolean
spiegelEmpfangenAktiv: boolean
} }
let zwischengespeichert: Umgebung | null = null let zwischengespeichert: Umgebung | null = null
@@ -72,6 +117,22 @@ function lesen(): Umgebung {
), ),
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET), graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_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),
// Die Instanz entscheidet mit: eine Integration, die hier nicht erlaubt
// ist, laesst sich auch mit vollstaendiger Konfiguration nicht anschalten.
zammadAktiv:
istErlaubt(werte.INSTANZ, 'integration:zammad') &&
Boolean(werte.ZAMMAD_URL && werte.ZAMMAD_TOKEN),
telegramAktiv:
istErlaubt(werte.INSTANZ, 'integration:telegram') && Boolean(werte.TELEGRAM_TOKEN),
spiegelSendenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:senden') &&
Boolean(werte.SPIEGEL_GEHEIMNIS && werte.SPIEGEL_ZIEL),
spiegelEmpfangenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:empfangen') && Boolean(werte.SPIEGEL_GEHEIMNIS),
} }
} }