diff --git a/.env.example b/.env.example index cf43fca..8dda2c7 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer # Muss exakt der Herkunft im Browser entsprechen, inklusive Port. WEBAUTHN_ORIGIN=http://localhost:3000 +# --- Kalenderanbieter ------------------------------------------------------ +# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint +# erst, wenn Kennung UND Geheimnis gesetzt sind. +# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten". +# Rueckleit-URIs: +# /api/kalender/rueckleitung/graph +# /api/kalender/rueckleitung/google +GRAPH_CLIENT_ID= +GRAPH_CLIENT_SECRET= +# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant. +GRAPH_TENANT_ID=organizations + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + # --- Sonstiges ------------------------------------------------------------- # Zeitzone fuer Anzeigen und Tagesgrenzen. TZ=Europe/Berlin diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d2f87..b741729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +## [0.3.0] – 2026-09-10 + +### Hinzugefügt + +- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen, + von Hand abgleichen und trennen. Google gibt es nur in der Instanz + `daheim`; in `firma` liefert die Route 404. +- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus. + Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" — + keine Teilnehmer, Beschreibungen, Anhänge oder Orte. +- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus + allen verbundenen Kalendern gegen die Tageskapazität. +- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und + abgelaufene WebAuthn-Herausforderungen entfernen. +- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID + und die Google Cloud Console in der README. + +### Sicherheit + +- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank + (AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in + die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen. +- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die + Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein + beliebiges Konto hängen. Er verfällt nach 30 Minuten. +- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der + enthält das Client-Secret oft im Echo. +- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder + Termininhalte. + ## [0.2.0] – 2026-09-10 ### Hinzugefügt diff --git a/README.md b/README.md index d4345ea..a77dcea 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben wird. -> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben, -> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb. -> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen -> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt. +> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der +> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das +> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der +> [Fahrplan](ROADMAP.md) sagt, was wann folgt. - **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone` zur ersten Anmeldung @@ -28,6 +28,8 @@ wird. nach Dringlichkeit und filterbar über die Adresszeile - **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar - **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt +- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je + Bereich, wie voll der heutige Tag schon ist - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird @@ -38,23 +40,24 @@ wird. ### Geplant -Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen, +Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). ## Bildergalerie -| | Hell | Dunkel | -| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | -| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | -| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | -| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | -| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | -| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | -| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | -| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | -| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | +| | Hell | Dunkel | +| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | +| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | +| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | +| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | +| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | +| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | +| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | +| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | +| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | +| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten. @@ -117,6 +120,19 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example). OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der Rückleit-URI beim Anbieter lautet `/api/auth/callback/oidc`. +### Kalenderanbieter + +| Variable | Standard | Bedeutung | +| ---------------------- | --------------- | ------------------------------------------------------------- | +| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID | +| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung | +| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant | +| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console | +| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients | + +Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind — +und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`). + ### Einrichtung und Sonstiges | Variable | Standard | Bedeutung | @@ -126,6 +142,59 @@ Rückleit-URI beim Anbieter lautet `/api/auth/callback/oidc`. | `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen | | `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` | +## Kalenderanbieter einrichten + +Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und** +Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite +Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere +Berechtigung von Anfang an. + +### Microsoft 365 (Entra ID) + +1. → **Identität → Anwendungen → + App-Registrierungen → Neue Registrierung**. +2. Name frei wählen, etwa „Planer". +3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis** + (dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder + **Konten in einem beliebigen Organisationsverzeichnis** (dann + `organizations` belassen). +4. Umleitungs-URI, Plattform **Web**: + `/api/kalender/rueckleitung/graph` +5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`, + **Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`. +6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert + sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt. +7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph → + Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`, + `openid`, `email`. + +> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein +> Administrator einmalig **Administratorzustimmung erteilen** klicken. + +### Google Kalender + +1. → Projekt anlegen oder wählen. +2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren. +3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt + _Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als + Testbenutzer. +4. Bereiche (Scopes) hinzufügen: + `.../auth/calendar.events` und `.../auth/calendar.readonly`. +5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**, + Anwendungstyp **Webanwendung**. +6. Autorisierter Weiterleitungs-URI: + `/api/kalender/rueckleitung/google` +7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und + `GOOGLE_CLIENT_SECRET`. + +> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach +> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den +> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**. + +Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender** +verbinden. Die Anleitung für den Alltag steht im +[Handbuch](docs/handbuch/kalenderkonten.md). + ## Betrieb ### Starten und stoppen diff --git a/ROADMAP.md b/ROADMAP.md index b434203..61427dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. | --------------------------------------- | ---------------------------- | -------- | | [M1](#m1--fundament) | Fundament | erledigt | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | -| [M3](#m3--kalender-lesen) | Kalender lesen | geplant | +| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M6](#m6--integrationen) | Integrationen | geplant | @@ -63,14 +63,16 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt. ## M3 – Kalender lesen +**Erledigt am 10.09.2026 mit Version 0.3.0.** + **Ziel:** Termine aus Microsoft 365 und Google sehen. -- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis -- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim -- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft -- [ ] Ganztägige Termine zählen nicht zur Belegung -- [ ] Anonymisierte Beispielantworten in `test/fixtures/` -- [ ] Hintergrundjob mit node-cron +- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis +- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim +- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft +- [x] Ganztägige Termine zählen nicht zur Belegung +- [x] Anonymisierte Beispielantworten in `test/fixtures/` +- [x] Hintergrundjob mit node-cron ## M4 – Wochenplanung und Zeitblöcke diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index 5656970..2b0c402 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -9,7 +9,98 @@ die Übersicht aus `scripts/zeit.sh summe`. | ----------- | ------ | ------- | -------- | | M1 | claude | 161 | 2,68 | | M2 | claude | 44 | 0,73 | -| **Gesamt** | | **205** | **3,42** | +| M3 | claude | 41 | 0,68 | +| **Gesamt** | | **246** | **4,10** | + +--- + +## 2026-09-10 – M3: Kalender lesen + +**Dauer:** 41 Minuten (17:48–18:29), Art `claude`. + +**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der +Planer weiß, wann überhaupt Platz ist. + +### Erledigt + +- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein + Standard-Kalenderkonto für M4 +- Tokenverschlüsselung mit AES-256-GCM +- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form + gebracht, geprüft gegen anonymisierte Beispielantworten +- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4 +- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf + einen vollständigen Durchlauf +- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite +- Hintergrundjobs für Abgleich und nächtliches Aufräumen +- 68 neue Unit-Tests, 10 neue Integrationsprüfungen +- Handbuchseite und Einrichtungsanleitung für Entra ID und Google + +### Entscheidungen mit Begründung + +**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die +Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf +fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die +nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere +Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet +nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss +die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig. + +**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und +Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an; +kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine +um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein +Kalender, der leise falsch liegt. + +**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel +blockiert als eine Doppelbuchung. + +**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt. +Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung +scheitert — einmal breiter fragen ist das kleinere Übel. + +**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder +Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit +kostet. + +**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit +ohnehin, gleich zu welchem Lebensbereich er gehört. + +**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und +die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem +Abhängigkeiten und blinde Flecken. + +### Probleme und Lösungen + +| Problem | Lösung | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. | +| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. | +| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. | +| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. | + +### Offene Punkte + +- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für + Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte + Beispielantworten geprüft; den ersten echten Verbindungsversuch muss + Florian machen. Die Anleitung dafür steht in der README. +- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind + im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche. +- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten + wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das + einfachere Verhalten. + +### Commits + +- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine +- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen +- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google +- 0c67b73 feat(kalender): Seite für die Kalenderkonten +- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen +- d8d3227 feat(kalender): Belegung des Tages auf der Startseite +- df50627 test: Verschlüsselung und Kalendermodelle absichern +- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter --- diff --git a/devlog/zeiten.csv b/devlog/zeiten.csv index 198eb51..0a5c029 100644 --- a/devlog/zeiten.csv +++ b/devlog/zeiten.csv @@ -1,3 +1,4 @@ datum;start;ende;minuten;meilenstein;art;notiz 2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt 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 diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png index 376dd5e..bc400aa 100644 Binary files a/docs/bilder/anmelden-dunkel-handy.png and b/docs/bilder/anmelden-dunkel-handy.png differ diff --git a/docs/bilder/anmelden-dunkel-schreibtisch.png b/docs/bilder/anmelden-dunkel-schreibtisch.png index 4747200..4ca28d5 100644 Binary files a/docs/bilder/anmelden-dunkel-schreibtisch.png and b/docs/bilder/anmelden-dunkel-schreibtisch.png differ diff --git a/docs/bilder/anmelden-hell-handy.png b/docs/bilder/anmelden-hell-handy.png index 834909f..e47c6cf 100644 Binary files a/docs/bilder/anmelden-hell-handy.png and b/docs/bilder/anmelden-hell-handy.png differ diff --git a/docs/bilder/anmelden-hell-schreibtisch.png b/docs/bilder/anmelden-hell-schreibtisch.png index d45f1a2..5429f54 100644 Binary files a/docs/bilder/anmelden-hell-schreibtisch.png and b/docs/bilder/anmelden-hell-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-dunkel-handy.png b/docs/bilder/aufgaben-dunkel-handy.png index 961130e..065727b 100644 Binary files a/docs/bilder/aufgaben-dunkel-handy.png and b/docs/bilder/aufgaben-dunkel-handy.png differ diff --git a/docs/bilder/aufgaben-dunkel-schreibtisch.png b/docs/bilder/aufgaben-dunkel-schreibtisch.png index 554a4fe..a43f2e8 100644 Binary files a/docs/bilder/aufgaben-dunkel-schreibtisch.png and b/docs/bilder/aufgaben-dunkel-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-hell-handy.png b/docs/bilder/aufgaben-hell-handy.png index 130aef1..bad5cbd 100644 Binary files a/docs/bilder/aufgaben-hell-handy.png and b/docs/bilder/aufgaben-hell-handy.png differ diff --git a/docs/bilder/aufgaben-hell-schreibtisch.png b/docs/bilder/aufgaben-hell-schreibtisch.png index 1da70b4..e6d72d5 100644 Binary files a/docs/bilder/aufgaben-hell-schreibtisch.png and b/docs/bilder/aufgaben-hell-schreibtisch.png differ diff --git a/docs/bilder/bereiche-dunkel-handy.png b/docs/bilder/bereiche-dunkel-handy.png index b0f079a..bb6fbf7 100644 Binary files a/docs/bilder/bereiche-dunkel-handy.png and b/docs/bilder/bereiche-dunkel-handy.png differ diff --git a/docs/bilder/bereiche-dunkel-schreibtisch.png b/docs/bilder/bereiche-dunkel-schreibtisch.png index 1ba6bc5..724f77a 100644 Binary files a/docs/bilder/bereiche-dunkel-schreibtisch.png and b/docs/bilder/bereiche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/bereiche-hell-handy.png b/docs/bilder/bereiche-hell-handy.png index d7e9245..617e703 100644 Binary files a/docs/bilder/bereiche-hell-handy.png and b/docs/bilder/bereiche-hell-handy.png differ diff --git a/docs/bilder/bereiche-hell-schreibtisch.png b/docs/bilder/bereiche-hell-schreibtisch.png index 45de07b..e3cbef2 100644 Binary files a/docs/bilder/bereiche-hell-schreibtisch.png and b/docs/bilder/bereiche-hell-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-dunkel-handy.png b/docs/bilder/einstellungen-dunkel-handy.png index 550673d..2ec1dc4 100644 Binary files a/docs/bilder/einstellungen-dunkel-handy.png and b/docs/bilder/einstellungen-dunkel-handy.png differ diff --git a/docs/bilder/einstellungen-dunkel-schreibtisch.png b/docs/bilder/einstellungen-dunkel-schreibtisch.png index c0dfe29..18370d0 100644 Binary files a/docs/bilder/einstellungen-dunkel-schreibtisch.png and b/docs/bilder/einstellungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-hell-handy.png b/docs/bilder/einstellungen-hell-handy.png index ed6900f..17f0c91 100644 Binary files a/docs/bilder/einstellungen-hell-handy.png and b/docs/bilder/einstellungen-hell-handy.png differ diff --git a/docs/bilder/einstellungen-hell-schreibtisch.png b/docs/bilder/einstellungen-hell-schreibtisch.png index 69295a0..e12f223 100644 Binary files a/docs/bilder/einstellungen-hell-schreibtisch.png and b/docs/bilder/einstellungen-hell-schreibtisch.png differ diff --git a/docs/bilder/ideen-dunkel-handy.png b/docs/bilder/ideen-dunkel-handy.png index 70dc760..df9ed8e 100644 Binary files a/docs/bilder/ideen-dunkel-handy.png and b/docs/bilder/ideen-dunkel-handy.png differ diff --git a/docs/bilder/ideen-dunkel-schreibtisch.png b/docs/bilder/ideen-dunkel-schreibtisch.png index c9f2ec6..3af0300 100644 Binary files a/docs/bilder/ideen-dunkel-schreibtisch.png and b/docs/bilder/ideen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/ideen-hell-handy.png b/docs/bilder/ideen-hell-handy.png index 0957715..16219ff 100644 Binary files a/docs/bilder/ideen-hell-handy.png and b/docs/bilder/ideen-hell-handy.png differ diff --git a/docs/bilder/ideen-hell-schreibtisch.png b/docs/bilder/ideen-hell-schreibtisch.png index cbc5804..d87b84d 100644 Binary files a/docs/bilder/ideen-hell-schreibtisch.png and b/docs/bilder/ideen-hell-schreibtisch.png differ diff --git a/docs/bilder/inbox-dunkel-handy.png b/docs/bilder/inbox-dunkel-handy.png index 793c1ec..d74215a 100644 Binary files a/docs/bilder/inbox-dunkel-handy.png and b/docs/bilder/inbox-dunkel-handy.png differ diff --git a/docs/bilder/inbox-dunkel-schreibtisch.png b/docs/bilder/inbox-dunkel-schreibtisch.png index ad93bce..a03a3cf 100644 Binary files a/docs/bilder/inbox-dunkel-schreibtisch.png and b/docs/bilder/inbox-dunkel-schreibtisch.png differ diff --git a/docs/bilder/inbox-hell-handy.png b/docs/bilder/inbox-hell-handy.png index a50c8cd..94ad907 100644 Binary files a/docs/bilder/inbox-hell-handy.png and b/docs/bilder/inbox-hell-handy.png differ diff --git a/docs/bilder/inbox-hell-schreibtisch.png b/docs/bilder/inbox-hell-schreibtisch.png index d953d04..7ee1624 100644 Binary files a/docs/bilder/inbox-hell-schreibtisch.png and b/docs/bilder/inbox-hell-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-handy.png b/docs/bilder/kalenderkonten-dunkel-handy.png new file mode 100644 index 0000000..097f083 Binary files /dev/null and b/docs/bilder/kalenderkonten-dunkel-handy.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png new file mode 100644 index 0000000..e2028bd Binary files /dev/null and b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-hell-handy.png b/docs/bilder/kalenderkonten-hell-handy.png new file mode 100644 index 0000000..931a8cc Binary files /dev/null and b/docs/bilder/kalenderkonten-hell-handy.png differ diff --git a/docs/bilder/kalenderkonten-hell-schreibtisch.png b/docs/bilder/kalenderkonten-hell-schreibtisch.png new file mode 100644 index 0000000..cd2c795 Binary files /dev/null and b/docs/bilder/kalenderkonten-hell-schreibtisch.png differ diff --git a/docs/bilder/projekte-dunkel-handy.png b/docs/bilder/projekte-dunkel-handy.png index 75587c9..3411486 100644 Binary files a/docs/bilder/projekte-dunkel-handy.png and b/docs/bilder/projekte-dunkel-handy.png differ diff --git a/docs/bilder/projekte-dunkel-schreibtisch.png b/docs/bilder/projekte-dunkel-schreibtisch.png index fef55a3..1ce8249 100644 Binary files a/docs/bilder/projekte-dunkel-schreibtisch.png and b/docs/bilder/projekte-dunkel-schreibtisch.png differ diff --git a/docs/bilder/projekte-hell-handy.png b/docs/bilder/projekte-hell-handy.png index 0ff21b7..cd4a172 100644 Binary files a/docs/bilder/projekte-hell-handy.png and b/docs/bilder/projekte-hell-handy.png differ diff --git a/docs/bilder/projekte-hell-schreibtisch.png b/docs/bilder/projekte-hell-schreibtisch.png index 1120ee6..3654f03 100644 Binary files a/docs/bilder/projekte-hell-schreibtisch.png and b/docs/bilder/projekte-hell-schreibtisch.png differ diff --git a/docs/bilder/protokoll-dunkel-handy.png b/docs/bilder/protokoll-dunkel-handy.png index aa8e77c..f004751 100644 Binary files a/docs/bilder/protokoll-dunkel-handy.png and b/docs/bilder/protokoll-dunkel-handy.png differ diff --git a/docs/bilder/protokoll-dunkel-schreibtisch.png b/docs/bilder/protokoll-dunkel-schreibtisch.png index 6888a50..e5ce738 100644 Binary files a/docs/bilder/protokoll-dunkel-schreibtisch.png and b/docs/bilder/protokoll-dunkel-schreibtisch.png differ diff --git a/docs/bilder/protokoll-hell-handy.png b/docs/bilder/protokoll-hell-handy.png index cbf803b..16ba490 100644 Binary files a/docs/bilder/protokoll-hell-handy.png and b/docs/bilder/protokoll-hell-handy.png differ diff --git a/docs/bilder/protokoll-hell-schreibtisch.png b/docs/bilder/protokoll-hell-schreibtisch.png index 5177389..2348930 100644 Binary files a/docs/bilder/protokoll-hell-schreibtisch.png and b/docs/bilder/protokoll-hell-schreibtisch.png differ diff --git a/docs/bilder/start-dunkel-handy.png b/docs/bilder/start-dunkel-handy.png index d1f2c8e..3e78f6f 100644 Binary files a/docs/bilder/start-dunkel-handy.png and b/docs/bilder/start-dunkel-handy.png differ diff --git a/docs/bilder/start-dunkel-schreibtisch.png b/docs/bilder/start-dunkel-schreibtisch.png index 1ddc1d8..bafd19a 100644 Binary files a/docs/bilder/start-dunkel-schreibtisch.png and b/docs/bilder/start-dunkel-schreibtisch.png differ diff --git a/docs/bilder/start-hell-handy.png b/docs/bilder/start-hell-handy.png index 420d8e2..60177d2 100644 Binary files a/docs/bilder/start-hell-handy.png and b/docs/bilder/start-hell-handy.png differ diff --git a/docs/bilder/start-hell-schreibtisch.png b/docs/bilder/start-hell-schreibtisch.png index aa47097..eb6ab12 100644 Binary files a/docs/bilder/start-hell-schreibtisch.png and b/docs/bilder/start-hell-schreibtisch.png differ diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md index fdbb49e..09cc555 100644 --- a/docs/handbuch/README.md +++ b/docs/handbuch/README.md @@ -16,10 +16,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema. 4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist 5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit Fortschritt +6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google + verbinden, Belegung verstehen ## Verwaltung -6. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat +7. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat ## Was noch kommt @@ -27,7 +29,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen: | Thema | Meilenstein | | ------------------------------- | ----------- | -| Kalenderkonten verbinden | M3 | | Wochenplanung und Zeitblöcke | M4 | | Benachrichtigungen | M5 | | Integrationen | M6 | diff --git a/docs/handbuch/kalenderkonten.md b/docs/handbuch/kalenderkonten.md new file mode 100644 index 0000000..3902d56 --- /dev/null +++ b/docs/handbuch/kalenderkonten.md @@ -0,0 +1,116 @@ +# Kalenderkonten verbinden + +Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**. +Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück. + +![Die Seite Kalenderkonten](../bilder/kalenderkonten-hell-schreibtisch.png) + +## Was der Planer liest — und was nicht + +| Liest er | Nutzt er wofür | +| --------------------------------- | ------------------------------------------------ | +| Beginn und Ende | Belegung gegen die Tageskapazität | +| Betreff | Anzeige in der Wochenansicht | +| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung | +| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht | + +**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte. +Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie +landen aber **niemals in Protokollen**. + +Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus +diesem Fenster herauswandert, wird nachts wieder entfernt. + +## Einen Kalender verbinden + +1. Im Menü auf **Kalender**. +2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**. +3. Beim Anbieter anmelden und der Berechtigung zustimmen. +4. Du landest wieder hier, und der erste Abgleich läuft sofort. + +> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4 +> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte +> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau +> daran scheitert Einrichtung erfahrungsgemäß. + +### Welche Anbieter es gibt + +Das hängt an der Instanz und ist nicht verhandelbar: + +| | Firma | Daheim | +| ------------- | -------- | ------ | +| Microsoft 365 | ja | ja | +| Google | **nein** | ja | + +In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort +mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler. + +## Der Abgleich + +Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen** +stößt du ihn von Hand an. + +Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht +jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer +Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer +einem etwas längeren Abgleich nichts. + +## Die Belegung + +Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist: +Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses +Bereichs. + +> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die +> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört. +> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die +> Firma tun. + +Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin +um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um +17 Uhr endet. + +Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur +einmal. + +## Ein Konto umbenennen + +Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich +besser als die Mailadresse, gerade wenn du mehrere Konten desselben +Anbieters hast. + +## Ein Konto trennen + +Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei — +**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4 +ohnehin nur gelesen. + +Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen: + +- Microsoft: → _Zugriff verwalten_ +- Google: + +## Wenn es klemmt + +| Was du siehst | Was dahintersteckt | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. | +| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. | +| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. | +| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. | +| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. | +| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. | + +## Sicherheit + +Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM). +Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange +er nicht auch `AUTH_SECRET` hat. + +Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden +werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen +ungültig. + +Für die Einrichtung auf Serverseite — was in Entra ID und in der Google +Cloud Console anzulegen ist — siehe +[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten). diff --git a/e2e/aufgaben.spec.ts b/e2e/aufgaben.spec.ts index 75c6324..89fe0af 100644 --- a/e2e/aufgaben.spec.ts +++ b/e2e/aufgaben.spec.ts @@ -72,6 +72,11 @@ test.describe('Aufgaben', () => { await expect(inListe).toBeVisible() await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click() + // Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine + // Server Action an, und ohne diesen Haltepunkt navigiert der Test + // gelegentlich schneller, als gespeichert wird. + await expect(page.getByText('ist jetzt erledigt')).toBeVisible() + // Erledigtes fällt aus der Standardansicht heraus. await page.goto('/aufgaben') await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index 2e52055..de91e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "planer", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planer", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", diff --git a/package.json b/package.json index a5abede..f696d25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "planer", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "type": "module", diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts index 068b213..8b960f7 100644 --- a/prisma/demo-seed.ts +++ b/prisma/demo-seed.ts @@ -327,6 +327,52 @@ async function main(): Promise { }) } + // --- Kalenderkonto und erfundene Termine --------------------------------- + // Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit + // keinem Anbieter, sie soll die Seite nur nicht leer zeigen. + await db.kalenderkonto.deleteMany({}) + const kalenderkonto = await db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: 'Firma Outlook', + extKontoId: 'martina.beispiel@firma.invalid', + letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000), + }, + }) + + const TERMINE = [ + { betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 }, + { betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 }, + { betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true }, + { betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 }, + ] + + for (const eintrag of TERMINE) { + await db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`, + betreff: eintrag.betreff, + beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)), + ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)), + zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT', + }, + }) + } + + await db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: 'demo-brueckentag', + betreff: 'Brückentag', + beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)), + ende: new Date(Date.UTC(2026, 2, 21, 0, 0)), + ganztaegig: true, + zeigtAls: 'ABWESEND', + }, + }) + // Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste. await db.aufgabe.create({ data: { diff --git a/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql new file mode 100644 index 0000000..91ce289 --- /dev/null +++ b/prisma/migrations/20260910155036_kalenderkonten_und_termine/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE'); + +-- CreateEnum +CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT'); + +-- AlterTable +ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT; + +-- CreateTable +CREATE TABLE "kalenderkonto" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "anbieter" "KalenderAnbieter" NOT NULL, + "anzeigename" TEXT NOT NULL, + "extKontoId" TEXT NOT NULL, + "kalenderId" TEXT, + "zugriffstoken" TEXT, + "aktualisierungstoken" TEXT, + "tokenGueltigBis" TIMESTAMP(3), + "bereiche" TEXT, + "deltaVerweis" TEXT, + "letzterAbgleich" TIMESTAMP(3), + "letzterFehler" TEXT, + "aktiv" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "termin" ( + "id" TEXT NOT NULL, + "kalenderkontoId" TEXT NOT NULL, + "extId" TEXT NOT NULL, + "etag" TEXT, + "betreff" TEXT, + "beginn" TIMESTAMP(3) NOT NULL, + "ende" TIMESTAMP(3) NOT NULL, + "ganztaegig" BOOLEAN NOT NULL DEFAULT false, + "zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT', + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "termin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv"); + +-- CreateIndex +CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId"); + +-- CreateIndex +CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn"); + +-- CreateIndex +CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende"); + +-- CreateIndex +CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId"); + +-- AddForeignKey +ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql new file mode 100644 index 0000000..c915eca --- /dev/null +++ b/prisma/migrations/20260910155856_kalenderid_verpflichtend/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL, +ALTER COLUMN "kalenderId" SET DEFAULT 'standard'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38e858b..8c3c8d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,9 +31,10 @@ model Benutzer { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - konten Konto[] - passkeys Passkey[] - audit AuditEintrag[] + konten Konto[] + passkeys Passkey[] + audit AuditEintrag[] + kalenderkonten Kalenderkonto[] @@index([aktiv]) @@map("benutzer") @@ -121,6 +122,10 @@ model Bereich { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt + /// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden. + standardKalenderkontoId String? + standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull) + aufgaben Aufgabe[] projekte Projekt[] @@ -252,3 +257,86 @@ model AuditEintrag { @@index([benutzerId]) @@map("audit_eintrag") } + +enum KalenderAnbieter { + GRAPH + GOOGLE +} + +/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr. +enum ZeigtAls { + FREI + GEBUCHT + MIT_VORBEHALT + ABWESEND + WOANDERS + UNBEKANNT +} + +/// Ein verbundener Kalender bei Microsoft Graph oder Google. +model Kalenderkonto { + id String @id @default(cuid()) + benutzerId String + anbieter KalenderAnbieter + /// Frei waehlbare Bezeichnung, z. B. "Firma Outlook". + anzeigename String + /// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen. + extKontoId String + /// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID. + /// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional, + /// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst. + kalenderId String @default("standard") + + /// Verschluesselt abgelegt, siehe src/server/krypto.ts. + zugriffstoken String? + aktualisierungstoken String? + tokenGueltigBis DateTime? + /// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte. + bereiche String? + + /// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten. + deltaVerweis String? + letzterAbgleich DateTime? + /// Klartextmeldung des letzten Fehlschlags, ohne Tokens. + letzterFehler String? + + aktiv Boolean @default(true) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + termine Termin[] + standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + + /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. + @@unique([benutzerId, anbieter, extKontoId, kalenderId]) + @@index([benutzerId, aktiv]) + @@map("kalenderkonto") +} + +/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die +/// Belegungsrechnung und die Wochenansicht gebraucht wird. +model Termin { + id String @id @default(cuid()) + kalenderkontoId String + /// Kennung des Termins beim Anbieter. + extId String + /// Fuer bedingte Schreibzugriffe ab M4 (If-Match). + etag String? + + betreff String? + beginn DateTime + ende DateTime + ganztaegig Boolean @default(false) + zeigtAls ZeigtAls @default(GEBUCHT) + + aktualisiertAm DateTime @updatedAt + + kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade) + + @@unique([kalenderkontoId, extId]) + @@index([kalenderkontoId, beginn]) + @@index([beginn, ende]) + @@map("termin") +} diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts index a55d80b..9f70913 100644 --- a/screenshots/bilder.spec.ts +++ b/screenshots/bilder.spec.ts @@ -34,6 +34,7 @@ const AUFNAHMEN: Aufnahme[] = [ { name: 'projekte', pfad: '/projekte' }, { name: 'ideen', pfad: '/ideen' }, { name: 'bereiche', pfad: '/bereiche' }, + { name: 'kalenderkonten', pfad: '/kalenderkonten' }, { name: 'einstellungen', pfad: '/einstellungen' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' }, ] diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 5dc9217..84f6055 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -50,6 +50,9 @@ async function aufraeumen(): Promise { await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } }) await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } }) await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } }) + // Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test + // vorher abbricht, hier trotzdem noch einmal aufräumen. + await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } }) await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.webauthnChallenge.deleteMany({ where: { kennung } }) @@ -146,6 +149,102 @@ async function main(): Promise { }), ) + // --- Kalenderkonto und Termine ------------------------------------------ + const kalenderkonto = await db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Firma Outlook`, + extKontoId: `${kennung}-konto`, + }, + }) + pruefe( + 'Kalenderkonto zeigt im Zweifel auf den Standardkalender', + kalenderkonto.kalenderId === 'standard', + ) + pruefe( + 'Ein frisches Konto hat noch keinen Abgleichverweis', + kalenderkonto.deltaVerweis === null, + ) + + await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () => + db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Doppelt`, + extKontoId: `${kennung}-konto`, + }, + }), + ) + + // Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung. + const zweiterKalender = await db.kalenderkonto.create({ + data: { + benutzerId: benutzer.id, + anbieter: 'GRAPH', + anzeigename: `${kennung} Zweiter Kalender`, + extKontoId: `${kennung}-konto`, + kalenderId: 'AAMkAD-zweiter', + }, + }) + pruefe( + 'Dasselbe Konto mit anderem Kalender ist erlaubt', + zweiterKalender.id !== kalenderkonto.id, + ) + + const termin = await db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + betreff: 'Wochenbesprechung', + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }) + pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false) + + await scheitert('Derselbe Termin je Konto nur einmal', () => + db.termin.create({ + data: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }), + ) + + // So arbeitet der Abgleich: upsert statt anlegen. + const gewandelterTermin = await db.termin.upsert({ + where: { + kalenderkontoId_extId: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + }, + }, + update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' }, + create: { + kalenderkontoId: kalenderkonto.id, + extId: `${kennung}-termin`, + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T09:00:00Z'), + }, + }) + pruefe( + 'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln', + gewandelterTermin.id === termin.id && + gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' && + (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1, + ) + + // --- Standard-Kalenderkonto eines Bereichs ------------------------------- + await db.bereich.update({ + where: { id: bereich.id }, + data: { standardKalenderkontoId: kalenderkonto.id }, + }) + await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } }) + // --- Audit überlebt den Benutzer ---------------------------------------- const audit = await db.auditEintrag.create({ data: { @@ -177,6 +276,19 @@ async function main(): Promise { 'Verknüpfte Konten verschwinden mit dem Benutzer', (await db.konto.count({ where: { providerAccountId: kennung } })) === 0, ) + pruefe( + 'Kalenderkonten verschwinden mit dem Benutzer', + (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, + ) + pruefe( + 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', + (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, + ) + pruefe( + 'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos', + (await db.bereich.findUnique({ where: { id: bereich.id } })) + ?.standardKalenderkontoId === null, + ) // --- Aufgaben: Standardwerte und Inbox ----------------------------------- const aufgabe = await db.aufgabe.create({ diff --git a/src/app/(app)/kalenderkonten/konto-karte.tsx b/src/app/(app)/kalenderkonten/konto-karte.tsx new file mode 100644 index 0000000..ff11214 --- /dev/null +++ b/src/app/(app)/kalenderkonten/konto-karte.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Loader2, RefreshCw, Unplug } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { + jetztAbgleichen, + kontoTrennen, + kontoUmbenennen, + type Rueckmeldung, +} from '@/server/aktionen/kalenderkonten' + +export type KontoAnzeige = { + id: string + anbieter: 'GRAPH' | 'GOOGLE' + anbieterName: string + anzeigename: string + extKontoId: string + termine: number + letzterAbgleich: string | null + letzterFehler: string | null +} + +export function KontoKarte({ konto }: { konto: KontoAnzeige }) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [name, setName] = useState(konto.anzeigename) + const [trennenBestaetigt, setTrennenBestaetigt] = useState(false) + + function mitRueckmeldung(tun: () => Promise) { + starte(async () => { + const antwort = await tun() + if (antwort.ok) { + toast.success(antwort.meldung) + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + } + + function felder(werte: Record): FormData { + const daten = new FormData() + for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert) + return daten + } + + return ( + + + {konto.anzeigename} + {konto.anbieterName} + {konto.letzterFehler && Fehler} + + + +
+
Konto
+
{konto.extKontoId}
+
Gespiegelte Termine
+
{konto.termine}
+
Letzter Abgleich
+
{konto.letzterAbgleich ?? 'noch nie'}
+
+ + {konto.letzterFehler && ( +

+ {konto.letzterFehler} +

+ )} + +
+
+ + setName(e.target.value)} + maxLength={60} + disabled={laeuft} + /> +
+ +
+ +
+ + + +
+ + {trennenBestaetigt && ( +

+ Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles + unangetastet. +

+ )} +
+
+ ) +} diff --git a/src/app/(app)/kalenderkonten/meldung.tsx b/src/app/(app)/kalenderkonten/meldung.tsx new file mode 100644 index 0000000..8ac2ea5 --- /dev/null +++ b/src/app/(app)/kalenderkonten/meldung.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +/** + * Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort + * hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem + * Neuladen erneut. + */ +export function Meldung({ + ok, + fehler, +}: { + ok?: string | undefined + fehler?: string | undefined +}) { + const router = useRouter() + const gezeigt = useRef(false) + + useEffect(() => { + if (gezeigt.current) return + if (!ok && !fehler) return + + gezeigt.current = true + if (ok) toast.success(ok) + if (fehler) toast.error(fehler) + router.replace('/kalenderkonten') + }, [ok, fehler, router]) + + return null +} diff --git a/src/app/(app)/kalenderkonten/page.tsx b/src/app/(app)/kalenderkonten/page.tsx new file mode 100644 index 0000000..d124171 --- /dev/null +++ b/src/app/(app)/kalenderkonten/page.tsx @@ -0,0 +1,140 @@ +import type { Metadata } from 'next' +import { CalendarDays, Plus } from 'lucide-react' +import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte' +import { Meldung } from '@/app/(app)/kalenderkonten/meldung' +import { Button } from '@/components/ui/button' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' +import { erlaubteKalenderAnbieter } from '@/server/instanz' +import { umgebung } from '@/server/umgebung' + +export const metadata: Metadata = { title: 'Kalenderkonten' } + +const ANBIETER_NAME: Record = { + GRAPH: 'Microsoft 365', + GOOGLE: 'Google', +} + +export default async function KalenderkontenSeite({ + searchParams, +}: { + searchParams: Promise<{ ok?: string; fehler?: string }> +}) { + const benutzer = await verlangeAnmeldung('/kalenderkonten') + const parameter = await searchParams + const einst = umgebung() + + // Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie + // benutzbar sind, hängt zusätzlich an der Konfiguration. + const erlaubt = erlaubteKalenderAnbieter() + const verfuegbar = erlaubt.filter((anbieter) => + anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv, + ) + const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a)) + + const konten = await db.kalenderkonto.findMany({ + where: { benutzerId: benutzer.id }, + orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }], + select: { + id: true, + anbieter: true, + anzeigename: true, + extKontoId: true, + letzterAbgleich: true, + letzterFehler: true, + _count: { select: { termine: true } }, + }, + }) + + return ( +
+
+

+ + Kalenderkonten +

+

+ Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4 + schreibt er Zeitblöcke in denselben Kalender zurück. +

+
+ + + + + + Kalender verbinden + + Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück. + Der Planer speichert keine Zugangsdaten im Klartext. + + + + {verfuegbar.length === 0 ? ( +

+ In dieser Instanz ist kein Kalenderanbieter eingerichtet. +

+ ) : ( +
+ {verfuegbar.map((anbieter) => ( + + ))} +
+ )} + + {fehlendKonfiguriert.length > 0 && ( +

+ {fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier + erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt + Umgebungsvariablen. +

+ )} +
+
+ +
+

Verbundene Konten

+ + {konten.length === 0 ? ( + + + Noch kein Kalender verbunden. + + + ) : ( + konten.map((konto) => ( + + )) + )} +
+
+ ) +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 164dcd7..bfb27b8 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste' import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung' +import { Fortschrittsbalken } from '@/components/fortschrittsbalken' import { Card, CardBeschreibung, @@ -16,6 +17,7 @@ import { projekteZurAuswahlLaden, zaehlstaende, } from '@/server/abfragen/aufgaben' +import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender' import { verlangeAnmeldung } from '@/server/berechtigung' import { db } from '@/server/db' import { jetzt } from '@/server/zeitgeber' @@ -24,16 +26,19 @@ export default async function StartSeite() { const benutzer = await verlangeAnmeldung('/') const heute = jetzt() - const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([ - aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200), - bereicheLaden(), - projekteZurAuswahlLaden(), - zaehlstaende(), - db.bereich.findMany({ - where: { aktiv: true }, - orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], - }), - ]) + const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] = + await Promise.all([ + aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200), + bereicheLaden(), + projekteZurAuswahlLaden(), + zaehlstaende(), + db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }), + belegungHeute(benutzer.id, heute), + hatKalenderkonto(benutzer.id), + ]) // Was jetzt zählt: überfällig, heute oder morgen fällig. const dran = alleOffenen.filter( @@ -108,9 +113,10 @@ export default async function StartSeite() {
    {bereichsListe.map((bereich) => { const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id) + const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung return (
  • - + {bereich.name} - +

    {offeneHier.length === 0 ? 'Nichts offen' : `${offeneHier.length} offen`}{' '} · {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche

    + + {/* + Ohne verbundenen Kalender wäre die Belegung immer null – + das sähe nach "alles frei" aus und wäre irreführend. + */} + {mitKalender && heuteHier && ( + <> + +

    + {heuteHier.kapazitaet === 0 + ? 'Heute nicht eingeplant' + : heuteHier.ueberbucht + ? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität` + : `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`} +

    + + )}
  • diff --git a/src/app/api/kalender/rueckleitung/[anbieter]/route.ts b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts new file mode 100644 index 0000000..fd4a449 --- /dev/null +++ b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server' +import { notFound } from 'next/navigation' +import { auditSchreiben } from '@/server/audit' +import { db } from '@/server/db' +import { erzwingeFaehigkeit } from '@/server/instanz' +import { kontoAbgleichen } from '@/server/kalender/abgleich' +import { codeEinloesen } from '@/server/kalender/oauth' +import { + alsAnbieter, + faehigkeitFuer, + istAnbieter, + zustandLesen, +} from '@/server/kalender/verbinden' +import { verschluesseln } from '@/server/krypto' +import { umgebung } from '@/server/umgebung' + +export const dynamic = 'force-dynamic' + +function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse { + const ziel = new URL('/kalenderkonten', umgebung().APP_URL) + ziel.searchParams.set(art, nachricht) + return NextResponse.redirect(ziel) +} + +/** + * Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an. + * + * Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese + * Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen. + */ +export async function GET( + anfrage: Request, + { params }: { params: Promise<{ anbieter: string }> }, +) { + const { anbieter: roh } = await params + if (!istAnbieter(roh)) notFound() + + const anbieter = alsAnbieter(roh) + erzwingeFaehigkeit(faehigkeitFuer(anbieter)) + + const adresse = new URL(anfrage.url) + const fehlerAnbieter = adresse.searchParams.get('error') + if (fehlerAnbieter) { + // Meist "access_denied", weil die Zustimmung abgebrochen wurde. + return zurueck('Die Verbindung wurde abgebrochen.', 'fehler') + } + + const zustand = zustandLesen(adresse.searchParams.get('state')) + if (!zustand || zustand.anbieter !== anbieter) { + return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler') + } + + const code = adresse.searchParams.get('code') + if (!code) { + return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler') + } + + const benutzer = await db.benutzer.findUnique({ + where: { id: zustand.benutzerId }, + select: { id: true, email: true, aktiv: true }, + }) + if (!benutzer?.aktiv) { + return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler') + } + + try { + const satz = await codeEinloesen(anbieter, code) + + if (!satz.aktualisierungstoken) { + // Ohne das wäre die Verbindung nach einer Stunde tot. + return zurueck( + 'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.', + 'fehler', + ) + } + + const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto` + const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard' + + const konto = await db.kalenderkonto.upsert({ + where: { + benutzerId_anbieter_extKontoId_kalenderId: { + benutzerId: benutzer.id, + anbieter, + extKontoId, + kalenderId, + }, + }, + update: { + zugriffstoken: verschluesseln(satz.zugriffstoken), + aktualisierungstoken: verschluesseln(satz.aktualisierungstoken), + tokenGueltigBis: satz.gueltigBis, + bereiche: satz.bereiche, + aktiv: true, + letzterFehler: null, + // Neu verbunden heißt: von vorn abgleichen. + deltaVerweis: null, + }, + create: { + benutzerId: benutzer.id, + anbieter, + anzeigename: + satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'), + extKontoId, + kalenderId, + zugriffstoken: verschluesseln(satz.zugriffstoken), + aktualisierungstoken: verschluesseln(satz.aktualisierungstoken), + tokenGueltigBis: satz.gueltigBis, + bereiche: satz.bereiche, + }, + select: { id: true, anzeigename: true }, + }) + + await auditSchreiben({ + akteur: { id: benutzer.id, email: benutzer.email }, + aktion: 'kalenderkonto.verbunden', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + // Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde. + details: { anbieter, anzeigename: konto.anzeigename }, + }) + + const bericht = await kontoAbgleichen(konto.id) + + return zurueck( + bericht.ok + ? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.` + : `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`, + bericht.ok ? 'ok' : 'fehler', + ) + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.' + return zurueck(meldung, 'fehler') + } +} diff --git a/src/app/api/kalender/verbinden/[anbieter]/route.ts b/src/app/api/kalender/verbinden/[anbieter]/route.ts new file mode 100644 index 0000000..1e85795 --- /dev/null +++ b/src/app/api/kalender/verbinden/[anbieter]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { notFound } from 'next/navigation' +import { autorisierungsAdresse } from '@/server/kalender/oauth' +import { + alsAnbieter, + faehigkeitFuer, + istAnbieter, + zustandErzeugen, +} from '@/server/kalender/verbinden' +import { angemeldet } from '@/server/berechtigung' +import { erzwingeFaehigkeit } from '@/server/instanz' + +export const dynamic = 'force-dynamic' + +/** + * Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter. + * + * Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der + * Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es + * die Möglichkeit anderswo gäbe. + */ +export async function GET( + _anfrage: Request, + { params }: { params: Promise<{ anbieter: string }> }, +) { + const { anbieter: roh } = await params + if (!istAnbieter(roh)) notFound() + + const anbieter = alsAnbieter(roh) + erzwingeFaehigkeit(faehigkeitFuer(anbieter)) + + const benutzer = await angemeldet() + if (!benutzer) { + return NextResponse.redirect( + new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''), + ) + } + + try { + const zustand = zustandErzeugen(anbieter, benutzer.id) + return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand)) + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.' + return NextResponse.redirect( + new URL( + `/kalenderkonten?fehler=${encodeURIComponent(meldung)}`, + process.env.APP_URL ?? '', + ), + ) + } +} diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx index 0b8d027..f547cc3 100644 --- a/src/components/kopfleiste.tsx +++ b/src/components/kopfleiste.tsx @@ -33,6 +33,7 @@ export function Kopfleiste({ { pfad: '/projekte', name: 'Projekte' }, { pfad: '/ideen', name: 'Ideen' }, { pfad: '/bereiche', name: 'Bereiche' }, + { pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, ] diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..91c9d1b --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,15 @@ +/** + * Wird von Next einmal beim Hochfahren des Servers ausgeführt. + * + * Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch + * node-cron. Der Demo-Modus startet bewusst keine Jobs — die + * Screenshot-Instanz soll sich nicht selbst verändern, während sie + * abfotografiert wird. + */ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + if (process.env.DEMO_MODUS === 'true') return + + const { jobsStarten } = await import('@/server/jobs') + jobsStarten() +} diff --git a/src/lib/kalender/belegung.test.ts b/src/lib/kalender/belegung.test.ts new file mode 100644 index 0000000..d0acc0c --- /dev/null +++ b/src/lib/kalender/belegung.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest' +import { + arbeitsfensterAmTag, + belegteMinuten, + belegtZeit, + freieFenster, + kapazitaetAmTag, + tagesBelegung, + termineAmTag, + ueberschneidungMinuten, + type BelegenderTermin, + type BereichKapazitaet, +} from './belegung' + +// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1. +const dienstag = new Date('2026-03-17T09:00:00Z') + +const bereich: BereichKapazitaet = { + kapazitaetMo: 360, + kapazitaetDi: 240, + kapazitaetMi: 360, + kapazitaetDo: 360, + kapazitaetFr: 120, + kapazitaetSa: 0, + kapazitaetSo: 0, + // 08:00 bis 17:00 Berliner Zeit. + arbeitsfensterVon: 480, + arbeitsfensterBis: 1020, +} + +function termin( + vonUtc: string, + bisUtc: string, + teil: Partial = {}, +): BelegenderTermin { + return { + beginn: new Date(vonUtc), + ende: new Date(bisUtc), + ganztaegig: false, + zeigtAls: 'GEBUCHT', + ...teil, + } +} + +describe('belegtZeit', () => { + it('zählt einen gewöhnlichen Termin', () => { + expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true) + }) + + it('zählt ganztägige Termine nicht', () => { + // Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster. + expect( + belegtZeit( + termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }), + ), + ).toBe(false) + }) + + it('zählt als frei markierte Termine nicht', () => { + expect( + belegtZeit( + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }), + ), + ).toBe(false) + }) + + it('zählt Vorbehalt und Abwesenheit sehr wohl', () => { + for (const zeigtAls of [ + 'MIT_VORBEHALT', + 'ABWESEND', + 'WOANDERS', + 'UNBEKANNT', + ] as const) { + expect( + belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })), + ).toBe(true) + } + }) +}) + +describe('ueberschneidungMinuten', () => { + const fenster = { + von: new Date('2026-03-17T08:00:00Z'), + bis: new Date('2026-03-17T12:00:00Z'), + } + + it('rechnet eine vollständige Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('schneidet an den Rändern ab', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') }, + fenster, + ), + ).toBe(60) + }) + + it('ist ohne Überschneidung null', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') }, + fenster, + ), + ).toBe(0) + }) + + it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => { + expect( + ueberschneidungMinuten( + { von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') }, + fenster, + ), + ).toBe(0) + }) +}) + +describe('arbeitsfensterAmTag', () => { + it('legt das Fenster in Berliner Ortszeit', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + // 08:00 Berlin = 07:00 UTC in der Winterzeit. + expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('verschiebt sich mit der Sommerzeit', () => { + // Ab dem 29. März 2026 gilt in Berlin UTC+2. + const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020) + expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z') + expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z') + }) +}) + +describe('kapazitaetAmTag', () => { + it('nimmt den richtigen Wochentag', () => { + expect(kapazitaetAmTag(bereich, dienstag)).toBe(240) + // Montag, 16. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360) + // Sonntag, 22. März. + expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0) + }) +}) + +describe('belegteMinuten', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('ist ohne Termine null', () => { + expect(belegteMinuten([], fenster)).toBe(0) + }) + + it('summiert getrennte Termine', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + ], + fenster, + ), + ).toBe(180) + }) + + it('legt lückenlos aneinandergrenzende Termine zusammen', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(120) + }) + + it('zählt einen vollständig enthaltenen Termin nur einmal', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'), + termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'), + ], + fenster, + ), + ).toBe(240) + }) + + it('schneidet an den Rändern des Arbeitsfensters ab', () => { + // 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt. + expect( + belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster), + ).toBe(60) + }) + + it('lässt Termine außerhalb des Fensters ganz weg', () => { + // 20:00 Berlin, das Fenster endet um 17:00. + expect( + belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster), + ).toBe(0) + }) + + it('lässt ganztägige und freie Termine weg', () => { + expect( + belegteMinuten( + [ + termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }), + termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }), + ], + fenster, + ), + ).toBe(0) + }) +}) + +describe('tagesBelegung', () => { + it('rechnet Kapazität und Termine gegeneinander', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')], + dienstag, + ) + expect(belegung.kapazitaet).toBe(240) + expect(belegung.belegt).toBe(120) + expect(belegung.frei).toBe(120) + expect(belegung.prozent).toBe(50) + expect(belegung.ueberbucht).toBe(false) + }) + + it('erkennt einen überbuchten Tag', () => { + const belegung = tagesBelegung( + bereich, + [termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')], + dienstag, + ) + expect(belegung.belegt).toBe(360) + expect(belegung.frei).toBe(0) + expect(belegung.ueberbucht).toBe(true) + expect(belegung.prozent).toBe(150) + }) + + it('behandelt einen Tag ohne Kapazität als voll', () => { + // Sonntag: keine Kapazität, also gibt es dort nichts zu planen. + const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z')) + expect(belegung.kapazitaet).toBe(0) + expect(belegung.prozent).toBe(100) + expect(belegung.frei).toBe(0) + }) +}) + +describe('freieFenster', () => { + const fenster = arbeitsfensterAmTag(dienstag, 480, 1020) + + it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => { + const luecken = freieFenster([], fenster) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z') + }) + + it('findet die Lücke zwischen zwei Terminen', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + ) + expect(luecken).toHaveLength(1) + expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z') + }) + + it('lässt zu kurze Lücken weg', () => { + const luecken = freieFenster( + [ + termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'), + // Nur zehn Minuten Pause. + termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'), + ], + fenster, + 15, + ) + expect(luecken).toEqual([]) + }) + + it('ist bei durchgehender Belegung leer', () => { + expect( + freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster), + ).toEqual([]) + }) + + it('übergeht ganztägige Termine und findet den Tag frei', () => { + const luecken = freieFenster( + [termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })], + fenster, + ) + expect(luecken).toHaveLength(1) + }) +}) + +describe('termineAmTag', () => { + it('nimmt, was den Berliner Kalendertag berührt', () => { + const liste = [ + termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'), + ] + expect(termineAmTag(liste, dienstag)).toHaveLength(1) + }) + + it('nimmt auch einen Termin, der in den Tag hineinragt', () => { + const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z') + expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1) + }) + + it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => { + // 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag. + const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z') + expect(termineAmTag([davor], dienstag)).toEqual([]) + }) +}) diff --git a/src/lib/kalender/belegung.ts b/src/lib/kalender/belegung.ts new file mode 100644 index 0000000..94306b9 --- /dev/null +++ b/src/lib/kalender/belegung.ts @@ -0,0 +1,217 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' +import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit' + +/** + * Wie voll ein Tag schon ist. + * + * Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in + * Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer. + */ + +export type BelegenderTermin = { + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +export type Zeitfenster = { von: Date; bis: Date } + +/** + * Zählt ein Termin gegen die Kapazität? + * + * Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender + * blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet. + * Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind. + */ +export function belegtZeit(termin: BelegenderTermin): boolean { + if (termin.ganztaegig) return false + if (termin.zeigtAls === 'FREI') return false + return true +} + +/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */ +export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number { + const von = Math.max(a.von.getTime(), b.von.getTime()) + const bis = Math.min(a.bis.getTime(), b.bis.getTime()) + if (bis <= von) return 0 + return Math.round((bis - von) / 60_000) +} + +/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */ +export function arbeitsfensterAmTag( + tag: Date, + arbeitsfensterVon: number, + arbeitsfensterBis: number, +): Zeitfenster { + const beginn = tagesBeginn(tag) + return { + von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000), + bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000), + } +} + +/** + * Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine + * belegt sind. + * + * Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht + * doppelt zählt. + */ +export function belegteMinuten( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, +): number { + const abschnitte = termine + .filter(belegtZeit) + .map((t) => ({ von: t.beginn, bis: t.ende })) + .filter((t) => ueberschneidungMinuten(t, fenster) > 0) + .map((t) => ({ + von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())), + })) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + let summe = 0 + let offenVon: Date | null = null + let offenBis: Date | null = null + + for (const abschnitt of abschnitte) { + if (offenVon === null || offenBis === null) { + offenVon = abschnitt.von + offenBis = abschnitt.bis + continue + } + if (abschnitt.von.getTime() <= offenBis.getTime()) { + // Überlappt oder schließt an – zusammenlegen. + if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis + continue + } + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + offenVon = abschnitt.von + offenBis = abschnitt.bis + } + + if (offenVon !== null && offenBis !== null) { + summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000) + } + + return summe +} + +export type TagesBelegung = { + /** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */ + kapazitaet: number + /** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */ + belegt: number + /** Was von der Kapazität übrig bleibt; nie negativ. */ + frei: number + /** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */ + prozent: number + /** Mehr Termine als Kapazität – der Tag ist überbucht. */ + ueberbucht: boolean +} + +export type BereichKapazitaet = { + kapazitaetMo: number + kapazitaetDi: number + kapazitaetMi: number + kapazitaetDo: number + kapazitaetFr: number + kapazitaetSa: number + kapazitaetSo: number + arbeitsfensterVon: number + arbeitsfensterBis: number +} + +const KAPAZITAET_JE_TAG = [ + 'kapazitaetMo', + 'kapazitaetDi', + 'kapazitaetMi', + 'kapazitaetDo', + 'kapazitaetFr', + 'kapazitaetSa', + 'kapazitaetSo', +] as const + +/** Kapazität des Bereichs an genau diesem Tag. */ +export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number { + const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)] + return feld ? bereich[feld] : 0 +} + +/** + * Rechnet Kapazität und Termine eines Tages gegeneinander. + * + * Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr + * verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr + * endet. + */ +export function tagesBelegung( + bereich: BereichKapazitaet, + termine: readonly BelegenderTermin[], + tag: Date, +): TagesBelegung { + const kapazitaet = kapazitaetAmTag(bereich, tag) + const fenster = arbeitsfensterAmTag( + tag, + bereich.arbeitsfensterVon, + bereich.arbeitsfensterBis, + ) + const belegt = belegteMinuten(termine, fenster) + + return { + kapazitaet, + belegt, + frei: Math.max(0, kapazitaet - belegt), + prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100), + ueberbucht: belegt > kapazitaet, + } +} + +/** + * Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken, + * in die ab M4 Zeitblöcke gelegt werden können. + */ +export function freieFenster( + termine: readonly BelegenderTermin[], + fenster: Zeitfenster, + mindestensMinuten = 15, +): Zeitfenster[] { + const belegt = termine + .filter(belegtZeit) + .map((t) => ({ + von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())), + bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())), + })) + .filter((t) => t.bis.getTime() > t.von.getTime()) + .sort((a, b) => a.von.getTime() - b.von.getTime()) + + const luecken: Zeitfenster[] = [] + let cursor = fenster.von + + for (const abschnitt of belegt) { + if (abschnitt.von.getTime() > cursor.getTime()) { + luecken.push({ von: cursor, bis: abschnitt.von }) + } + if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis + } + + if (cursor.getTime() < fenster.bis.getTime()) { + luecken.push({ von: cursor, bis: fenster.bis }) + } + + return luecken.filter( + (l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten, + ) +} + +/** Termine, die an diesem Kalendertag überhaupt vorkommen. */ +export function termineAmTag( + termine: readonly T[], + tag: Date, +): T[] { + const von = tagesBeginn(tag).getTime() + const bis = tagesEnde(tag).getTime() + return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von) +} diff --git a/src/lib/kalender/google.test.ts b/src/lib/kalender/google.test.ts new file mode 100644 index 0000000..8c6a274 --- /dev/null +++ b/src/lib/kalender/google.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google' +import { AntwortUnbrauchbar } from './typen' + +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGoogle', () => { + it('kennt nur frei und gebucht', () => { + expect(zeigtAlsAusGoogle('transparent')).toBe('FREI') + expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT') + }) + + it('nimmt ein fehlendes Feld als gebucht', () => { + // Google lässt transparency weg, wenn der Termin die Zeit belegt. + expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT') + }) +}) + +describe('googleZeitpunkt', () => { + it('liest eine Zeit mit Versatz', () => { + const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(false) + }) + + it('erkennt ein reines Datum als ganztägig', () => { + const ergebnis = googleZeitpunkt({ date: '2026-03-20' }) + expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z') + expect(ergebnis.ganztaegig).toBe(true) + }) + + it('bricht ab, wenn beides fehlt', () => { + expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbaren Angaben ab', () => { + expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar) + expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar) + }) +}) + +describe('googleSeiteLesen', () => { + it('liest die erste Seite samt Seitenverweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite1')) + expect(seite.geaendert).toHaveLength(2) + expect(seite.weiter).toBe('BEISPIEL-SEITE-2') + expect(seite.deltaVerweis).toBeNull() + }) + + it('rechnet den Versatz nach UTC um', () => { + const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert + expect(erster?.extId).toBe('beispiel-termin-a') + expect(erster?.betreff).toBe('Zahnarzt') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + }) + + it('erkennt ganztägige Termine am reinen Datum', () => { + const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.betreff).toBe('Urlaubstag') + }) + + it('liest die letzte Seite mit dem Sync-Verweis', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1') + }) + + it('behandelt abgesagte Termine als entfernt', () => { + const seite = googleSeiteLesen(fixture('google-events-seite2')) + expect(seite.entfernt).toEqual(['beispiel-termin-d']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c']) + }) + + it('erkennt einen durchlässigen Termin als frei', () => { + const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('kommt mit einer leeren Antwort zurecht', () => { + const seite = googleSeiteLesen(fixture('google-events-leer')) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2') + }) + + it('weist eine Antwort mit falschem Aufbau ab', () => { + expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum', () => { + const seite = googleSeiteLesen({ + items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + }) +}) diff --git a/src/lib/kalender/google.ts b/src/lib/kalender/google.ts new file mode 100644 index 0000000..074333d --- /dev/null +++ b/src/lib/kalender/google.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten der Google-Kalender-API (`events.list`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. + */ + +/** + * Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime` + * (mit Versatz). Genau eines von beidem. + */ +const googleZeit = z.looseObject({ + date: z.string().optional(), + dateTime: z.string().optional(), + timeZone: z.string().optional(), +}) + +const googleTermin = z.looseObject({ + id: z.string().min(1), + etag: z.string().optional(), + status: z.string().optional(), + summary: z.string().nullable().optional(), + start: googleZeit.optional(), + end: googleZeit.optional(), + transparency: z.string().optional(), +}) + +const googleAntwort = z.looseObject({ + items: z.array(googleTermin).default([]), + nextPageToken: z.string().optional(), + nextSyncToken: z.string().optional(), +}) + +/** + * Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei, + * alles andere (auch das Fehlen des Feldes) heißt gebucht. + */ +export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls { + return transparency === 'transparent' ? 'FREI' : 'GEBUCHT' +} + +export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined } + +/** + * Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig + * war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine + * zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag. + */ +export function googleZeitpunkt(zeit: GoogleZeit): { + zeitpunkt: Date + ganztaegig: boolean +} { + if (zeit.date) { + const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: true } + } + + if (zeit.dateTime) { + const zeitpunkt = new Date(zeit.dateTime) + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar') + } + return { zeitpunkt, ganztaegig: false } + } + + throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt') +} + +/** Liest eine Antwortseite von `events.list`. */ +export function googleSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = googleAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.items) { + if (eintrag.status === 'cancelled') { + entfernt.push(eintrag.id) + continue + } + + if (!eintrag.start || !eintrag.end) continue + + const beginn = googleZeitpunkt(eintrag.start) + const ende = googleZeitpunkt(eintrag.end) + + geaendert.push({ + extId: eintrag.id, + etag: eintrag.etag ?? null, + betreff: eintrag.summary ?? null, + beginn: beginn.zeitpunkt, + ende: ende.zeitpunkt, + ganztaegig: beginn.ganztaegig, + zeigtAls: zeigtAlsAusGoogle(eintrag.transparency), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data.nextPageToken ?? null, + deltaVerweis: gelesen.data.nextSyncToken ?? null, + } +} diff --git a/src/lib/kalender/graph.test.ts b/src/lib/kalender/graph.test.ts new file mode 100644 index 0000000..34d942a --- /dev/null +++ b/src/lib/kalender/graph.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph' +import { AntwortUnbrauchbar } from './typen' + +/** Beispielantworten aus test/fixtures, anonymisiert. */ +function fixture(name: string): unknown { + return JSON.parse( + readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'), + ) as unknown +} + +describe('zeigtAlsAusGraph', () => { + it('übersetzt die bekannten Werte', () => { + expect(zeigtAlsAusGraph('free')).toBe('FREI') + expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT') + expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT') + expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND') + expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS') + }) + + it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => { + expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT') + expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT') + }) +}) + +describe('graphZeitpunkt', () => { + it('liest eine UTC-Angabe', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.0000000', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => { + expect( + graphZeitpunkt({ + dateTime: '2026-03-17T08:00:00.1234567', + timeZone: 'UTC', + }).toISOString(), + ).toBe('2026-03-17T08:00:00.123Z') + }) + + it('kommt auch ohne Nachkommastellen zurecht', () => { + expect( + graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(), + ).toBe('2026-03-17T08:00:00.000Z') + }) + + it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => { + expect(() => + graphZeitpunkt({ + dateTime: '2026-03-17T09:00:00.0000000', + timeZone: 'W. Europe Standard Time', + }), + ).toThrow(AntwortUnbrauchbar) + }) + + it('bricht bei unlesbarer Zeitangabe ab', () => { + expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow( + AntwortUnbrauchbar, + ) + }) +}) + +describe('graphSeiteLesen', () => { + it('liest die erste Seite samt Verweis auf die nächste', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite1')) + + expect(seite.geaendert).toHaveLength(2) + expect(seite.entfernt).toEqual([]) + expect(seite.weiter).toContain('$skiptoken=') + expect(seite.deltaVerweis).toBeNull() + }) + + it('übernimmt Betreff, Kennung und Etag', () => { + const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert + expect(erster?.extId).toBe('BEISPIEL-TERMIN-1') + expect(erster?.betreff).toBe('Wochenbesprechung') + expect(erster?.etag).toContain('BEISPIELETAG1') + expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z') + expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z') + expect(erster?.ganztaegig).toBe(false) + expect(erster?.zeigtAls).toBe('GEBUCHT') + }) + + it('erkennt ganztägige Termine', () => { + const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1] + expect(ganztaegig?.ganztaegig).toBe(true) + expect(ganztaegig?.zeigtAls).toBe('ABWESEND') + }) + + it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.weiter).toBeNull() + expect(seite.deltaVerweis).toContain('$deltatoken=') + }) + + it('trennt Entferntes von Geändertem', () => { + const seite = graphSeiteLesen(fixture('graph-delta-seite2')) + expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9']) + expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3']) + }) + + it('erkennt einen als frei markierten Termin', () => { + const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert + expect(frei?.zeigtAls).toBe('FREI') + }) + + it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => { + expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow( + AntwortUnbrauchbar, + ) + }) + + it('weist eine Antwort ohne value-Liste ab', () => { + expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar) + }) + + it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => { + const seite = graphSeiteLesen({ + value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }], + }) + expect(seite.geaendert).toEqual([]) + expect(seite.entfernt).toEqual([]) + }) +}) diff --git a/src/lib/kalender/graph.ts b/src/lib/kalender/graph.ts new file mode 100644 index 0000000..fc605c4 --- /dev/null +++ b/src/lib/kalender/graph.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { + AntwortUnbrauchbar, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' + +/** + * Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`). + * + * Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die + * gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die + * Beispielantworten in test/fixtures prüfbar. + */ + +/** + * Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen + * mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes + * zurück, wäre jede Umrechnung geraten – dann lieber abbrechen. + */ +const graphZeit = z.object({ + dateTime: z.string().min(1), + timeZone: z.string().min(1), +}) + +const graphTermin = z.looseObject({ + id: z.string().min(1), + '@odata.etag': z.string().optional(), + '@removed': z.looseObject({ reason: z.string().optional() }).optional(), + subject: z.string().nullable().optional(), + start: graphZeit.optional(), + end: graphZeit.optional(), + isAllDay: z.boolean().optional(), + showAs: z.string().optional(), +}) + +const graphAntwort = z.looseObject({ + value: z.array(graphTermin), + '@odata.nextLink': z.string().optional(), + '@odata.deltaLink': z.string().optional(), +}) + +/** showAs von Graph auf unsere Belegungsarten. */ +export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls { + switch (showAs) { + case 'free': + return 'FREI' + case 'busy': + return 'GEBUCHT' + case 'tentative': + return 'MIT_VORBEHALT' + case 'oof': + return 'ABWESEND' + case 'workingElsewhere': + return 'WOANDERS' + default: + return 'UNBEKANNT' + } +} + +/** + * Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt. + * + * Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet + * Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das + * schweigend zu übergehen hieße, Termine um Stunden zu verschieben. + */ +export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date { + if (zeit.timeZone.toUpperCase() !== 'UTC') { + throw new AntwortUnbrauchbar( + 'Graph', + `unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`, + ) + } + + // "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht + // Date nicht, drei genügen. + const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1') + const zeitpunkt = new Date(`${gekuerzt}Z`) + + if (Number.isNaN(zeitpunkt.getTime())) { + throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar') + } + return zeitpunkt +} + +/** Liest eine Antwortseite von `/me/calendarView/delta`. */ +export function graphSeiteLesen(inhalt: unknown): AbgleichSeite { + const gelesen = graphAntwort.safeParse(inhalt) + if (!gelesen.success) { + throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau') + } + + const geaendert: RohTermin[] = [] + const entfernt: string[] = [] + + for (const eintrag of gelesen.data.value) { + if (eintrag['@removed']) { + entfernt.push(eintrag.id) + continue + } + + // Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten. + if (!eintrag.start || !eintrag.end) continue + + geaendert.push({ + extId: eintrag.id, + etag: eintrag['@odata.etag'] ?? null, + betreff: eintrag.subject ?? null, + beginn: graphZeitpunkt(eintrag.start), + ende: graphZeitpunkt(eintrag.end), + ganztaegig: eintrag.isAllDay === true, + zeigtAls: zeigtAlsAusGraph(eintrag.showAs), + }) + } + + return { + geaendert, + entfernt, + weiter: gelesen.data['@odata.nextLink'] ?? null, + deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null, + } +} diff --git a/src/lib/kalender/typen.ts b/src/lib/kalender/typen.ts new file mode 100644 index 0000000..daa129b --- /dev/null +++ b/src/lib/kalender/typen.ts @@ -0,0 +1,56 @@ +import type { ZeigtAls } from '@/generated/prisma/enums' + +/** + * Gemeinsame Form eines Termins, unabhängig vom Anbieter. + * + * Graph und Google liefern sehr verschiedene Antworten; alles, was danach + * kommt — Belegungsrechnung, Wochenansicht, Schreibweg ab M4 — arbeitet nur + * noch mit dieser Form. + */ +export type RohTermin = { + /** Kennung beim Anbieter. */ + extId: string + /** Für bedingte Schreibzugriffe ab M4. */ + etag: string | null + betreff: string | null + beginn: Date + ende: Date + ganztaegig: boolean + zeigtAls: ZeigtAls +} + +/** + * Ergebnis einer einzelnen Abfrageseite. + * + * Genau eines von `weiter` und `deltaVerweis` ist gesetzt: solange `weiter` + * steht, gibt es weitere Seiten; kommt `deltaVerweis`, ist die Runde zu Ende + * und der Verweis wird für den nächsten Abgleich aufbewahrt. + */ +export type AbgleichSeite = { + geaendert: RohTermin[] + /** Kennungen entfernter Termine. */ + entfernt: string[] + /** Nächste Seite derselben Runde, sonst null. */ + weiter: string | null + /** Ausgangspunkt für die nächste Runde, sonst null. */ + deltaVerweis: string | null +} + +/** Fehler beim Lesen einer Anbieterantwort – Inhalt nie im Klartext ins Log. */ +export class AntwortUnbrauchbar extends Error { + constructor(anbieter: string, grund: string) { + super(`Antwort von ${anbieter} nicht verwertbar: ${grund}`) + this.name = 'AntwortUnbrauchbar' + } +} + +/** + * Der Sitzungsverweis ist abgelaufen; es muss von vorn abgeglichen werden. + * Graph antwortet dann mit 410, Google ebenfalls mit 410. + */ +export class VerweisAbgelaufen extends Error { + constructor(anbieter: string) { + super(`Der Abgleichverweis von ${anbieter} ist abgelaufen.`) + this.name = 'VerweisAbgelaufen' + } +} diff --git a/src/server/abfragen/kalender.ts b/src/server/abfragen/kalender.ts new file mode 100644 index 0000000..cce0d1d --- /dev/null +++ b/src/server/abfragen/kalender.ts @@ -0,0 +1,75 @@ +import 'server-only' +import { + tagesBelegung, + termineAmTag, + type BelegenderTermin, + type TagesBelegung, +} from '@/lib/kalender/belegung' +import { tagesBeginn, tagesEnde } from '@/lib/zeit' +import { db } from '@/server/db' +import { jetzt } from '@/server/zeitgeber' + +/** + * Abfragen rund um die gespiegelten Termine. + * + * Die Termine gehören dem Benutzer über sein Kalenderkonto; die Kennung geht + * deshalb immer in die where-Bedingung. + */ + +export type BereichMitBelegung = { + id: string + name: string + farbe: string + belegung: TagesBelegung +} + +/** Alle Termine des Benutzers, die diesen Kalendertag berühren. */ +export async function termineAmTagLaden( + benutzerId: string, + tag: Date, +): Promise { + const zeilen = await db.termin.findMany({ + where: { + kalenderkonto: { benutzerId, aktiv: true }, + beginn: { lte: tagesEnde(tag) }, + ende: { gt: tagesBeginn(tag) }, + }, + select: { beginn: true, ende: true, ganztaegig: true, zeigtAls: true }, + orderBy: { beginn: 'asc' }, + }) + return zeilen +} + +/** + * Wie voll heute in jedem Bereich schon ist. + * + * Alle Bereiche rechnen gegen dieselben Termine — der Planer weiß nicht, + * welcher Termin zu welchem Bereich gehört, und ein Termin blockiert die Zeit + * ohnehin für alles andere mit. + */ +export async function belegungHeute( + benutzerId: string, + zeitpunkt: Date = jetzt(), +): Promise { + const [bereiche, termine] = await Promise.all([ + db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }), + termineAmTagLaden(benutzerId, zeitpunkt), + ]) + + const heute = termineAmTag(termine, zeitpunkt) + + return bereiche.map((bereich) => ({ + id: bereich.id, + name: bereich.name, + farbe: bereich.farbe, + belegung: tagesBelegung(bereich, heute, zeitpunkt), + })) +} + +/** Hat der Benutzer überhaupt einen Kalender verbunden? */ +export async function hatKalenderkonto(benutzerId: string): Promise { + return (await db.kalenderkonto.count({ where: { benutzerId, aktiv: true } })) > 0 +} diff --git a/src/server/aktionen/kalenderkonten.ts b/src/server/aktionen/kalenderkonten.ts new file mode 100644 index 0000000..2301719 --- /dev/null +++ b/src/server/aktionen/kalenderkonten.ts @@ -0,0 +1,126 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { kontoAbgleichen } from '@/server/kalender/abgleich' + +export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string } + +const eingabe = z.object({ id: z.string().min(1) }) + +/** + * Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die + * Kennung geht in die where-Bedingung, nicht in einen nachträglichen + * Vergleich. + */ +async function eigenesKonto(id: string, benutzerId: string) { + return db.kalenderkonto.findFirst({ + where: { id, benutzerId }, + select: { id: true, anzeigename: true, anbieter: true }, + }) +} + +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 + } +} + +export async function jetztAbgleichen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + const bericht = await kontoAbgleichen(konto.id) + revalidatePath('/kalenderkonten') + + if (!bericht.ok) { + return { ok: false, fehler: bericht.fehler ?? 'Der Abgleich schlug fehl.' } + } + return { + ok: true, + meldung: `„${konto.anzeigename}": ${bericht.geaendert} Termin(e) gelesen, ${bericht.entfernt} entfernt.`, + } +} + +const umbenennen = z.object({ + id: z.string().min(1), + anzeigename: z.string().trim().min(2, 'Mindestens zwei Zeichen.').max(60), +}) + +export async function kontoUmbenennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = umbenennen.safeParse({ + id: daten.get('id'), + anzeigename: daten.get('anzeigename'), + }) + if (!gelesen.success) { + return { ok: false, fehler: 'Bitte einen Namen mit mindestens zwei Zeichen angeben.' } + } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { anzeigename: gelesen.data.anzeigename }, + }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.umbenannt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { vorher: konto.anzeigename, nachher: gelesen.data.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + return { ok: true, meldung: 'Name gespeichert.' } +} + +/** + * Trennt ein Kalenderkonto. Die gespiegelten Termine verschwinden mit ihm — + * sie stammen aus dem Kalender und sind dort weiterhin vorhanden. + */ +export async function kontoTrennen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + + const gelesen = eingabe.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id) + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + await db.kalenderkonto.delete({ where: { id: konto.id } }) + + await auditSchreiben({ + akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email }, + aktion: 'kalenderkonto.getrennt', + objektTyp: 'Kalenderkonto', + objektId: konto.id, + details: { anbieter: konto.anbieter, anzeigename: konto.anzeigename }, + }) + + revalidatePath('/kalenderkonten') + revalidatePath('/') + return { + ok: true, + meldung: `„${konto.anzeigename}" getrennt. Die Termine bleiben im Kalender selbst erhalten.`, + } +} diff --git a/src/server/jobs.ts b/src/server/jobs.ts new file mode 100644 index 0000000..a7df519 --- /dev/null +++ b/src/server/jobs.ts @@ -0,0 +1,72 @@ +import 'server-only' +import cron from 'node-cron' +import { db } from '@/server/db' +import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' +import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' +import { jetzt } from '@/server/zeitgeber' + +/** + * Hintergrundjobs. + * + * Werden einmal je Prozess gestartet. Im Entwicklungsbetrieb lädt Next Module + * mehrfach neu — deshalb der Riegel über globalThis, sonst liefen nach ein + * paar Änderungen ein Dutzend Zeitpläne parallel. + */ + +const globalerBereich = globalThis as unknown as { planerJobs?: boolean } + +/** Alle 15 Minuten abgleichen; öfter bringt nichts und kostet nur Aufrufe. */ +const ABGLEICH_PLAN = '*/15 * * * *' +/** Einmal nachts aufräumen. */ +const AUFRAEUM_PLAN = '17 3 * * *' + +async function alleKontenAbgleichen(): Promise { + const zeitpunkt = jetzt() + const konten = await db.kalenderkonto.findMany({ + where: { aktiv: true }, + select: { id: true }, + }) + + let ok = 0 + let gescheitert = 0 + for (const konto of konten) { + const bericht = await kontoAbgleichen(konto.id, zeitpunkt) + if (bericht.ok) ok++ + else gescheitert++ + } + + if (gescheitert > 0) { + // Nur Zahlen, keine Namen und keine Termininhalte. + console.warn( + `Kalenderabgleich: ${ok} Konto/Konten erfolgreich, ${gescheitert} fehlgeschlagen.`, + ) + } +} + +/** + * Entfernt Termine, die aus dem Abgleichfenster herausgewandert sind. + * Ohne das bliebe der Spiegel für immer stehen. + */ +async function alteTermineEntfernen(): Promise { + const fenster = abgleichFenster(jetzt()) + await db.termin.deleteMany({ where: { ende: { lt: fenster.von } } }) +} + +export function jobsStarten(): void { + if (globalerBereich.planerJobs) return + globalerBereich.planerJobs = true + + cron.schedule(ABGLEICH_PLAN, () => { + void alleKontenAbgleichen().catch((fehler: unknown) => { + console.error('Kalenderabgleich abgebrochen:', fehler) + }) + }) + + cron.schedule(AUFRAEUM_PLAN, () => { + void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) + .then(() => undefined) + .catch((fehler: unknown) => { + console.error('Aufräumen abgebrochen:', fehler) + }) + }) +} diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts new file mode 100644 index 0000000..26fe4e7 --- /dev/null +++ b/src/server/kalender/abgleich.ts @@ -0,0 +1,356 @@ +import 'server-only' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { graphSeiteLesen } from '@/lib/kalender/graph' +import { googleSeiteLesen } from '@/lib/kalender/google' +import { + VerweisAbgelaufen, + type AbgleichSeite, + type RohTermin, +} from '@/lib/kalender/typen' +import { tagesBeginn } from '@/lib/zeit' +import { db } from '@/server/db' +import { entschluesseln, verschluesseln } from '@/server/krypto' +import { tokenErneuern } from '@/server/kalender/oauth' +import { jetzt } from '@/server/zeitgeber' + +/** + * Abgleich der Kalender. + * + * Der Ablauf ist bei beiden Anbietern derselbe: Zugriffstoken auffrischen, + * Seiten abrufen, bis ein Verweis für die nächste Runde kommt, und die + * gelesenen Termine in den örtlichen Spiegel schreiben. + * + * Beide Anbieter kennen einen Verweis, der abläuft (HTTP 410). Dann wird er + * verworfen und von vorn abgeglichen — deshalb ist der Abgleich so gebaut, + * dass ein vollständiger Durchlauf jederzeit möglich ist. + */ + +/** Wie weit in die Zukunft und Vergangenheit gelesen wird. */ +export const FENSTER_TAGE_ZURUECK = 7 +export const FENSTER_TAGE_VORAUS = 90 + +/** Höchstzahl Seiten je Runde – schützt vor einer Endlosschleife. */ +const SEITEN_GRENZE = 50 + +export type AbgleichBericht = { + kalenderkontoId: string + anzeigename: string + geaendert: number + entfernt: number + ok: boolean + fehler?: string +} + +type Konto = { + id: string + anbieter: KalenderAnbieter + anzeigename: string + kalenderId: string + zugriffstoken: string | null + aktualisierungstoken: string | null + tokenGueltigBis: Date | null + deltaVerweis: string | null +} + +/** Zeitfenster, das abgeglichen wird. */ +export function abgleichFenster(zeitpunkt: Date): { von: Date; bis: Date } { + const heute = tagesBeginn(zeitpunkt) + return { + von: new Date(heute.getTime() - FENSTER_TAGE_ZURUECK * 86_400_000), + bis: new Date(heute.getTime() + FENSTER_TAGE_VORAUS * 86_400_000), + } +} + +/** + * Gibt ein gültiges Zugriffstoken zurück und erneuert es, wenn es bald + * abläuft. Eine Minute Vorlauf, damit ein Abruf nicht mitten im Ablauf + * scheitert. + */ +async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise { + const vorhanden = entschluesseln(konto.zugriffstoken) + const laeuftBald = + !konto.tokenGueltigBis || + konto.tokenGueltigBis.getTime() - zeitpunkt.getTime() < 60_000 + + if (vorhanden && !laeuftBald) return vorhanden + + const aktualisierung = entschluesseln(konto.aktualisierungstoken) + if (!aktualisierung) { + throw new Error( + 'Kein Aktualisierungstoken hinterlegt. Das Konto muss neu verbunden werden.', + ) + } + + const satz = await tokenErneuern(konto.anbieter, aktualisierung, zeitpunkt) + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { + zugriffstoken: verschluesseln(satz.zugriffstoken), + ...(satz.aktualisierungstoken + ? { aktualisierungstoken: verschluesseln(satz.aktualisierungstoken) } + : {}), + tokenGueltigBis: satz.gueltigBis, + }, + }) + + return satz.zugriffstoken +} + +/** Adresse der ersten Seite einer Runde. */ +function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string { + if (konto.deltaVerweis) return konto.deltaVerweis + + if (konto.anbieter === 'GRAPH') { + // "standard" heißt: der Standardkalender des Postfachs. + const pfad = + konto.kalenderId === 'standard' + ? '/me/calendarView/delta' + : `/me/calendars/${encodeURIComponent(konto.kalenderId)}/calendarView/delta` + const parameter = new URLSearchParams({ + startDateTime: fenster.von.toISOString(), + endDateTime: fenster.bis.toISOString(), + }) + return `https://graph.microsoft.com/v1.0${pfad}?${parameter.toString()}` + } + + const kalender = encodeURIComponent( + konto.kalenderId === 'standard' ? 'primary' : konto.kalenderId, + ) + const parameter = new URLSearchParams({ + timeMin: fenster.von.toISOString(), + timeMax: fenster.bis.toISOString(), + // Serien werden aufgelöst; uns interessieren die einzelnen Vorkommen. + singleEvents: 'true', + showDeleted: 'true', + maxResults: '250', + }) + return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events?${parameter.toString()}` +} + +/** Adresse der nächsten Seite; Google liefert nur einen Token, keine URL. */ +function naechsteAdresse(konto: Konto, vorher: string, weiter: string): string { + if (konto.anbieter === 'GRAPH') return weiter + + const adresse = new URL(vorher) + adresse.searchParams.set('pageToken', weiter) + return adresse.toString() +} + +/** Verweis für die nächste Runde; Google gibt nur einen Token zurück. */ +function verweisFuerNaechsteRunde( + konto: Konto, + vorher: string, + deltaVerweis: string, +): string { + if (konto.anbieter === 'GRAPH') return deltaVerweis + + // Der syncToken verträgt sich nicht mit timeMin/timeMax – die müssen raus. + const adresse = new URL(vorher) + adresse.searchParams.delete('timeMin') + adresse.searchParams.delete('timeMax') + adresse.searchParams.delete('pageToken') + adresse.searchParams.set('syncToken', deltaVerweis) + return adresse.toString() +} + +async function seiteAbrufen( + konto: Konto, + adresse: string, + token: string, +): Promise { + const kopfzeilen: Record = { Authorization: `Bearer ${token}` } + if (konto.anbieter === 'GRAPH') { + // Ohne das liefert Graph Zeiten in der Zeitzone des Postfachs, und der + // Text trägt keinen Versatz – die Umrechnung wäre geraten. + kopfzeilen.Prefer = 'outlook.timezone="UTC", odata.maxpagesize=100' + } + + const antwort = await fetch(adresse, { headers: kopfzeilen }) + + if (antwort.status === 410) { + throw new VerweisAbgelaufen(konto.anbieter) + } + if (!antwort.ok) { + throw new Error(`Der Anbieter antwortete mit ${antwort.status}.`) + } + + const inhalt: unknown = await antwort.json() + return konto.anbieter === 'GRAPH' ? graphSeiteLesen(inhalt) : googleSeiteLesen(inhalt) +} + +/** + * Schreibt die gelesenen Termine in den örtlichen Spiegel. + * + * Googles Folgeabgleiche liefern auch Änderungen außerhalb unseres Fensters, + * weil der syncToken die Zeitgrenzen nicht mitträgt. Was daneben liegt, wird + * hier verworfen — sonst wüchse der Spiegel unbegrenzt. + */ +async function spiegeln( + kalenderkontoId: string, + seite: AbgleichSeite, + fenster: { von: Date; bis: Date }, +): Promise<{ geaendert: number; entfernt: number }> { + const imFenster = (termin: RohTermin): boolean => + termin.ende.getTime() > fenster.von.getTime() && + termin.beginn.getTime() < fenster.bis.getTime() + + const zuSchreiben = seite.geaendert.filter(imFenster) + const ausserhalb = seite.geaendert.filter((t) => !imFenster(t)).map((t) => t.extId) + + for (const termin of zuSchreiben) { + await db.termin.upsert({ + where: { kalenderkontoId_extId: { kalenderkontoId, extId: termin.extId } }, + update: { + etag: termin.etag, + betreff: termin.betreff, + beginn: termin.beginn, + ende: termin.ende, + ganztaegig: termin.ganztaegig, + zeigtAls: termin.zeigtAls, + }, + create: { + kalenderkontoId, + extId: termin.extId, + etag: termin.etag, + betreff: termin.betreff, + beginn: termin.beginn, + ende: termin.ende, + ganztaegig: termin.ganztaegig, + zeigtAls: termin.zeigtAls, + }, + }) + } + + const zuEntfernen = [...seite.entfernt, ...ausserhalb] + const entfernt = + zuEntfernen.length > 0 + ? ( + await db.termin.deleteMany({ + where: { kalenderkontoId, extId: { in: zuEntfernen } }, + }) + ).count + : 0 + + return { geaendert: zuSchreiben.length, entfernt } +} + +/** Gleicht ein einzelnes Konto ab. */ +export async function kontoAbgleichen( + kalenderkontoId: string, + zeitpunkt: Date = jetzt(), +): Promise { + const konto = await db.kalenderkonto.findUnique({ + where: { id: kalenderkontoId }, + select: { + id: true, + anbieter: true, + anzeigename: true, + kalenderId: true, + zugriffstoken: true, + aktualisierungstoken: true, + tokenGueltigBis: true, + deltaVerweis: true, + }, + }) + if (!konto) { + return { + kalenderkontoId, + anzeigename: '(unbekannt)', + geaendert: 0, + entfernt: 0, + ok: false, + fehler: 'Dieses Kalenderkonto gibt es nicht mehr.', + } + } + + const fenster = abgleichFenster(zeitpunkt) + let geaendert = 0 + let entfernt = 0 + + try { + const token = await gueltigesToken(konto, zeitpunkt) + let adresse = ersteAdresse(konto, fenster) + let neuerVerweis: string | null = null + + for (let seitenzahl = 0; seitenzahl < SEITEN_GRENZE; seitenzahl++) { + let seite: AbgleichSeite + try { + seite = await seiteAbrufen(konto, adresse, token) + } catch (fehler) { + if (fehler instanceof VerweisAbgelaufen && konto.deltaVerweis) { + // Von vorn: Verweis verwerfen und den vollen Zeitraum neu lesen. + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { deltaVerweis: null }, + }) + await db.termin.deleteMany({ where: { kalenderkontoId: konto.id } }) + return kontoAbgleichen(kalenderkontoId, zeitpunkt) + } + throw fehler + } + + const bilanz = await spiegeln(konto.id, seite, fenster) + geaendert += bilanz.geaendert + entfernt += bilanz.entfernt + + if (seite.deltaVerweis) { + neuerVerweis = verweisFuerNaechsteRunde(konto, adresse, seite.deltaVerweis) + break + } + if (!seite.weiter) break + adresse = naechsteAdresse(konto, adresse, seite.weiter) + } + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { + ...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}), + letzterAbgleich: zeitpunkt, + letzterFehler: null, + }, + }) + + return { + kalenderkontoId: konto.id, + anzeigename: konto.anzeigename, + geaendert, + entfernt, + ok: true, + } + } catch (fehler) { + const meldung = + fehler instanceof Error ? fehler.message : 'Unbekannter Fehler beim Abgleich.' + + await db.kalenderkonto.update({ + where: { id: konto.id }, + data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung }, + }) + + return { + kalenderkontoId: konto.id, + anzeigename: konto.anzeigename, + geaendert, + entfernt, + ok: false, + fehler: meldung, + } + } +} + +/** Gleicht alle aktiven Konten eines Benutzers ab. */ +export async function alleKontenAbgleichen( + benutzerId: string, + zeitpunkt: Date = jetzt(), +): Promise { + const konten = await db.kalenderkonto.findMany({ + where: { benutzerId, aktiv: true }, + select: { id: true }, + }) + + const berichte: AbgleichBericht[] = [] + for (const konto of konten) { + berichte.push(await kontoAbgleichen(konto.id, zeitpunkt)) + } + return berichte +} diff --git a/src/server/kalender/oauth.ts b/src/server/kalender/oauth.ts new file mode 100644 index 0000000..81c524e --- /dev/null +++ b/src/server/kalender/oauth.ts @@ -0,0 +1,209 @@ +import 'server-only' +import { z } from 'zod' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import { umgebung } from '@/server/umgebung' + +/** + * OAuth für die Kalenderanbieter. + * + * Bewusst mit blankem fetch statt mit einem SDK: es geht um drei Aufrufe je + * Anbieter, und die Antworten werden ohnehin mit zod geprüft. Ein SDK würde + * hier vor allem Abhängigkeiten und blinde Flecken hinzufügen. + * + * Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest. Sonst + * müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist genau der + * Schritt, an dem Einrichtung scheitert. + */ + +const GRAPH_BEREICHE = ['offline_access', 'openid', 'email', 'Calendars.ReadWrite'] +const GOOGLE_BEREICHE = [ + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/calendar.readonly', + 'openid', + 'email', +] + +export type AnbieterEinstellung = { + anbieter: KalenderAnbieter + clientId: string + clientSecret: string + autorisierungsUrl: string + tokenUrl: string + bereiche: string[] +} + +export function anbieterEinstellung(anbieter: KalenderAnbieter): AnbieterEinstellung { + const einst = umgebung() + + if (anbieter === 'GRAPH') { + if (!einst.graphAktiv) { + throw new Error('Microsoft Graph ist nicht eingerichtet (GRAPH_CLIENT_ID fehlt).') + } + const tenant = einst.GRAPH_TENANT_ID || 'organizations' + return { + anbieter, + clientId: einst.GRAPH_CLIENT_ID, + clientSecret: einst.GRAPH_CLIENT_SECRET, + autorisierungsUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, + bereiche: GRAPH_BEREICHE, + } + } + + if (!einst.googleAktiv) { + throw new Error('Google ist nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).') + } + return { + anbieter, + clientId: einst.GOOGLE_CLIENT_ID, + clientSecret: einst.GOOGLE_CLIENT_SECRET, + autorisierungsUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + bereiche: GOOGLE_BEREICHE, + } +} + +/** Der Rückleit-URI, den es beim Anbieter zu hinterlegen gilt. */ +export function rueckleitUrl(anbieter: KalenderAnbieter): string { + return `${umgebung().APP_URL}/api/kalender/rueckleitung/${anbieter.toLowerCase()}` +} + +/** Adresse, zu der der Browser für die Zustimmung geschickt wird. */ +export function autorisierungsAdresse( + anbieter: KalenderAnbieter, + zustand: string, +): string { + const einst = anbieterEinstellung(anbieter) + const parameter = new URLSearchParams({ + client_id: einst.clientId, + response_type: 'code', + redirect_uri: rueckleitUrl(anbieter), + scope: einst.bereiche.join(' '), + state: zustand, + }) + + if (anbieter === 'GOOGLE') { + // Ohne beides gibt Google kein Aktualisierungstoken heraus – und ohne das + // wäre die Verbindung nach einer Stunde wieder tot. + parameter.set('access_type', 'offline') + parameter.set('prompt', 'consent') + } else { + parameter.set('response_mode', 'query') + } + + return `${einst.autorisierungsUrl}?${parameter.toString()}` +} + +const tokenAntwort = z.looseObject({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.coerce.number().int().positive().optional(), + scope: z.string().optional(), + id_token: z.string().optional(), + token_type: z.string().optional(), +}) + +export type Tokensatz = { + zugriffstoken: string + aktualisierungstoken: string | null + gueltigBis: Date | null + bereiche: string | null + /** Aus dem id_token gelesen, wenn vorhanden – dient dem Wiedererkennen. */ + kennung: string | null +} + +/** Liest die E-Mail aus einem id_token, ohne die Signatur zu prüfen. */ +function kennungAus(idToken: string | undefined): string | null { + if (!idToken) return null + const teile = idToken.split('.') + if (teile.length < 2) return null + try { + const nutzlast = JSON.parse( + Buffer.from(teile[1] ?? '', 'base64url').toString('utf8'), + ) as Record + // Die Signatur wird bewusst nicht geprüft: das Token kommt gerade eben + // über eine TLS-Verbindung direkt vom Anbieter, und der Wert dient nur + // der Anzeige und dem Wiedererkennen – nie einer Berechtigung. + const wert = nutzlast.email ?? nutzlast.preferred_username ?? nutzlast.upn + return typeof wert === 'string' ? wert : null + } catch { + return null + } +} + +async function tokenAbrufen( + anbieter: KalenderAnbieter, + koerper: URLSearchParams, + jetzt: Date, +): Promise { + const einst = anbieterEinstellung(anbieter) + koerper.set('client_id', einst.clientId) + koerper.set('client_secret', einst.clientSecret) + + const antwort = await fetch(einst.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: koerper, + }) + + if (!antwort.ok) { + // Der Antwortkörper enthält oft das Client-Secret im Klartext-Echo – + // deshalb kommt hier nur der Status heraus. + throw new Error(`Der Anbieter hat den Token-Aufruf mit ${antwort.status} abgelehnt.`) + } + + const gelesen = tokenAntwort.safeParse(await antwort.json()) + if (!gelesen.success) { + throw new Error('Die Token-Antwort des Anbieters war nicht verwertbar.') + } + + return { + zugriffstoken: gelesen.data.access_token, + aktualisierungstoken: gelesen.data.refresh_token ?? null, + gueltigBis: gelesen.data.expires_in + ? new Date(jetzt.getTime() + gelesen.data.expires_in * 1000) + : null, + bereiche: gelesen.data.scope ?? null, + kennung: kennungAus(gelesen.data.id_token), + } +} + +/** Tauscht den Code aus der Rückleitung gegen Tokens. */ +export async function codeEinloesen( + anbieter: KalenderAnbieter, + code: string, + jetzt: Date = new Date(), +): Promise { + return tokenAbrufen( + anbieter, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: rueckleitUrl(anbieter), + }), + jetzt, + ) +} + +/** + * Holt ein frisches Zugriffstoken. Google schickt dabei kein neues + * Aktualisierungstoken mit – das alte bleibt gültig und wird behalten. + */ +export async function tokenErneuern( + anbieter: KalenderAnbieter, + aktualisierungstoken: string, + jetzt: Date = new Date(), +): Promise { + const satz = await tokenAbrufen( + anbieter, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: aktualisierungstoken, + }), + jetzt, + ) + return { + ...satz, + aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken, + } +} diff --git a/src/server/kalender/verbinden.ts b/src/server/kalender/verbinden.ts new file mode 100644 index 0000000..4ac68e6 --- /dev/null +++ b/src/server/kalender/verbinden.ts @@ -0,0 +1,91 @@ +import 'server-only' +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import type { KalenderAnbieter } from '@/generated/prisma/enums' +import type { Faehigkeit } from '@/lib/instanz' +import { umgebung } from '@/server/umgebung' + +/** + * Zustandswert für den OAuth-Ablauf. + * + * Er trägt Anbieter und Benutzer und ist mit AUTH_SECRET signiert. Ohne das + * könnte jemand einen Rückleit-Aufruf unterschieben und ein fremdes + * Kalenderkonto an das eigene Konto hängen. + */ + +const TRENNER = '.' + +export function faehigkeitFuer(anbieter: KalenderAnbieter): Faehigkeit { + return anbieter === 'GRAPH' ? 'kalender:graph' : 'kalender:google' +} + +export function istAnbieter(wert: string): wert is Lowercase { + return wert === 'graph' || wert === 'google' +} + +export function alsAnbieter(wert: string): KalenderAnbieter { + return wert.toUpperCase() as KalenderAnbieter +} + +function signieren(nutzlast: string): string { + return createHmac('sha256', umgebung().AUTH_SECRET).update(nutzlast).digest('base64url') +} + +/** Erzeugt einen signierten Zustandswert. */ +export function zustandErzeugen( + anbieter: KalenderAnbieter, + benutzerId: string, + jetzt: Date = new Date(), +): string { + const nutzlast = Buffer.from( + JSON.stringify({ + a: anbieter, + b: benutzerId, + z: jetzt.getTime(), + n: randomBytes(9).toString('base64url'), + }), + 'utf8', + ).toString('base64url') + + return `${nutzlast}${TRENNER}${signieren(nutzlast)}` +} + +export type Zustand = { anbieter: KalenderAnbieter; benutzerId: string } + +/** Gültigkeit des Zustandswerts – die Zustimmung dauert keine Stunde. */ +const ZUSTAND_GUELTIG_MINUTEN = 30 + +/** + * Prüft den Zustandswert. Gibt null zurück, wenn er nicht stimmt, verändert + * wurde oder zu alt ist. + */ +export function zustandLesen( + wert: string | null, + jetzt: Date = new Date(), +): Zustand | null { + if (!wert) return null + + const [nutzlast, unterschrift] = wert.split(TRENNER) + if (!nutzlast || !unterschrift) return null + + const erwartet = Buffer.from(signieren(nutzlast), 'utf8') + const erhalten = Buffer.from(unterschrift, 'utf8') + if (erwartet.length !== erhalten.length) return null + if (!timingSafeEqual(erwartet, erhalten)) return null + + try { + const inhalt = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as { + a?: unknown + b?: unknown + z?: unknown + } + + if (typeof inhalt.a !== 'string' || typeof inhalt.b !== 'string') return null + if (typeof inhalt.z !== 'number') return null + if (jetzt.getTime() - inhalt.z > ZUSTAND_GUELTIG_MINUTEN * 60_000) return null + if (inhalt.a !== 'GRAPH' && inhalt.a !== 'GOOGLE') return null + + return { anbieter: inhalt.a, benutzerId: inhalt.b } + } catch { + return null + } +} diff --git a/src/server/krypto.test.ts b/src/server/krypto.test.ts new file mode 100644 index 0000000..2327991 --- /dev/null +++ b/src/server/krypto.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Die Verschlüsselung liegt in src/server, weil sie AUTH_SECRET braucht. + * Für den Test wird die Umgebung ersetzt und "server-only" auf ein leeres + * Modul umgebogen — sonst bricht der Import außerhalb einer Serverumgebung ab. + */ +vi.mock('server-only', () => ({})) + +vi.mock('@/server/umgebung', () => ({ + umgebung: () => ({ AUTH_SECRET: 'test-geheimnis-fuer-die-ableitung-1234567890' }), +})) + +const { entschluesseln, schluesselVergessen, verschluesseln } = + await import('@/server/krypto') + +describe('verschluesseln und entschluesseln', () => { + beforeEach(() => { + schluesselVergessen() + }) + + it('gibt zurück, was hineinging', () => { + const klartext = 'ya29.beispiel-aktualisierungstoken' + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext) + }) + + it('kommt mit Umlauten und langen Texten zurecht', () => { + const klartext = 'Rückgabewert mit Umlauten: äöüß — ' + 'x'.repeat(4000) + expect(entschluesseln(verschluesseln(klartext))).toBe(klartext) + }) + + it('erzeugt bei gleichem Text verschiedene Chiffren', () => { + // Sonst ließe sich an der Datenbank ablesen, welche Konten dasselbe + // Token tragen. + expect(verschluesseln('gleich')).not.toBe(verschluesseln('gleich')) + }) + + it('lässt den Klartext nicht durchscheinen', () => { + expect(verschluesseln('geheim')).not.toContain('geheim') + }) + + it('gibt für nichts auch nichts zurück', () => { + expect(entschluesseln(null)).toBeNull() + }) + + it('weist Veränderungen ab, statt Unsinn zu liefern', () => { + const chiffre = verschluesseln('unverändert') + const teile = chiffre.split(':') + // Ein Bit im Inhalt kippen. + const inhalt = Buffer.from(teile[3] ?? '', 'base64url') + inhalt[0] = (inhalt[0] ?? 0) ^ 0x01 + teile[3] = inhalt.toString('base64url') + + expect(entschluesseln(teile.join(':'))).toBeNull() + }) + + it('weist eine fremde Form ab', () => { + expect(entschluesseln('einfach nur Text')).toBeNull() + expect(entschluesseln('v2:a:b:c')).toBeNull() + expect(entschluesseln('v1:zu:wenige')).toBeNull() + }) +}) diff --git a/src/server/krypto.ts b/src/server/krypto.ts new file mode 100644 index 0000000..b61dd7b --- /dev/null +++ b/src/server/krypto.ts @@ -0,0 +1,84 @@ +import 'server-only' +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' +import { umgebung } from '@/server/umgebung' + +/** + * Verschlüsselung für Zugangs- und Aktualisierungstoken der Kalenderkonten. + * + * Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst + * direkten Zugriff auf fremde Kalender und Postfächer. Das wiegt schwerer als + * bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. + * + * Der Schlüssel wird aus AUTH_SECRET abgeleitet, nicht separat konfiguriert: + * eine zusätzliche Pflichtvariable wäre eine weitere Stelle, an der bei der + * Einrichtung etwas schiefgeht. Wer AUTH_SECRET wechselt, muss die + * Kalenderkonten neu verbinden – das ist gewollt, denn mit dem alten Geheimnis + * sind auch die alten Sitzungen ungültig. + */ + +const ALGORITHMUS = 'aes-256-gcm' +const SCHLUESSEL_LAENGE = 32 +const IV_LAENGE = 12 +const MARKE = 'v1' + +let zwischengespeichert: Buffer | null = null + +function schluessel(): Buffer { + if (zwischengespeichert) return zwischengespeichert + const abgeleitet = hkdfSync( + 'sha256', + Buffer.from(umgebung().AUTH_SECRET, 'utf8'), + Buffer.alloc(0), + Buffer.from('planer:kalender-token', 'utf8'), + SCHLUESSEL_LAENGE, + ) + zwischengespeichert = Buffer.from(abgeleitet) + return zwischengespeichert +} + +/** + * Verschlüsselt einen Text. Das Ergebnis ist selbstbeschreibend: + * "v1:::", alles base64url. + */ +export function verschluesseln(klartext: string): string { + const iv = randomBytes(IV_LAENGE) + const chiffre = createCipheriv(ALGORITHMUS, schluessel(), iv) + const inhalt = Buffer.concat([chiffre.update(klartext, 'utf8'), chiffre.final()]) + const echtheitsmarke = chiffre.getAuthTag() + + return [ + MARKE, + iv.toString('base64url'), + echtheitsmarke.toString('base64url'), + inhalt.toString('base64url'), + ].join(':') +} + +/** + * Entschlüsselt einen Text. Gibt null zurück, wenn er nicht zum Schlüssel + * passt oder verändert wurde – das ist kein Absturzgrund, sondern heißt: + * das Konto muss neu verbunden werden. + */ +export function entschluesseln(gespeichert: string | null): string | null { + if (!gespeichert) return null + + const teile = gespeichert.split(':') + if (teile.length !== 4 || teile[0] !== MARKE) return null + + try { + const iv = Buffer.from(teile[1] ?? '', 'base64url') + const echtheitsmarke = Buffer.from(teile[2] ?? '', 'base64url') + const inhalt = Buffer.from(teile[3] ?? '', 'base64url') + + const dechiffre = createDecipheriv(ALGORITHMUS, schluessel(), iv) + dechiffre.setAuthTag(echtheitsmarke) + return Buffer.concat([dechiffre.update(inhalt), dechiffre.final()]).toString('utf8') + } catch { + return null + } +} + +/** Nur für Tests: erzwingt beim nächsten Aufruf ein erneutes Ableiten. */ +export function schluesselVergessen(): void { + zwischengespeichert = null +} diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts index 5900dfb..533c9a4 100644 --- a/src/server/umgebung.ts +++ b/src/server/umgebung.ts @@ -30,6 +30,17 @@ const schema = z.object({ WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'), WEBAUTHN_ORIGIN: z.url(), + // --- Kalenderanbieter (M3) --- + // Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte + // tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts. + GRAPH_CLIENT_ID: z.string().trim().default(''), + GRAPH_CLIENT_SECRET: z.string().trim().default(''), + /// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant. + GRAPH_TENANT_ID: z.string().trim().default('organizations'), + + GOOGLE_CLIENT_ID: z.string().trim().default(''), + GOOGLE_CLIENT_SECRET: z.string().trim().default(''), + DEMO_MODUS: jaNein.default(false), }) @@ -37,6 +48,9 @@ export type Umgebung = z.infer & { instanz: Instanz /** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */ oidcAktiv: boolean + /** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */ + graphAktiv: boolean + googleAktiv: boolean } let zwischengespeichert: Umgebung | null = null @@ -56,6 +70,8 @@ function lesen(): Umgebung { oidcAktiv: Boolean( werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET, ), + graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET), + googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET), } } diff --git a/test/fixtures/kalender/google-events-leer.json b/test/fixtures/kalender/google-events-leer.json new file mode 100644 index 0000000..3501b61 --- /dev/null +++ b/test/fixtures/kalender/google-events-leer.json @@ -0,0 +1,8 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-3\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-2", + "items": [] +} diff --git a/test/fixtures/kalender/google-events-seite1.json b/test/fixtures/kalender/google-events-seite1.json new file mode 100644 index 0000000..b607d44 --- /dev/null +++ b/test/fixtures/kalender/google-events-seite1.json @@ -0,0 +1,29 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-1\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextPageToken": "BEISPIEL-SEITE-2", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG10\"", + "id": "beispiel-termin-a", + "status": "confirmed", + "summary": "Zahnarzt", + "updated": "2026-03-10T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T14:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T15:00:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG11\"", + "id": "beispiel-termin-b", + "status": "confirmed", + "summary": "Urlaubstag", + "updated": "2026-03-11T09:00:00.000Z", + "start": { "date": "2026-03-20" }, + "end": { "date": "2026-03-21" } + } + ] +} diff --git a/test/fixtures/kalender/google-events-seite2.json b/test/fixtures/kalender/google-events-seite2.json new file mode 100644 index 0000000..6fc1bea --- /dev/null +++ b/test/fixtures/kalender/google-events-seite2.json @@ -0,0 +1,27 @@ +{ + "kind": "calendar#events", + "etag": "\"BEISPIEL-LISTE-2\"", + "summary": "person@example.invalid", + "timeZone": "Europe/Berlin", + "nextSyncToken": "BEISPIEL-SYNC-1", + "items": [ + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG12\"", + "id": "beispiel-termin-c", + "status": "confirmed", + "summary": "Sport", + "transparency": "transparent", + "updated": "2026-03-12T09:00:00.000Z", + "start": { "dateTime": "2026-03-17T18:00:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-03-17T19:30:00+01:00", "timeZone": "Europe/Berlin" } + }, + { + "kind": "calendar#event", + "etag": "\"BEISPIELETAG13\"", + "id": "beispiel-termin-d", + "status": "cancelled", + "updated": "2026-03-13T09:00:00.000Z" + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-fremde-zeitzone.json b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json new file mode 100644 index 0000000..4c5da01 --- /dev/null +++ b/test/fixtures/kalender/graph-delta-fremde-zeitzone.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-2", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-4", + "subject": "Falsch angefragt", + "isAllDay": false, + "showAs": "busy", + "start": { + "dateTime": "2026-03-17T09:00:00.0000000", + "timeZone": "W. Europe Standard Time" + }, + "end": { + "dateTime": "2026-03-17T10:00:00.0000000", + "timeZone": "W. Europe Standard Time" + } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite1.json b/test/fixtures/kalender/graph-delta-seite1.json new file mode 100644 index 0000000..9dcf3ab --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite1.json @@ -0,0 +1,29 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=BEISPIEL-SKIP-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG1==\"", + "id": "BEISPIEL-TERMIN-1", + "subject": "Wochenbesprechung", + "isAllDay": false, + "showAs": "busy", + "start": { "dateTime": "2026-03-17T08:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T09:00:00.0000000", "timeZone": "UTC" }, + "organizer": { + "emailAddress": { "name": "Beispiel Person", "address": "person@example.invalid" } + } + }, + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG2==\"", + "id": "BEISPIEL-TERMIN-2", + "subject": "Betriebsausflug", + "isAllDay": true, + "showAs": "oof", + "start": { "dateTime": "2026-03-18T00:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-19T00:00:00.0000000", "timeZone": "UTC" } + } + ] +} diff --git a/test/fixtures/kalender/graph-delta-seite2.json b/test/fixtures/kalender/graph-delta-seite2.json new file mode 100644 index 0000000..1613c0f --- /dev/null +++ b/test/fixtures/kalender/graph-delta-seite2.json @@ -0,0 +1,21 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(event)", + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=BEISPIEL-DELTA-1", + "value": [ + { + "@odata.type": "#microsoft.graph.event", + "@odata.etag": "W/\"BEISPIELETAG3==\"", + "id": "BEISPIEL-TERMIN-3", + "subject": "Mittagspause", + "isAllDay": false, + "showAs": "free", + "start": { "dateTime": "2026-03-17T11:00:00.0000000", "timeZone": "UTC" }, + "end": { "dateTime": "2026-03-17T12:00:00.0000000", "timeZone": "UTC" } + }, + { + "@odata.type": "#microsoft.graph.event", + "id": "BEISPIEL-TERMIN-9", + "@removed": { "reason": "deleted" } + } + ] +}