13 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 dad2db929b chore(release): 0.3.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:41:59 +02:00
Florian WildeandClaude Opus 5 e4b08b4c30 docs: DEVLOG-Eintrag für M3
Ziel, Erledigtes, die Entscheidungen mit Begründung, die Probleme samt
Lösung und die offenen Punkte — allen voran, dass der Abgleich nicht gegen
die echten APIs getestet werden konnte.

Dauer laut zeiten.csv: 41 Minuten. Gesamt bislang 246 Minuten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 7e11ef736c test: Wartepunkt nach dem Abhaken ergänzen
Der Test navigierte direkt nach dem Klick weiter, ohne das Ende der Server
Action abzuwarten. In M2 war die Seite schnell genug, mit der zusätzlichen
Belegungsabfrage aus M3 nicht mehr — ein Zeitproblem im Test, kein
Produktfehler. Er wartet jetzt auf die Bestätigung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 80f86b4cff docs: Handbuchbilder für M3 neu aufnehmen
40 Bilder aus der Wegwerf-Instanz: zehn Seiten, je hell und dunkel,
Schreibtisch und Handy. Neu dabei die Kalenderkonten und die Belegung des
Tages auf der Startseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 a8b2ff0235 docs: Handbuchseite und Einrichtung der Kalenderanbieter
Die Handbuchseite erklärt, was der Planer liest und was ausdrücklich nicht,
warum alle Kalender gegen jeden Bereich zählen und was beim Trennen mit den
Terminen passiert.

Die README bekommt eine Schritt-für-Schritt-Anleitung für Entra ID und die
Google Cloud Console — mit den genauen Rückleit-URIs und Berechtigungen.
Dazu der Hinweis, dass ein Google-Projekt im Status "Test" die
Aktualisierungstoken nach sieben Tagen verfallen lässt; das kostet sonst
einen halben Abend Fehlersuche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:05 +02:00
Florian WildeandClaude Opus 5 df50627a20 test: Verschlüsselung und Kalendermodelle absichern
Sieben Tests für die Tokenverschlüsselung: Rückweg, Umlaute, verschiedene
Chiffren bei gleichem Klartext, und dass eine Veränderung am Chiffrat null
ergibt statt Unsinn.

Der Integrationstest deckt jetzt auch ab, dass dasselbe Konto mit
verschiedenen Kalendern erlaubt ist, ein Upsert den Termin aktualisiert
statt zu doppeln, und ein Bereich das Löschen seines Standard-Kalenderkontos
überlebt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:05 +02:00
Florian WildeandClaude Opus 5 d8d3227c82 feat(kalender): Belegung des Tages auf der Startseite
Jeder Bereich zeigt, wie voll der heutige Tag schon ist: Termine aus allen
verbundenen Kalendern gegen die Tageskapazität dieses Bereichs.

Alle Kalender zählen gegen jeden Bereich, weil ein Termin die Zeit ohnehin
blockiert, gleich zu welchem Lebensbereich er gehört. Wer um 14 Uhr beim
Zahnarzt sitzt, kann zur selben Zeit nichts für die Firma tun.

Ohne verbundenen Kalender bleibt der Balken weg. Er wäre sonst immer bei
null und sähe nach "alles frei" aus — eine Zahl, die lügt, ist schlechter
als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:04 +02:00
Florian WildeandClaude Opus 5 42c98c8b29 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
Alle 15 Minuten abgleichen, nachts alte Termine und abgelaufene
WebAuthn-Herausforderungen entfernen. Ohne das Aufräumen bliebe der
Terminspiegel für immer stehen.

Der Riegel über globalThis ist nötig, weil Next im Entwicklungsbetrieb
Module mehrfach lädt — sonst liefen nach ein paar Änderungen ein Dutzend
Zeitpläne parallel. Im Demo-Modus starten gar keine Jobs: die
Screenshot-Instanz soll sich nicht selbst verändern, während sie
abfotografiert wird.

Protokolliert werden nur Zahlen, nie Kontonamen oder Termininhalte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:07 +02:00
Florian WildeandClaude Opus 5 0c67b73a62 feat(kalender): Seite für die Kalenderkonten
Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen
arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in
die where-Bedingung, nicht in einen nachträglichen Vergleich.

Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er
konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite
das ausdrücklich, statt den Knopf wortlos wegzulassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:07 +02:00
Florian WildeandClaude Opus 5 9946e7c308 feat(kalender): OAuth und Abgleich für Graph und Google
Verbinden, Token erneuern und seitenweise abrufen — mit blankem fetch,
ohne SDK. 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.

Entscheidungen:
- Der Zustandswert im OAuth-Ablauf ist mit AUTH_SECRET signiert und trägt
  die Benutzerkennung. Ohne das ließe sich ein fremdes Kalenderkonto an
  ein beliebiges Konto hängen.
- 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.
- Läuft der Abgleichverweis ab (beide Anbieter antworten mit 410), wird er
  verworfen und von vorn gelesen. Der Ablauf ist so gebaut, dass ein
  vollständiger Durchlauf jederzeit möglich ist.
- Googles syncToken trägt die Zeitgrenzen nicht mit; Folgeabgleiche
  liefern also auch Änderungen außerhalb unseres Fensters. Was daneben
  liegt, wird verworfen — sonst wüchse der Spiegel unbegrenzt.

Die Instanzregel greift serverseitig: Google gibt es nur daheim, in der
Firmeninstanz liefert die Route 404 statt eines Hinweises.

Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
enthält das Client-Secret oft im Echo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:06 +02:00
Florian WildeandClaude Opus 5 6fda2a579e feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod
geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen
anonymisierte Beispielantworten in test/fixtures — 61 Tests.

Zwei Stellen, an denen bewusst abgebrochen statt geraten wird:
- Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir
  fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes
  zurück, wäre jede Umrechnung geraten und würde Termine um Stunden
  verschieben.
- Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber
  eine Stunde zu viel als eine Doppelbuchung.

Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder
Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine
Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte
Termine. Überlappende Termine werden zusammengelegt, damit eine
Doppelbuchung nicht doppelt zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 79e6c0d556 feat(kalender): Datenmodell für Kalenderkonten und Termine
Kalenderkonto mit Tokens und Abgleichverweis, Termin als örtlicher Spiegel
mit genau so viel, wie die Belegungsrechnung und die Wochenansicht
brauchen. Bereich bekommt sein Standard-Kalenderkonto für M4.

Die Tokens liegen verschlüsselt (AES-256-GCM). Wer die Datenbank oder eine
Sicherung in die Hände bekommt, hätte sonst direkten Zugriff auf fremde
Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die nur die
Anmeldung betreffen. Der Schlüssel wird aus AUTH_SECRET abgeleitet statt
separat konfiguriert: eine weitere Pflichtvariable wäre eine weitere
Stelle, an der die Einrichtung scheitert.

kalenderId ist nicht optional, sondern trägt den Wert "standard" für den
Standardkalender — Prisma lässt in einem zusammengesetzten Unique-Schlüssel
kein null zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 a6bcaf8982 Merge: Meilenstein 2 – Aufgaben und Projekte (0.2.0)
Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern in der
Adresszeile, Ideen als eigene Ansicht und Projekte mit Fortschritt.

Geprüft vor dem Merge: npm run pruefen mit 131 Unit-Tests, 30
Integrationsprüfungen, Migrationstest und 14 E2E-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:47:43 +02:00
87 changed files with 3564 additions and 44 deletions
+15
View File
@@ -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:
# <APP_URL>/api/kalender/rueckleitung/graph
# <APP_URL>/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
+30
View File
@@ -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
+75 -6
View File
@@ -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,20 +40,21 @@ 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) |
| 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) |
@@ -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 `<APP_URL>/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 `<APP_URL>/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. <https://entra.microsoft.com> → **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**:
`<APP_URL>/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. <https://console.cloud.google.com> → 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:
`<APP_URL>/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
+9 -7
View File
@@ -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
+92 -1
View File
@@ -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
---
+1
View File
@@ -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
1 datum start ende minuten meilenstein art notiz
2 2026-09-10 12:06 14:47 161 M1 claude M1 Fundament fertiggestellt
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 240 KiB

+3 -2
View File
@@ -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 |
+116
View File
@@ -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: <https://myapps.microsoft.com> → _Zugriff verwalten_
- Google: <https://myaccount.google.com/permissions>
## 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).
+5
View File
@@ -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)
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+46
View File
@@ -327,6 +327,52 @@ async function main(): Promise<void> {
})
}
// --- 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: {
@@ -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;
@@ -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';
+88
View File
@@ -34,6 +34,7 @@ model Benutzer {
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")
}
+1
View File
@@ -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' },
]
+112
View File
@@ -50,6 +50,9 @@ async function aufraeumen(): Promise<void> {
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<void> {
}),
)
// --- 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<void> {
'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({
@@ -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<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function felder(werte: Record<string, string>): FormData {
const daten = new FormData()
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
return daten
}
return (
<Card>
<CardKopf className="flex-row flex-wrap items-center gap-2">
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
</CardKopf>
<CardInhalt className="flex flex-col gap-4">
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
<dt className="text-gedaempft-vordergrund">Konto</dt>
<dd>{konto.extKontoId}</dd>
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
<dd>{konto.termine}</dd>
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
</dl>
{konto.letzterFehler && (
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
{konto.letzterFehler}
</p>
)}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`name-${konto.id}`}
className="text-gedaempft-vordergrund text-xs"
>
Bezeichnung
</label>
<Input
id={`name-${konto.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
disabled={laeuft}
/>
</div>
<Button
art="umriss"
disabled={laeuft || name.trim() === konto.anzeigename}
onClick={() =>
mitRueckmeldung(() =>
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
)
}
>
Speichern
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
art="umriss"
disabled={laeuft}
onClick={() =>
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
Jetzt abgleichen
</Button>
<Button
art="gefahr"
className="ml-auto"
disabled={laeuft}
onBlur={() => setTrennenBestaetigt(false)}
onClick={() => {
if (!trennenBestaetigt) {
setTrennenBestaetigt(true)
return
}
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
}}
>
<Unplug aria-hidden />
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
</Button>
</div>
{trennenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
unangetastet.
</p>
)}
</CardInhalt>
</Card>
)
}
+33
View File
@@ -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
}
+140
View File
@@ -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<KalenderAnbieter, string> = {
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 (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarDays className="size-6" aria-hidden />
Kalenderkonten
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
schreibt er Zeitblöcke in denselben Kalender zurück.
</p>
</header>
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
<Card>
<CardKopf>
<CardTitel className="text-base">Kalender verbinden</CardTitel>
<CardBeschreibung>
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
Der Planer speichert keine Zugangsdaten im Klartext.
</CardBeschreibung>
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
{verfuegbar.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
</p>
) : (
<div className="flex flex-wrap gap-2">
{verfuegbar.map((anbieter) => (
<Button key={anbieter} alsKind>
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
<Plus aria-hidden />
{ANBIETER_NAME[anbieter]} verbinden
</a>
</Button>
))}
</div>
)}
{fehlendKonfiguriert.length > 0 && (
<p className="text-gedaempft-vordergrund text-xs">
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
Umgebungsvariablen.
</p>
)}
</CardInhalt>
</Card>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Verbundene Konten</h2>
{konten.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Kalender verbunden.
</CardInhalt>
</Card>
) : (
konten.map((konto) => (
<KontoKarte
key={konto.id}
konto={{
id: konto.id,
anbieter: konto.anbieter,
anbieterName: ANBIETER_NAME[konto.anbieter],
anzeigename: konto.anzeigename,
extKontoId: konto.extKontoId,
termine: konto._count.termine,
letzterAbgleich: konto.letzterAbgleich
? alsDatumUhrzeit(konto.letzterAbgleich)
: null,
letzterFehler: konto.letzterFehler,
}}
/>
))
)}
</section>
</div>
)
}
+29 -3
View File
@@ -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,7 +26,8 @@ export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const heute = jetzt()
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
@@ -33,6 +36,8 @@ export default async function StartSeite() {
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.
@@ -108,9 +113,10 @@ export default async function StartSeite() {
<ul className="grid gap-4 sm:grid-cols-2">
{bereichsListe.map((bereich) => {
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
return (
<li key={bereich.id}>
<Card>
<Card className="h-full">
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
@@ -119,13 +125,33 @@ export default async function StartSeite() {
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt>
<CardInhalt className="flex flex-col gap-2">
<p className="text-gedaempft-vordergrund text-sm">
{offeneHier.length === 0
? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p>
{/*
Ohne verbundenen Kalender wäre die Belegung immer null –
das sähe nach "alles frei" aus und wäre irreführend.
*/}
{mitKalender && heuteHier && (
<>
<Fortschrittsbalken
prozent={heuteHier.prozent}
beschriftung={`Belegung heute in ${bereich.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{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)}`}
</p>
</>
)}
</CardInhalt>
</Card>
</li>
@@ -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')
}
}
@@ -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 ?? '',
),
)
}
}
+1
View File
@@ -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 },
]
+15
View File
@@ -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<void> {
if (process.env.NEXT_RUNTIME !== 'nodejs') return
if (process.env.DEMO_MODUS === 'true') return
const { jobsStarten } = await import('@/server/jobs')
jobsStarten()
}
+337
View File
@@ -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> = {},
): 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([])
})
})
+217
View File
@@ -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<T extends { beginn: Date; ende: Date }>(
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)
}
+104
View File
@@ -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([])
})
})
+118
View File
@@ -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,
}
}
+130
View File
@@ -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([])
})
})
+124
View File
@@ -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,
}
}
+56
View File
@@ -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'
}
}
+75
View File
@@ -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<BelegenderTermin[]> {
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<BereichMitBelegung[]> {
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<boolean> {
return (await db.kalenderkonto.count({ where: { benutzerId, aktiv: true } })) > 0
}
+126
View File
@@ -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<Rueckmeldung> {
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<Rueckmeldung> {
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<Rueckmeldung> {
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.`,
}
}
+72
View File
@@ -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<void> {
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<void> {
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)
})
})
}
+356
View File
@@ -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<string> {
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<AbgleichSeite> {
const kopfzeilen: Record<string, string> = { 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<AbgleichBericht> {
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<AbgleichBericht[]> {
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
}
+209
View File
@@ -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<string, unknown>
// 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<Tokensatz> {
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<Tokensatz> {
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<Tokensatz> {
const satz = await tokenAbrufen(
anbieter,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: aktualisierungstoken,
}),
jetzt,
)
return {
...satz,
aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken,
}
}
+91
View File
@@ -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<KalenderAnbieter> {
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
}
}
+62
View File
@@ -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()
})
})
+84
View File
@@ -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:<iv>:<marke>:<inhalt>", 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
}
+16
View File
@@ -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<typeof schema> & {
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),
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"kind": "calendar#events",
"etag": "\"BEISPIEL-LISTE-3\"",
"summary": "person@example.invalid",
"timeZone": "Europe/Berlin",
"nextSyncToken": "BEISPIEL-SYNC-2",
"items": []
}
+29
View File
@@ -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" }
}
]
}
+27
View File
@@ -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"
}
]
}
+21
View File
@@ -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"
}
}
]
}
+29
View File
@@ -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" }
}
]
}
+21
View File
@@ -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" }
}
]
}