Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad2db929b | ||
|
|
e4b08b4c30 | ||
|
|
7e11ef736c | ||
|
|
80f86b4cff | ||
|
|
a8b2ff0235 | ||
|
|
df50627a20 | ||
|
|
d8d3227c82 | ||
|
|
42c98c8b29 | ||
|
|
0c67b73a62 | ||
|
|
9946e7c308 | ||
|
|
6fda2a579e | ||
|
|
79e6c0d556 | ||
|
|
a6bcaf8982 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,10 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||
wird.
|
||||
|
||||
> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben,
|
||||
> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb.
|
||||
> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen
|
||||
> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||
> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der
|
||||
> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das
|
||||
> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der
|
||||
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||
|
||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||
zur ersten Anmeldung
|
||||
@@ -28,6 +28,8 @@ wird.
|
||||
nach Dringlichkeit und filterbar über die Adresszeile
|
||||
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
||||
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||
Bereich, wie voll der heutige Tag schon ist
|
||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
@@ -38,23 +40,24 @@ wird.
|
||||
|
||||
### Geplant
|
||||
|
||||
Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen,
|
||||
Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen,
|
||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Bildergalerie
|
||||
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Kalender |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
|
||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||
@@ -117,6 +120,19 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||
Rückleit-URI beim Anbieter lautet `<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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 240 KiB |
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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';
|
||||
@@ -31,9 +31,10 @@ model Benutzer {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
kalenderkonten Kalenderkonto[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("benutzer")
|
||||
@@ -121,6 +122,10 @@ model Bereich {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
|
||||
standardKalenderkontoId String?
|
||||
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
|
||||
|
||||
aufgaben Aufgabe[]
|
||||
projekte Projekt[]
|
||||
|
||||
@@ -252,3 +257,86 @@ model AuditEintrag {
|
||||
@@index([benutzerId])
|
||||
@@map("audit_eintrag")
|
||||
}
|
||||
|
||||
enum KalenderAnbieter {
|
||||
GRAPH
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
|
||||
enum ZeigtAls {
|
||||
FREI
|
||||
GEBUCHT
|
||||
MIT_VORBEHALT
|
||||
ABWESEND
|
||||
WOANDERS
|
||||
UNBEKANNT
|
||||
}
|
||||
|
||||
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
|
||||
model Kalenderkonto {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
anbieter KalenderAnbieter
|
||||
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
|
||||
anzeigename String
|
||||
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
|
||||
extKontoId String
|
||||
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
|
||||
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
|
||||
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
|
||||
kalenderId String @default("standard")
|
||||
|
||||
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
|
||||
zugriffstoken String?
|
||||
aktualisierungstoken String?
|
||||
tokenGueltigBis DateTime?
|
||||
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
|
||||
bereiche String?
|
||||
|
||||
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
|
||||
deltaVerweis String?
|
||||
letzterAbgleich DateTime?
|
||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||
letzterFehler String?
|
||||
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
termine Termin[]
|
||||
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
||||
|
||||
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
|
||||
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
|
||||
@@index([benutzerId, aktiv])
|
||||
@@map("kalenderkonto")
|
||||
}
|
||||
|
||||
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
|
||||
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
|
||||
model Termin {
|
||||
id String @id @default(cuid())
|
||||
kalenderkontoId String
|
||||
/// Kennung des Termins beim Anbieter.
|
||||
extId String
|
||||
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
|
||||
etag String?
|
||||
|
||||
betreff String?
|
||||
beginn DateTime
|
||||
ende DateTime
|
||||
ganztaegig Boolean @default(false)
|
||||
zeigtAls ZeigtAls @default(GEBUCHT)
|
||||
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([kalenderkontoId, extId])
|
||||
@@index([kalenderkontoId, beginn])
|
||||
@@index([beginn, ende])
|
||||
@@map("termin")
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
projekteZurAuswahlLaden,
|
||||
zaehlstaende,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
@@ -24,16 +26,19 @@ export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const heute = jetzt()
|
||||
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
zaehlstaende(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
])
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||
await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
zaehlstaende(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
belegungHeute(benutzer.id, heute),
|
||||
hatKalenderkonto(benutzer.id),
|
||||
])
|
||||
|
||||
// Was jetzt zählt: überfällig, heute oder morgen fällig.
|
||||
const dran = alleOffenen.filter(
|
||||
@@ -108,9 +113,10 @@ export default async function StartSeite() {
|
||||
<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 ?? '',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.`,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kind": "calendar#events",
|
||||
"etag": "\"BEISPIEL-LISTE-3\"",
|
||||
"summary": "person@example.invalid",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"nextSyncToken": "BEISPIEL-SYNC-2",
|
||||
"items": []
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||