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.
|
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
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 -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.2.0] – 2026-09-10
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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
|
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||||
wird.
|
wird.
|
||||||
|
|
||||||
> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben,
|
> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der
|
||||||
> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb.
|
> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das
|
||||||
> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen
|
> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der
|
||||||
> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||||
|
|
||||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||||
zur ersten Anmeldung
|
zur ersten Anmeldung
|
||||||
@@ -28,6 +28,8 @@ wird.
|
|||||||
nach Dringlichkeit und filterbar über die Adresszeile
|
nach Dringlichkeit und filterbar über die Adresszeile
|
||||||
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
||||||
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
- **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
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -38,20 +40,21 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### 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
|
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
| | Hell | Dunkel |
|
| | Hell | Dunkel |
|
||||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
| Anmelden |  |  |
|
| Anmelden |  |  |
|
||||||
| Start |  |  |
|
| Start |  |  |
|
||||||
| Inbox |  |  |
|
| Inbox |  |  |
|
||||||
| Aufgaben |  |  |
|
| Aufgaben |  |  |
|
||||||
| Projekte |  |  |
|
| Projekte |  |  |
|
||||||
| Ideen |  |  |
|
| Ideen |  |  |
|
||||||
|
| Kalender |  |  |
|
||||||
| Bereiche |  |  |
|
| Bereiche |  |  |
|
||||||
| Einstellungen |  |  |
|
| Einstellungen |  |  |
|
||||||
| Protokoll |  |  |
|
| Protokoll |  |  |
|
||||||
@@ -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
|
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||||
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
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
|
### Einrichtung und Sonstiges
|
||||||
|
|
||||||
| Variable | Standard | Bedeutung |
|
| 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 |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||||
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
| `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
|
## Betrieb
|
||||||
|
|
||||||
### Starten und stoppen
|
### Starten und stoppen
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| --------------------------------------- | ---------------------------- | -------- |
|
| --------------------------------------- | ---------------------------- | -------- |
|
||||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | 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 |
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
||||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||||
@@ -63,14 +63,16 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
|||||||
|
|
||||||
## M3 – Kalender lesen
|
## M3 – Kalender lesen
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||||
|
|
||||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||||
|
|
||||||
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||||
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||||
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||||
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||||
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||||
- [ ] Hintergrundjob mit node-cron
|
- [x] Hintergrundjob mit node-cron
|
||||||
|
|
||||||
## M4 – Wochenplanung und Zeitblöcke
|
## M4 – Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,98 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
|||||||
| ----------- | ------ | ------- | -------- |
|
| ----------- | ------ | ------- | -------- |
|
||||||
| M1 | claude | 161 | 2,68 |
|
| M1 | claude | 161 | 2,68 |
|
||||||
| M2 | claude | 44 | 0,73 |
|
| 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
|
datum;start;ende;minuten;meilenstein;art;notiz
|
||||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
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;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
|
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||||
Fortschritt
|
Fortschritt
|
||||||
|
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||||
|
verbinden, Belegung verstehen
|
||||||
|
|
||||||
## Verwaltung
|
## 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
|
## Was noch kommt
|
||||||
|
|
||||||
@@ -27,7 +29,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ------------------------------- | ----------- |
|
||||||
| Kalenderkonten verbinden | M3 |
|
|
||||||
| Wochenplanung und Zeitblöcke | M4 |
|
| Wochenplanung und Zeitblöcke | M4 |
|
||||||
| Benachrichtigungen | M5 |
|
| Benachrichtigungen | M5 |
|
||||||
| Integrationen | M6 |
|
| 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 expect(inListe).toBeVisible()
|
||||||
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
|
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.
|
// Erledigtes fällt aus der Standardansicht heraus.
|
||||||
await page.goto('/aufgaben')
|
await page.goto('/aufgaben')
|
||||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "2.11.3",
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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.
|
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||||
await db.aufgabe.create({
|
await db.aufgabe.create({
|
||||||
data: {
|
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';
|
||||||
@@ -34,6 +34,7 @@ model Benutzer {
|
|||||||
konten Konto[]
|
konten Konto[]
|
||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
audit AuditEintrag[]
|
audit AuditEintrag[]
|
||||||
|
kalenderkonten Kalenderkonto[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -121,6 +122,10 @@ model Bereich {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
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[]
|
aufgaben Aufgabe[]
|
||||||
projekte Projekt[]
|
projekte Projekt[]
|
||||||
|
|
||||||
@@ -252,3 +257,86 @@ model AuditEintrag {
|
|||||||
@@index([benutzerId])
|
@@index([benutzerId])
|
||||||
@@map("audit_eintrag")
|
@@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: 'projekte', pfad: '/projekte' },
|
||||||
{ name: 'ideen', pfad: '/ideen' },
|
{ name: 'ideen', pfad: '/ideen' },
|
||||||
{ name: 'bereiche', pfad: '/bereiche' },
|
{ name: 'bereiche', pfad: '/bereiche' },
|
||||||
|
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ async function aufraeumen(): Promise<void> {
|
|||||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
||||||
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
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.benutzer.deleteMany({ where: { email: testEmail } })
|
||||||
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||||
@@ -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 ----------------------------------------
|
// --- Audit überlebt den Benutzer ----------------------------------------
|
||||||
const audit = await db.auditEintrag.create({
|
const audit = await db.auditEintrag.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -177,6 +276,19 @@ async function main(): Promise<void> {
|
|||||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||||
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
(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 -----------------------------------
|
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
|
||||||
const aufgabe = await db.aufgabe.create({
|
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 Link from 'next/link'
|
||||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||||
|
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardBeschreibung,
|
CardBeschreibung,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
projekteZurAuswahlLaden,
|
projekteZurAuswahlLaden,
|
||||||
zaehlstaende,
|
zaehlstaende,
|
||||||
} from '@/server/abfragen/aufgaben'
|
} from '@/server/abfragen/aufgaben'
|
||||||
|
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
@@ -24,7 +26,8 @@ export default async function StartSeite() {
|
|||||||
const benutzer = await verlangeAnmeldung('/')
|
const benutzer = await verlangeAnmeldung('/')
|
||||||
const heute = jetzt()
|
const heute = jetzt()
|
||||||
|
|
||||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
|
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||||
|
await Promise.all([
|
||||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||||
bereicheLaden(),
|
bereicheLaden(),
|
||||||
projekteZurAuswahlLaden(),
|
projekteZurAuswahlLaden(),
|
||||||
@@ -33,6 +36,8 @@ export default async function StartSeite() {
|
|||||||
where: { aktiv: true },
|
where: { aktiv: true },
|
||||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
}),
|
}),
|
||||||
|
belegungHeute(benutzer.id, heute),
|
||||||
|
hatKalenderkonto(benutzer.id),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Was jetzt zählt: überfällig, heute oder morgen fällig.
|
// Was jetzt zählt: überfällig, heute oder morgen fällig.
|
||||||
@@ -108,9 +113,10 @@ export default async function StartSeite() {
|
|||||||
<ul className="grid gap-4 sm:grid-cols-2">
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
{bereichsListe.map((bereich) => {
|
{bereichsListe.map((bereich) => {
|
||||||
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||||
|
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
|
||||||
return (
|
return (
|
||||||
<li key={bereich.id}>
|
<li key={bereich.id}>
|
||||||
<Card>
|
<Card className="h-full">
|
||||||
<CardKopf className="flex-row items-center gap-3">
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -119,13 +125,33 @@ export default async function StartSeite() {
|
|||||||
/>
|
/>
|
||||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||||
</CardKopf>
|
</CardKopf>
|
||||||
<CardInhalt>
|
<CardInhalt className="flex flex-col gap-2">
|
||||||
<p className="text-gedaempft-vordergrund text-sm">
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
{offeneHier.length === 0
|
{offeneHier.length === 0
|
||||||
? 'Nichts offen'
|
? 'Nichts offen'
|
||||||
: `${offeneHier.length} offen`}{' '}
|
: `${offeneHier.length} offen`}{' '}
|
||||||
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||||
</p>
|
</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>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</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: '/projekte', name: 'Projekte' },
|
||||||
{ pfad: '/ideen', name: 'Ideen' },
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
|
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
{ 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_RP_NAME: z.string().min(1).default('Planer'),
|
||||||
WEBAUTHN_ORIGIN: z.url(),
|
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),
|
DEMO_MODUS: jaNein.default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,6 +48,9 @@ export type Umgebung = z.infer<typeof schema> & {
|
|||||||
instanz: Instanz
|
instanz: Instanz
|
||||||
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
|
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
|
||||||
oidcAktiv: boolean
|
oidcAktiv: boolean
|
||||||
|
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
||||||
|
graphAktiv: boolean
|
||||||
|
googleAktiv: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let zwischengespeichert: Umgebung | null = null
|
let zwischengespeichert: Umgebung | null = null
|
||||||
@@ -56,6 +70,8 @@ function lesen(): Umgebung {
|
|||||||
oidcAktiv: Boolean(
|
oidcAktiv: Boolean(
|
||||||
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET,
|
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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||