Compare commits
@@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer
|
||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||
|
||||
# --- Kalenderanbieter ------------------------------------------------------
|
||||
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
|
||||
# erst, wenn Kennung UND Geheimnis gesetzt sind.
|
||||
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
|
||||
# Rueckleit-URIs:
|
||||
# <APP_URL>/api/kalender/rueckleitung/graph
|
||||
# <APP_URL>/api/kalender/rueckleitung/google
|
||||
GRAPH_CLIENT_ID=
|
||||
GRAPH_CLIENT_SECRET=
|
||||
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||
GRAPH_TENANT_ID=organizations
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# --- Sonstiges -------------------------------------------------------------
|
||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
@@ -6,6 +6,66 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.4.0] – 2026-09-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
|
||||
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
|
||||
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
|
||||
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
|
||||
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
|
||||
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
|
||||
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
|
||||
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
|
||||
werden rot, sperren aber nichts.
|
||||
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
|
||||
wieder zurück, wenn nichts mehr für sie geplant ist.
|
||||
- Handbuchseite zur Wochenplanung.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
|
||||
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
|
||||
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
|
||||
endlos hinterherschreiben.
|
||||
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
|
||||
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
|
||||
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
|
||||
fremden Stand zu holen.
|
||||
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
|
||||
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
|
||||
|
||||
## [0.3.0] – 2026-09-10
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
|
||||
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
|
||||
`daheim`; in `firma` liefert die Route 404.
|
||||
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
|
||||
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
|
||||
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
|
||||
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
|
||||
allen verbundenen Kalendern gegen die Tageskapazität.
|
||||
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
|
||||
abgelaufene WebAuthn-Herausforderungen entfernen.
|
||||
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
|
||||
und die Google Cloud Console in der README.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
|
||||
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
|
||||
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
|
||||
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
|
||||
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
|
||||
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
|
||||
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
|
||||
enthält das Client-Secret oft im Echo.
|
||||
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
|
||||
Termininhalte.
|
||||
|
||||
## [0.2.0] – 2026-09-10
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -7,10 +7,11 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||
wird.
|
||||
|
||||
> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben,
|
||||
> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb.
|
||||
> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen
|
||||
> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
|
||||
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
|
||||
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||
|
||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||
zur ersten Anmeldung
|
||||
@@ -28,6 +29,10 @@ wird.
|
||||
nach Dringlichkeit und filterbar über die Adresszeile
|
||||
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
||||
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||
Bereich, wie voll der heutige Tag schon ist
|
||||
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
||||
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
@@ -38,20 +43,21 @@ wird.
|
||||
|
||||
### Geplant
|
||||
|
||||
Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen,
|
||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Bildergalerie
|
||||
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Woche |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Kalender |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
@@ -117,6 +123,19 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||
|
||||
### Kalenderanbieter
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
| ---------------------- | --------------- | ------------------------------------------------------------- |
|
||||
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
|
||||
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
|
||||
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
|
||||
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
|
||||
|
||||
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
||||
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
|
||||
|
||||
### Einrichtung und Sonstiges
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
@@ -126,6 +145,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
||||
|
||||
## Kalenderanbieter einrichten
|
||||
|
||||
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
|
||||
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
|
||||
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
|
||||
Berechtigung von Anfang an.
|
||||
|
||||
### Microsoft 365 (Entra ID)
|
||||
|
||||
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
|
||||
App-Registrierungen → Neue Registrierung**.
|
||||
2. Name frei wählen, etwa „Planer".
|
||||
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
|
||||
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
|
||||
**Konten in einem beliebigen Organisationsverzeichnis** (dann
|
||||
`organizations` belassen).
|
||||
4. Umleitungs-URI, Plattform **Web**:
|
||||
`<APP_URL>/api/kalender/rueckleitung/graph`
|
||||
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
|
||||
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
|
||||
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
|
||||
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
|
||||
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
|
||||
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
|
||||
`openid`, `email`.
|
||||
|
||||
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
|
||||
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
|
||||
|
||||
### Google Kalender
|
||||
|
||||
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
|
||||
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
|
||||
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
|
||||
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
|
||||
Testbenutzer.
|
||||
4. Bereiche (Scopes) hinzufügen:
|
||||
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
|
||||
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
|
||||
Anwendungstyp **Webanwendung**.
|
||||
6. Autorisierter Weiterleitungs-URI:
|
||||
`<APP_URL>/api/kalender/rueckleitung/google`
|
||||
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
|
||||
`GOOGLE_CLIENT_SECRET`.
|
||||
|
||||
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
|
||||
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
|
||||
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
|
||||
|
||||
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
|
||||
verbinden. Die Anleitung für den Alltag steht im
|
||||
[Handbuch](docs/handbuch/kalenderkonten.md).
|
||||
|
||||
## Betrieb
|
||||
|
||||
### Starten und stoppen
|
||||
|
||||
@@ -7,8 +7,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
| --------------------------------------- | ---------------------------- | -------- |
|
||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
||||
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||
@@ -63,26 +63,34 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
||||
|
||||
## M3 – Kalender lesen
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||
|
||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||
|
||||
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
||||
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||
- [ ] Hintergrundjob mit node-cron
|
||||
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||
- [x] Hintergrundjob mit node-cron
|
||||
|
||||
## M4 – Wochenplanung und Zeitblöcke
|
||||
|
||||
**Erledigt am 11.09.2026 mit Version 0.4.0.**
|
||||
|
||||
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
||||
Verschiebungen von dort zurückholen.
|
||||
|
||||
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||
- [ ] Wochenansicht mit `@dnd-kit/core`
|
||||
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||
- [ ] Belegung gegen die Tageskapazität des Bereichs
|
||||
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||
- [x] Wochenansicht mit `@dnd-kit/core`
|
||||
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||
- [x] Belegung gegen die Tageskapazität des Bereichs
|
||||
|
||||
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
|
||||
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
|
||||
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
|
||||
|
||||
## M5 – Benachrichtigungen
|
||||
|
||||
|
||||
@@ -9,7 +9,175 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
||||
| ----------- | ------ | ------- | -------- |
|
||||
| M1 | claude | 161 | 2,68 |
|
||||
| M2 | claude | 44 | 0,73 |
|
||||
| **Gesamt** | | **205** | **3,42** |
|
||||
| M3 | claude | 41 | 0,68 |
|
||||
| M4 | claude | 167 | 2,78 |
|
||||
| **Gesamt** | | **413** | **6,88** |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 – M4: Wochenplanung und Zeitblöcke
|
||||
|
||||
**Dauer:** 167 Minuten (20:42–23:29), Art `claude`.
|
||||
|
||||
**Ziel der Sitzung:** Die Kernidee des Projekts — Aufgaben per Ziehen und
|
||||
Ablegen in echte Kalendertermine verwandeln, mit Rückweg.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- `Zeitblock` samt Migration und Schleifenschutz (30 Tests)
|
||||
- Schreibweg zu Graph und Google mit `If-Match`, Rückweg im Abgleich
|
||||
- Rasterlogik für die Wochenansicht (21 Tests)
|
||||
- Wochenansicht mit `@dnd-kit/core`: einplanen, verschieben, ausplanen
|
||||
- Kapazität und Belegung je Wochentag
|
||||
- 3 neue E2E-Tests, Handbuchseite
|
||||
|
||||
### Entscheidungen mit Begründung
|
||||
|
||||
**Der Fingerabdruck geht nur über Beginn und Ende.** Titel und Beschreibung
|
||||
ändern sich auch aus harmlosen Gründen — etwa weil der Aufgabentitel
|
||||
bearbeitet wurde. Für die Planung zählt allein, wann der Block liegt; alles
|
||||
andere in den Fingerabdruck zu nehmen hieße, den Anbieter grundlos zu
|
||||
behelligen.
|
||||
|
||||
**Der Kalender gewinnt, ausnahmslos.** Wer im Kalender etwas verschiebt,
|
||||
meint es so. Säße der Planer am längeren Hebel, zöge er dem Benutzer den
|
||||
Termin wieder weg — und der wüsste nicht, warum. Bei HTTP 412 verwirft der
|
||||
Planer sogar seinen eigenen Fingerabdruck, damit der nächste Abgleich die
|
||||
fremde Änderung sicher als solche erkennt.
|
||||
|
||||
**Scheitert das Schreiben, überlebt die Planung.** Der Zeitblock bleibt mit
|
||||
vermerktem Fehler stehen. Ihn zu verwerfen hieße, die Planung des Benutzers
|
||||
wegen eines Netzproblems zu vergessen; das nächste Verschieben trägt ihn
|
||||
nach. Beim Löschen ist es umgekehrt: Da verschwindet der Zeitblock auch bei
|
||||
unerreichbarem Kalender, sonst wäre die Planung blockiert.
|
||||
|
||||
**Die Dauer kommt aus dem geschätzten Aufwand.** Wer schätzt, bekommt die
|
||||
passende Blockgröße geschenkt. Das belohnt genau die Gewohnheit, die die
|
||||
Planung überhaupt erst tragfähig macht.
|
||||
|
||||
**Ziehen beginnt erst nach acht Pixeln.** Ohne diese Schwelle löst jeder
|
||||
Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
|
||||
|
||||
**Überbuchte Tage werden rot, aber nicht gesperrt.** Der Planer ist ein
|
||||
Werkzeug, kein Aufpasser. Manchmal muss ein Tag überbucht sein.
|
||||
|
||||
### Probleme und Lösungen
|
||||
|
||||
| Problem | Lösung |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Ein Zeitblock steht auch im Terminspiegel und erschien doppelt — einmal als eigene Planung, einmal als fremder Termin | Die Wochenabfrage filtert Termine heraus, deren `extId` zu einem Zeitblock gehört. |
|
||||
| Ein Build lief 32 Minuten statt Sekunden | Systemlast durch einen gleichzeitigen Docker-Bau. Ein sauberer Neubau danach: 46 Sekunden. Kein Codeproblem. |
|
||||
| Sich überschneidende Termine lagen unsichtbar übereinander | `spurenVerteilen` legt sie nebeneinander und gibt eine Spur wieder frei, sobald sie zu Ende ist. |
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- **Der Schreibweg ist nicht gegen die echten APIs geprüft.** Wie in M3
|
||||
fehlen die Zugangsdaten. Die Logik ist getestet, der erste echte Schreib-
|
||||
und Rückweg ist Florians.
|
||||
- Die Dauer lässt sich nicht am Balken ziehen; wer eine andere will, ändert
|
||||
den Aufwand der Aufgabe.
|
||||
- Ein Zeitblock landet immer im ersten verbundenen Kalender. Das
|
||||
Standard-Kalenderkonto je Bereich ist im Datenmodell vorbereitet, aber
|
||||
noch nicht in der Oberfläche.
|
||||
|
||||
### Commits
|
||||
|
||||
- 5791047 feat(zeitbloecke): Datenmodell und Schleifenschutz
|
||||
- 9c1c916 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
|
||||
- bcb4b8e feat(woche): Rasterlogik für die Wochenansicht
|
||||
- fada445 feat(woche): Wochenansicht mit Ziehen und Ablegen
|
||||
- 5b1e998 test: Wochenansicht und Ausplanen absichern
|
||||
- 0974695 docs: Handbuchseite zur Wochenplanung
|
||||
|
||||
---
|
||||
|
||||
## 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,5 @@
|
||||
datum;start;ende;minuten;meilenstein;art;notiz
|
||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
||||
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
|
||||
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung 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: 267 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 239 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: 131 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: 102 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 121 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: 136 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: 118 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -16,10 +16,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
Fortschritt
|
||||
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||
verbinden, Belegung verstehen
|
||||
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||
Kalender legen
|
||||
|
||||
## Verwaltung
|
||||
|
||||
6. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
|
||||
## Was noch kommt
|
||||
|
||||
@@ -27,8 +31,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||
|
||||
| Thema | Meilenstein |
|
||||
| ------------------------------- | ----------- |
|
||||
| Kalenderkonten verbinden | M3 |
|
||||
| Wochenplanung und Zeitblöcke | M4 |
|
||||
| Benachrichtigungen | M5 |
|
||||
| Integrationen | M6 |
|
||||
| Schnellerfassung vom Handy, API | M7 |
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Wochenplanung und Zeitblöcke
|
||||
|
||||
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
|
||||
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
|
||||
|
||||

|
||||
|
||||
## Der Aufbau
|
||||
|
||||
| Wo | Was |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
|
||||
| **Rechts** | Sieben Tage im Viertelstundenraster |
|
||||
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
|
||||
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
|
||||
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
|
||||
|
||||
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
|
||||
Bereiche, mit einer Stunde Luft nach oben und unten.
|
||||
|
||||
## Eine Aufgabe einplanen
|
||||
|
||||
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
|
||||
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
|
||||
Kalender.
|
||||
|
||||
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
|
||||
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
|
||||
> es 60 Minuten, die du danach anpassen kannst.
|
||||
|
||||
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
|
||||
|
||||
## Verschieben
|
||||
|
||||
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
|
||||
Kalendertermin nach.
|
||||
|
||||
## Ausplanen
|
||||
|
||||
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
|
||||
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
|
||||
ist.
|
||||
|
||||
## Wenn du im Kalender verschiebst
|
||||
|
||||
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
|
||||
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
|
||||
|
||||
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
|
||||
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
|
||||
> und du wüsstest nicht, warum.
|
||||
>
|
||||
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
|
||||
> Aufgabe selbst bleibt.
|
||||
|
||||
### Warum sich das nicht aufschaukelt
|
||||
|
||||
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
|
||||
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
|
||||
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
|
||||
ab, hat jemand anderes verschoben.
|
||||
|
||||
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
|
||||
|
||||
## Das Warnzeichen im Block
|
||||
|
||||
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
|
||||
gerade nicht erreichbar.
|
||||
|
||||
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
|
||||
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
|
||||
Planer ihn nach.
|
||||
|
||||
## Die Zahl über der Spalte
|
||||
|
||||
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
|
||||
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
|
||||
|
||||
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
|
||||
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
|
||||
|
||||
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
|
||||
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||
|
||||
## Blättern
|
||||
|
||||
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
|
||||
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Was du siehst | Was dahintersteckt |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
|
||||
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
|
||||
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
|
||||
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
|
||||
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
|
||||
|
||||
## Was noch nicht geht
|
||||
|
||||
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
|
||||
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
|
||||
Oberfläche.
|
||||
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
|
||||
ändert den Aufwand der Aufgabe und plant neu ein.
|
||||
@@ -72,6 +72,11 @@ test.describe('Aufgaben', () => {
|
||||
await expect(inListe).toBeVisible()
|
||||
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
|
||||
|
||||
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
|
||||
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
|
||||
// gelegentlich schneller, als gespeichert wird.
|
||||
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
|
||||
|
||||
// Erledigtes fällt aus der Standardansicht heraus.
|
||||
await page.goto('/aufgaben')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
|
||||
/**
|
||||
* Wochenansicht und Zeitblöcke.
|
||||
*
|
||||
* Ohne echtes Kalenderkonto lässt sich der Schreibweg zum Anbieter nicht
|
||||
* prüfen — er scheitert dann und der Zeitblock trägt einen Fehler. Genau das
|
||||
* ist aber selbst prüfenswert: Die Planung muss auch dann überleben, wenn der
|
||||
* Kalender nicht erreichbar ist.
|
||||
*/
|
||||
|
||||
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
||||
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||
const PRAEFIX = `E2E-W-${Date.now()}`
|
||||
|
||||
const verbindung = process.env.DATABASE_URL ?? ''
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||
|
||||
test.describe('Woche', () => {
|
||||
test.skip(
|
||||
!EMAIL || !PASSWORT || !verbindung,
|
||||
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||
)
|
||||
|
||||
test.afterAll(async () => {
|
||||
await db.zeitblock.deleteMany({
|
||||
where: { aufgabe: { titel: { startsWith: PRAEFIX } } },
|
||||
})
|
||||
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: PRAEFIX } } })
|
||||
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'zeitblock.' } } })
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
async function anmelden(seite: Page): Promise<void> {
|
||||
await seite.goto('/anmelden')
|
||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
}
|
||||
|
||||
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
||||
await db.kalenderkonto.deleteMany({})
|
||||
await anmelden(page)
|
||||
await page.goto('/woche')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Woche' })).toBeVisible()
|
||||
await expect(page.getByText('Noch kein Kalender verbunden')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Ohne verbundenen Kalender lässt sich nichts'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('zeigt einen Zeitblock im Raster und plant ihn wieder aus', async ({ page }) => {
|
||||
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||
|
||||
// Ein Kalenderkonto ohne gültige Tokens: die Ansicht funktioniert, der
|
||||
// Schreibweg zum Anbieter nicht.
|
||||
const konto = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${PRAEFIX} Testkalender`,
|
||||
extKontoId: `${PRAEFIX}-konto`,
|
||||
},
|
||||
})
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: { titel: `${PRAEFIX} Angebot schreiben`, aufwandMinuten: 60 },
|
||||
})
|
||||
|
||||
// Zeitblock auf 10:00 Uhr des Montags dieser Woche legen.
|
||||
const jetzt = new Date()
|
||||
const montag = new Date(jetzt)
|
||||
montag.setUTCHours(0, 0, 0, 0)
|
||||
montag.setUTCDate(montag.getUTCDate() - ((montag.getUTCDay() + 6) % 7))
|
||||
const beginn = new Date(montag.getTime() + 10 * 60 * 60 * 1000)
|
||||
|
||||
await db.zeitblock.create({
|
||||
data: {
|
||||
aufgabeId: aufgabe.id,
|
||||
kalenderkontoId: konto.id,
|
||||
beginn,
|
||||
ende: new Date(beginn.getTime() + 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
await db.aufgabe.update({
|
||||
where: { id: aufgabe.id },
|
||||
data: { status: 'GEPLANT' },
|
||||
})
|
||||
|
||||
await anmelden(page)
|
||||
await page.goto('/woche')
|
||||
|
||||
const block = page.getByTitle(new RegExp(`${PRAEFIX} Angebot schreiben`))
|
||||
await expect(block).toBeVisible()
|
||||
|
||||
// Ausplanen: der Block verschwindet, die Aufgabe fällt zurück auf offen.
|
||||
await block.getByRole('button', { name: 'Ausplanen' }).click()
|
||||
await expect(page.getByText('ausgeplant')).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () => db.zeitblock.count({ where: { aufgabeId: aufgabe.id } }))
|
||||
.toBe(0)
|
||||
|
||||
const danach = await db.aufgabe.findUniqueOrThrow({ where: { id: aufgabe.id } })
|
||||
expect(danach.status).toBe('OFFEN')
|
||||
})
|
||||
|
||||
test('lässt sich durch die Wochen blättern', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await page.goto('/woche')
|
||||
|
||||
await page.getByRole('link', { name: 'Weiter' }).click()
|
||||
await expect(page).toHaveURL(/versatz=1/)
|
||||
|
||||
await page.getByRole('link', { name: 'Zurück' }).click()
|
||||
await expect(page).toHaveURL(/versatz=0/)
|
||||
|
||||
await page.getByRole('link', { name: 'Diese Woche' }).click()
|
||||
await expect(page).toHaveURL(/\/woche$/)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
@@ -593,6 +595,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electric-sql/pglite": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
@@ -40,6 +40,8 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
|
||||
@@ -327,6 +327,83 @@ 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',
|
||||
},
|
||||
})
|
||||
|
||||
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||||
await db.zeitblock.deleteMany({})
|
||||
const einzuplanen = await db.aufgabe.findMany({
|
||||
where: {
|
||||
titel: {
|
||||
in: [
|
||||
'Wochenbericht an die Geschäftsführung',
|
||||
'Angebot Heizungstausch nachfassen',
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, titel: true, aufwandMinuten: true },
|
||||
})
|
||||
|
||||
let blockStunde = 15
|
||||
for (const aufgabe of einzuplanen) {
|
||||
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||||
await db.zeitblock.create({
|
||||
data: {
|
||||
aufgabeId: aufgabe.id,
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
beginn,
|
||||
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||||
extId: `demo-block-${aufgabe.id}`,
|
||||
hash: 'demo',
|
||||
},
|
||||
})
|
||||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||
blockStunde += 2
|
||||
}
|
||||
|
||||
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kalenderkonto" (
|
||||
"id" TEXT NOT NULL,
|
||||
"benutzerId" TEXT NOT NULL,
|
||||
"anbieter" "KalenderAnbieter" NOT NULL,
|
||||
"anzeigename" TEXT NOT NULL,
|
||||
"extKontoId" TEXT NOT NULL,
|
||||
"kalenderId" TEXT,
|
||||
"zugriffstoken" TEXT,
|
||||
"aktualisierungstoken" TEXT,
|
||||
"tokenGueltigBis" TIMESTAMP(3),
|
||||
"bereiche" TEXT,
|
||||
"deltaVerweis" TEXT,
|
||||
"letzterAbgleich" TIMESTAMP(3),
|
||||
"letzterFehler" TEXT,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kalenderkontoId" TEXT NOT NULL,
|
||||
"extId" TEXT NOT NULL,
|
||||
"etag" TEXT,
|
||||
"betreff" TEXT,
|
||||
"beginn" TIMESTAMP(3) NOT NULL,
|
||||
"ende" TIMESTAMP(3) NOT NULL,
|
||||
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
|
||||
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
|
||||
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "zeitblock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"aufgabeId" TEXT NOT NULL,
|
||||
"kalenderkontoId" TEXT NOT NULL,
|
||||
"beginn" TIMESTAMP(3) NOT NULL,
|
||||
"ende" TIMESTAMP(3) NOT NULL,
|
||||
"extId" TEXT,
|
||||
"etag" TEXT,
|
||||
"hash" TEXT,
|
||||
"letzterFehler" TEXT,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -34,6 +34,7 @@ model Benutzer {
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
kalenderkonten Kalenderkonto[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("benutzer")
|
||||
@@ -121,6 +122,10 @@ model Bereich {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
|
||||
standardKalenderkontoId String?
|
||||
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
|
||||
|
||||
aufgaben Aufgabe[]
|
||||
projekte Projekt[]
|
||||
|
||||
@@ -219,6 +224,7 @@ model Aufgabe {
|
||||
|
||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
||||
zeitbloecke Zeitblock[]
|
||||
|
||||
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
|
||||
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
|
||||
@@ -252,3 +258,123 @@ model AuditEintrag {
|
||||
@@index([benutzerId])
|
||||
@@map("audit_eintrag")
|
||||
}
|
||||
|
||||
enum KalenderAnbieter {
|
||||
GRAPH
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
|
||||
enum ZeigtAls {
|
||||
FREI
|
||||
GEBUCHT
|
||||
MIT_VORBEHALT
|
||||
ABWESEND
|
||||
WOANDERS
|
||||
UNBEKANNT
|
||||
}
|
||||
|
||||
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
|
||||
model Kalenderkonto {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
anbieter KalenderAnbieter
|
||||
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
|
||||
anzeigename String
|
||||
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
|
||||
extKontoId String
|
||||
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
|
||||
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
|
||||
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
|
||||
kalenderId String @default("standard")
|
||||
|
||||
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
|
||||
zugriffstoken String?
|
||||
aktualisierungstoken String?
|
||||
tokenGueltigBis DateTime?
|
||||
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
|
||||
bereiche String?
|
||||
|
||||
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
|
||||
deltaVerweis String?
|
||||
letzterAbgleich DateTime?
|
||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||
letzterFehler String?
|
||||
|
||||
aktiv Boolean @default(true)
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
termine Termin[]
|
||||
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
||||
zeitblocks Zeitblock[]
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
|
||||
///
|
||||
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
|
||||
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
|
||||
model Zeitblock {
|
||||
id String @id @default(cuid())
|
||||
aufgabeId String
|
||||
kalenderkontoId String
|
||||
|
||||
beginn DateTime
|
||||
ende DateTime
|
||||
|
||||
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
|
||||
extId String?
|
||||
/// Fuer bedingte Schreibzugriffe (If-Match).
|
||||
etag String?
|
||||
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
|
||||
hash String?
|
||||
|
||||
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
|
||||
letzterFehler String?
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
|
||||
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
|
||||
@@unique([kalenderkontoId, extId])
|
||||
@@index([beginn, ende])
|
||||
@@index([aufgabeId])
|
||||
@@map("zeitblock")
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
{ name: 'start', pfad: '/' },
|
||||
{ name: 'inbox', pfad: '/inbox' },
|
||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||
{ name: 'woche', pfad: '/woche' },
|
||||
{ name: 'projekte', pfad: '/projekte' },
|
||||
{ name: 'ideen', pfad: '/ideen' },
|
||||
{ name: 'bereiche', pfad: '/bereiche' },
|
||||
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||
]
|
||||
|
||||
@@ -50,6 +50,9 @@ async function aufraeumen(): Promise<void> {
|
||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
||||
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
|
||||
// vorher abbricht, hier trotzdem noch einmal aufräumen.
|
||||
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
|
||||
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||
@@ -146,6 +149,102 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
)
|
||||
|
||||
// --- Kalenderkonto und Termine ------------------------------------------
|
||||
const kalenderkonto = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Firma Outlook`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
|
||||
kalenderkonto.kalenderId === 'standard',
|
||||
)
|
||||
pruefe(
|
||||
'Ein frisches Konto hat noch keinen Abgleichverweis',
|
||||
kalenderkonto.deltaVerweis === null,
|
||||
)
|
||||
|
||||
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
|
||||
db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Doppelt`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
|
||||
const zweiterKalender = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Zweiter Kalender`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
kalenderId: 'AAMkAD-zweiter',
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Dasselbe Konto mit anderem Kalender ist erlaubt',
|
||||
zweiterKalender.id !== kalenderkonto.id,
|
||||
)
|
||||
|
||||
const termin = await db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
betreff: 'Wochenbesprechung',
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
})
|
||||
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
|
||||
|
||||
await scheitert('Derselbe Termin je Konto nur einmal', () =>
|
||||
db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// So arbeitet der Abgleich: upsert statt anlegen.
|
||||
const gewandelterTermin = await db.termin.upsert({
|
||||
where: {
|
||||
kalenderkontoId_extId: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
},
|
||||
},
|
||||
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
|
||||
create: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
|
||||
gewandelterTermin.id === termin.id &&
|
||||
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
|
||||
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
|
||||
)
|
||||
|
||||
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
|
||||
await db.bereich.update({
|
||||
where: { id: bereich.id },
|
||||
data: { standardKalenderkontoId: kalenderkonto.id },
|
||||
})
|
||||
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
|
||||
|
||||
// --- Audit überlebt den Benutzer ----------------------------------------
|
||||
const audit = await db.auditEintrag.create({
|
||||
data: {
|
||||
@@ -177,6 +276,19 @@ async function main(): Promise<void> {
|
||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Kalenderkonten verschwinden mit dem Benutzer',
|
||||
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
|
||||
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos',
|
||||
(await db.bereich.findUnique({ where: { id: bereich.id } }))
|
||||
?.standardKalenderkontoId === null,
|
||||
)
|
||||
|
||||
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import {
|
||||
jetztAbgleichen,
|
||||
kontoTrennen,
|
||||
kontoUmbenennen,
|
||||
type Rueckmeldung,
|
||||
} from '@/server/aktionen/kalenderkonten'
|
||||
|
||||
export type KontoAnzeige = {
|
||||
id: string
|
||||
anbieter: 'GRAPH' | 'GOOGLE'
|
||||
anbieterName: string
|
||||
anzeigename: string
|
||||
extKontoId: string
|
||||
termine: number
|
||||
letzterAbgleich: string | null
|
||||
letzterFehler: string | null
|
||||
}
|
||||
|
||||
export function KontoKarte({ konto }: { konto: KontoAnzeige }) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [name, setName] = useState(konto.anzeigename)
|
||||
const [trennenBestaetigt, setTrennenBestaetigt] = useState(false)
|
||||
|
||||
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||
starte(async () => {
|
||||
const antwort = await tun()
|
||||
if (antwort.ok) {
|
||||
toast.success(antwort.meldung)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function felder(werte: Record<string, string>): FormData {
|
||||
const daten = new FormData()
|
||||
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
|
||||
return daten
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardKopf className="flex-row flex-wrap items-center gap-2">
|
||||
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
|
||||
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
|
||||
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
|
||||
</CardKopf>
|
||||
|
||||
<CardInhalt className="flex flex-col gap-4">
|
||||
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
|
||||
<dt className="text-gedaempft-vordergrund">Konto</dt>
|
||||
<dd>{konto.extKontoId}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
|
||||
<dd>{konto.termine}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
|
||||
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
|
||||
</dl>
|
||||
|
||||
{konto.letzterFehler && (
|
||||
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
|
||||
{konto.letzterFehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<label
|
||||
htmlFor={`name-${konto.id}`}
|
||||
className="text-gedaempft-vordergrund text-xs"
|
||||
>
|
||||
Bezeichnung
|
||||
</label>
|
||||
<Input
|
||||
id={`name-${konto.id}`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
disabled={laeuft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
art="umriss"
|
||||
disabled={laeuft || name.trim() === konto.anzeigename}
|
||||
onClick={() =>
|
||||
mitRueckmeldung(() =>
|
||||
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
|
||||
)
|
||||
}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
art="umriss"
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
|
||||
}
|
||||
>
|
||||
{laeuft ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RefreshCw aria-hidden />
|
||||
)}
|
||||
Jetzt abgleichen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
art="gefahr"
|
||||
className="ml-auto"
|
||||
disabled={laeuft}
|
||||
onBlur={() => setTrennenBestaetigt(false)}
|
||||
onClick={() => {
|
||||
if (!trennenBestaetigt) {
|
||||
setTrennenBestaetigt(true)
|
||||
return
|
||||
}
|
||||
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
|
||||
}}
|
||||
>
|
||||
<Unplug aria-hidden />
|
||||
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{trennenBestaetigt && (
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
|
||||
unangetastet.
|
||||
</p>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort
|
||||
* hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem
|
||||
* Neuladen erneut.
|
||||
*/
|
||||
export function Meldung({
|
||||
ok,
|
||||
fehler,
|
||||
}: {
|
||||
ok?: string | undefined
|
||||
fehler?: string | undefined
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const gezeigt = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (gezeigt.current) return
|
||||
if (!ok && !fehler) return
|
||||
|
||||
gezeigt.current = true
|
||||
if (ok) toast.success(ok)
|
||||
if (fehler) toast.error(fehler)
|
||||
router.replace('/kalenderkonten')
|
||||
}, [ok, fehler, router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { CalendarDays, Plus } from 'lucide-react'
|
||||
import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte'
|
||||
import { Meldung } from '@/app/(app)/kalenderkonten/meldung'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
CardInhalt,
|
||||
CardKopf,
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { erlaubteKalenderAnbieter } from '@/server/instanz'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
export const metadata: Metadata = { title: 'Kalenderkonten' }
|
||||
|
||||
const ANBIETER_NAME: Record<KalenderAnbieter, string> = {
|
||||
GRAPH: 'Microsoft 365',
|
||||
GOOGLE: 'Google',
|
||||
}
|
||||
|
||||
export default async function KalenderkontenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ ok?: string; fehler?: string }>
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung('/kalenderkonten')
|
||||
const parameter = await searchParams
|
||||
const einst = umgebung()
|
||||
|
||||
// Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie
|
||||
// benutzbar sind, hängt zusätzlich an der Konfiguration.
|
||||
const erlaubt = erlaubteKalenderAnbieter()
|
||||
const verfuegbar = erlaubt.filter((anbieter) =>
|
||||
anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv,
|
||||
)
|
||||
const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a))
|
||||
|
||||
const konten = await db.kalenderkonto.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
anbieter: true,
|
||||
anzeigename: true,
|
||||
extKontoId: true,
|
||||
letzterAbgleich: true,
|
||||
letzterFehler: true,
|
||||
_count: { select: { termine: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<CalendarDays className="size-6" aria-hidden />
|
||||
Kalenderkonten
|
||||
</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
|
||||
schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Kalender verbinden</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
|
||||
Der Planer speichert keine Zugangsdaten im Klartext.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
<CardInhalt className="flex flex-col gap-3">
|
||||
{verfuegbar.length === 0 ? (
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{verfuegbar.map((anbieter) => (
|
||||
<Button key={anbieter} alsKind>
|
||||
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
|
||||
<Plus aria-hidden />
|
||||
{ANBIETER_NAME[anbieter]} verbinden
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehlendKonfiguriert.length > 0 && (
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
|
||||
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
|
||||
Umgebungsvariablen.
|
||||
</p>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Verbundene Konten</h2>
|
||||
|
||||
{konten.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||
Noch kein Kalender verbunden.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
konten.map((konto) => (
|
||||
<KontoKarte
|
||||
key={konto.id}
|
||||
konto={{
|
||||
id: konto.id,
|
||||
anbieter: konto.anbieter,
|
||||
anbieterName: ANBIETER_NAME[konto.anbieter],
|
||||
anzeigename: konto.anzeigename,
|
||||
extKontoId: konto.extKontoId,
|
||||
termine: konto._count.termine,
|
||||
letzterAbgleich: konto.letzterAbgleich
|
||||
? alsDatumUhrzeit(konto.letzterAbgleich)
|
||||
: null,
|
||||
letzterFehler: konto.letzterFehler,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
projekteZurAuswahlLaden,
|
||||
zaehlstaende,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
@@ -24,7 +26,8 @@ export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const heute = jetzt()
|
||||
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||
await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
@@ -33,6 +36,8 @@ export default async function StartSeite() {
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
belegungHeute(benutzer.id, heute),
|
||||
hatKalenderkonto(benutzer.id),
|
||||
])
|
||||
|
||||
// Was jetzt zählt: überfällig, heute oder morgen fällig.
|
||||
@@ -108,9 +113,10 @@ export default async function StartSeite() {
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereichsListe.map((bereich) => {
|
||||
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
|
||||
return (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<Card className="h-full">
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -119,13 +125,33 @@ export default async function StartSeite() {
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<CardInhalt className="flex flex-col gap-2">
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{offeneHier.length === 0
|
||||
? 'Nichts offen'
|
||||
: `${offeneHier.length} offen`}{' '}
|
||||
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||
</p>
|
||||
|
||||
{/*
|
||||
Ohne verbundenen Kalender wäre die Belegung immer null –
|
||||
das sähe nach "alles frei" aus und wäre irreführend.
|
||||
*/}
|
||||
{mitKalender && heuteHier && (
|
||||
<>
|
||||
<Fortschrittsbalken
|
||||
prozent={heuteHier.prozent}
|
||||
beschriftung={`Belegung heute in ${bereich.name}`}
|
||||
/>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{heuteHier.kapazitaet === 0
|
||||
? 'Heute nicht eingeplant'
|
||||
: heuteHier.ueberbucht
|
||||
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
|
||||
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { CalendarRange } from 'lucide-react'
|
||||
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
|
||||
import { Card, CardInhalt } from '@/components/ui/card'
|
||||
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
|
||||
import {
|
||||
belegteMinuten,
|
||||
arbeitsfensterAmTag,
|
||||
kapazitaetAmTag,
|
||||
} from '@/lib/kalender/belegung'
|
||||
import { fensterHuelle, rasterBauen } from '@/lib/woche'
|
||||
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export const metadata: Metadata = { title: 'Woche' }
|
||||
|
||||
const WOCHENTAGE = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag',
|
||||
]
|
||||
|
||||
export default async function WocheSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ versatz?: string }>
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung('/woche')
|
||||
const parameter = await searchParams
|
||||
|
||||
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
|
||||
// bestimmte Woche als Lesezeichen taugt.
|
||||
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
|
||||
const sichereWochen = Number.isFinite(versatz)
|
||||
? Math.max(-52, Math.min(52, versatz))
|
||||
: 0
|
||||
const heute = jetzt()
|
||||
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
|
||||
|
||||
const [daten, aufgaben, bereiche, konto] = await Promise.all([
|
||||
wocheLaden(benutzer.id, zeitpunkt),
|
||||
planbareAufgabenLaden(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
|
||||
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
|
||||
db.kalenderkonto.findFirst({
|
||||
where: { benutzerId: benutzer.id, aktiv: true },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
select: { id: true, anzeigename: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const huelle = fensterHuelle(bereiche)
|
||||
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
|
||||
|
||||
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
|
||||
// Termin blockiert die Zeit für alles gleichermaßen.
|
||||
const spalten = raster.spalten.map((spalte) => {
|
||||
const tag = spalte.tagesBeginn
|
||||
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
|
||||
|
||||
const belegend = [
|
||||
...daten.termine.map((t) => ({
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
})),
|
||||
...daten.zeitbloecke.map((z) => ({
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
ganztaegig: false,
|
||||
zeigtAls: 'GEBUCHT' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
|
||||
// die Tageskapazität ist hier die Summe über alle Bereiche.
|
||||
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
|
||||
const belegt = belegteMinuten(belegend, fenster)
|
||||
|
||||
return {
|
||||
schluessel: spalte.schluessel,
|
||||
tagesBeginnMs: spalte.tagesBeginn.getTime(),
|
||||
name: WOCHENTAGE[spalte.wochentag] ?? '',
|
||||
datum: alsDatum(tag).split(', ')[1] ?? '',
|
||||
heute: gleicherTag(tag, heute),
|
||||
kapazitaet,
|
||||
belegt,
|
||||
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
|
||||
}
|
||||
})
|
||||
|
||||
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
|
||||
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
|
||||
|
||||
return (
|
||||
/*
|
||||
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
|
||||
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
|
||||
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
|
||||
100rem, mittig und mit demselben Seitenabstand wie überall.
|
||||
*/
|
||||
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<CalendarRange className="size-6" aria-hidden />
|
||||
Woche
|
||||
</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
{alsDatum(montag)} bis {alsDatum(sonntag)}
|
||||
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen - 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
<Link
|
||||
href="/woche"
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Diese Woche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen + 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Weiter
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{!konto && (
|
||||
<Card>
|
||||
<CardInhalt className="py-4 text-sm">
|
||||
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
|
||||
<Link
|
||||
href="/kalenderkonten"
|
||||
className="text-betont underline underline-offset-4"
|
||||
>
|
||||
jetzt verbinden
|
||||
</Link>
|
||||
.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Wochenraster
|
||||
spalten={spalten}
|
||||
zeilen={raster.zeilen}
|
||||
vonMinute={raster.vonMinute}
|
||||
bisMinute={raster.bisMinute}
|
||||
kalenderkontoId={konto?.id ?? null}
|
||||
zeitbloecke={daten.zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginnMs: z.beginn.getTime(),
|
||||
endeMs: z.ende.getTime(),
|
||||
titel: z.titel,
|
||||
bereichFarbe: z.bereichFarbe,
|
||||
kalenderName: z.kalenderName,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.offen,
|
||||
}))}
|
||||
termine={daten.termine.map((t) => ({
|
||||
id: t.id,
|
||||
beginnMs: t.beginn.getTime(),
|
||||
endeMs: t.ende.getTime(),
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
frei: t.zeigtAls === 'FREI',
|
||||
kalenderName: t.kalenderName,
|
||||
}))}
|
||||
aufgaben={aufgaben.map((a) => ({
|
||||
id: a.id,
|
||||
titel: a.titel,
|
||||
aufwandMinuten: a.aufwandMinuten,
|
||||
bereichName: a.bereich?.name ?? null,
|
||||
bereichFarbe: a.bereich?.farbe ?? null,
|
||||
faelligText: a.faelligAm
|
||||
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
|
||||
: null,
|
||||
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
|
||||
bereitsGeplant: a._count.zeitbloecke,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
||||
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
|
||||
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import {
|
||||
ZEILE_HOEHE,
|
||||
balkenlage,
|
||||
spurenVerteilen,
|
||||
zeilenBeschriftung,
|
||||
zeitpunktAus,
|
||||
type Raster,
|
||||
} from '@/lib/woche'
|
||||
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
zeitblockAnlegen,
|
||||
zeitblockLoesen,
|
||||
zeitblockVerschieben,
|
||||
type Rueckmeldung,
|
||||
} from '@/server/aktionen/zeitbloecke'
|
||||
|
||||
export type PlanbareAufgabe = {
|
||||
id: string
|
||||
titel: string
|
||||
aufwandMinuten: number | null
|
||||
bereichName: string | null
|
||||
bereichFarbe: string | null
|
||||
faelligText: string | null
|
||||
ueberfaellig: boolean
|
||||
bereitsGeplant: number
|
||||
}
|
||||
|
||||
export type RasterZeitblock = {
|
||||
id: string
|
||||
beginnMs: number
|
||||
endeMs: number
|
||||
titel: string
|
||||
bereichFarbe: string | null
|
||||
kalenderName: string
|
||||
letzterFehler: string | null
|
||||
offen: boolean
|
||||
}
|
||||
|
||||
export type RasterTermin = {
|
||||
id: string
|
||||
beginnMs: number
|
||||
endeMs: number
|
||||
betreff: string | null
|
||||
ganztaegig: boolean
|
||||
frei: boolean
|
||||
kalenderName: string
|
||||
}
|
||||
|
||||
export type RasterSpalte = {
|
||||
schluessel: string
|
||||
tagesBeginnMs: number
|
||||
name: string
|
||||
datum: string
|
||||
heute: boolean
|
||||
kapazitaet: number
|
||||
belegt: number
|
||||
ueberbucht: boolean
|
||||
}
|
||||
|
||||
type Eigenschaften = {
|
||||
spalten: RasterSpalte[]
|
||||
zeilen: number[]
|
||||
vonMinute: number
|
||||
bisMinute: number
|
||||
zeitbloecke: RasterZeitblock[]
|
||||
termine: RasterTermin[]
|
||||
aufgaben: PlanbareAufgabe[]
|
||||
kalenderkontoId: string | null
|
||||
}
|
||||
|
||||
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
|
||||
const STANDARD_MINUTEN = 60
|
||||
|
||||
export function Wochenraster({
|
||||
spalten,
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
zeitbloecke,
|
||||
termine,
|
||||
aufgaben,
|
||||
kalenderkontoId,
|
||||
}: Eigenschaften) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [gezogen, setGezogen] = useState<string | null>(null)
|
||||
|
||||
const sensoren = useSensors(
|
||||
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
|
||||
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
)
|
||||
|
||||
const raster: Raster = {
|
||||
spalten: spalten.map((s) => ({
|
||||
tagesBeginn: new Date(s.tagesBeginnMs),
|
||||
wochentag: 0,
|
||||
schluessel: s.schluessel,
|
||||
})),
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
}
|
||||
|
||||
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||
starte(async () => {
|
||||
const antwort = await tun()
|
||||
if (antwort.ok) toast.success(antwort.meldung)
|
||||
else toast.error(antwort.fehler)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
function beimStart(ereignis: DragStartEvent) {
|
||||
setGezogen(String(ereignis.active.id))
|
||||
}
|
||||
|
||||
function beimEnde(ereignis: DragEndEvent) {
|
||||
setGezogen(null)
|
||||
const ziel = ereignis.over?.id
|
||||
if (!ziel) return
|
||||
|
||||
const [schluessel, minuteRoh] = String(ziel).split('@')
|
||||
if (!schluessel || minuteRoh === undefined) return
|
||||
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
|
||||
|
||||
const kennung = String(ereignis.active.id)
|
||||
|
||||
if (kennung.startsWith('aufgabe:')) {
|
||||
if (!kalenderkontoId) {
|
||||
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
|
||||
return
|
||||
}
|
||||
const aufgabeId = kennung.slice('aufgabe:'.length)
|
||||
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
|
||||
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
|
||||
const ende = new Date(beginn.getTime() + dauer * 60_000)
|
||||
|
||||
const daten = new FormData()
|
||||
daten.set('aufgabeId', aufgabeId)
|
||||
daten.set('kalenderkontoId', kalenderkontoId)
|
||||
daten.set('beginn', beginn.toISOString())
|
||||
daten.set('ende', ende.toISOString())
|
||||
mitRueckmeldung(() => zeitblockAnlegen(daten))
|
||||
return
|
||||
}
|
||||
|
||||
if (kennung.startsWith('block:')) {
|
||||
const id = kennung.slice('block:'.length)
|
||||
const block = zeitbloecke.find((z) => z.id === id)
|
||||
if (!block) return
|
||||
const dauer = block.endeMs - block.beginnMs
|
||||
const ende = new Date(beginn.getTime() + dauer)
|
||||
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
daten.set('beginn', beginn.toISOString())
|
||||
daten.set('ende', ende.toISOString())
|
||||
mitRueckmeldung(() => zeitblockVerschieben(daten))
|
||||
}
|
||||
}
|
||||
|
||||
function ausplanen(id: string) {
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
mitRueckmeldung(() => zeitblockLoesen(daten))
|
||||
}
|
||||
|
||||
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
|
||||
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
|
||||
: null
|
||||
const gezogenerBlock = gezogen?.startsWith('block:')
|
||||
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
|
||||
: null
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{/* Vorrat: was noch eingeplant werden kann */}
|
||||
<aside className="lg:w-72 lg:shrink-0">
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
Einzuplanen
|
||||
<span className="text-gedaempft-vordergrund ml-2 font-normal">
|
||||
{aufgaben.length}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{!kalenderkontoId && (
|
||||
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
|
||||
Ohne verbundenen Kalender lässt sich nichts einplanen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
|
||||
{aufgaben.length === 0 ? (
|
||||
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
|
||||
Nichts offen. Ein guter Tag.
|
||||
</li>
|
||||
) : (
|
||||
aufgaben.map((aufgabe) => (
|
||||
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Das Wochenraster */}
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="min-w-[46rem]">
|
||||
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||
<div />
|
||||
{spalten.map((spalte) => (
|
||||
<div
|
||||
key={spalte.schluessel}
|
||||
className={cn(
|
||||
'border-rand border-b px-1 pb-2 text-center',
|
||||
spalte.heute && 'bg-betont/5',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{spalte.name}</p>
|
||||
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs',
|
||||
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
|
||||
)}
|
||||
>
|
||||
{spalte.kapazitaet === 0
|
||||
? '—'
|
||||
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||
{/* Stundenleiste */}
|
||||
<div className="relative">
|
||||
{zeilen.map((minute) => (
|
||||
<div
|
||||
key={minute}
|
||||
style={{ height: ZEILE_HOEHE }}
|
||||
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
|
||||
>
|
||||
{zeilenBeschriftung(minute)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{spalten.map((spalte) => {
|
||||
const blockeHier = zeitbloecke.filter(
|
||||
(z) =>
|
||||
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||
z.endeMs > spalte.tagesBeginnMs,
|
||||
)
|
||||
const termineHier = termine.filter(
|
||||
(t) =>
|
||||
!t.ganztaegig &&
|
||||
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||
t.endeMs > spalte.tagesBeginnMs,
|
||||
)
|
||||
|
||||
const verteilteTermine = spurenVerteilen(
|
||||
termineHier.map((t) => ({
|
||||
...t,
|
||||
beginn: new Date(t.beginnMs),
|
||||
ende: new Date(t.endeMs),
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={spalte.schluessel}
|
||||
className={cn(
|
||||
'border-rand relative border-l',
|
||||
spalte.heute && 'bg-betont/5',
|
||||
)}
|
||||
>
|
||||
{zeilen.map((minute) => (
|
||||
<Zielfeld
|
||||
key={minute}
|
||||
kennung={`${spalte.schluessel}@${minute}`}
|
||||
volleStunde={minute % 60 === 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
|
||||
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
|
||||
const lage = balkenlage(
|
||||
eintrag.beginn,
|
||||
eintrag.ende,
|
||||
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||
raster,
|
||||
)
|
||||
if (lage.ausserhalb) return null
|
||||
return (
|
||||
<div
|
||||
key={eintrag.id}
|
||||
className={cn(
|
||||
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
|
||||
eintrag.frei
|
||||
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||
: 'bg-gedaempft text-gedaempft-vordergrund',
|
||||
)}
|
||||
style={{
|
||||
top: lage.oben,
|
||||
height: lage.hoehe,
|
||||
left: `${(spur / spuren) * 100}%`,
|
||||
width: `${100 / spuren}%`,
|
||||
}}
|
||||
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
||||
>
|
||||
<span className="line-clamp-2">
|
||||
{eintrag.betreff ?? 'Termin'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
|
||||
{blockeHier.map((block) => {
|
||||
const lage = balkenlage(
|
||||
new Date(block.beginnMs),
|
||||
new Date(block.endeMs),
|
||||
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||
raster,
|
||||
)
|
||||
if (lage.ausserhalb) return null
|
||||
return (
|
||||
<Zeitblockbalken
|
||||
key={block.id}
|
||||
block={block}
|
||||
oben={lage.oben}
|
||||
hoehe={lage.hoehe}
|
||||
gesperrt={laeuft}
|
||||
onAusplanen={() => ausplanen(block.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{gezogeneAufgabe && (
|
||||
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||
{gezogeneAufgabe.titel}
|
||||
<span className="ml-1 opacity-80">
|
||||
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{gezogenerBlock && (
|
||||
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||
{gezogenerBlock.titel}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: kennung })
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ height: ZEILE_HOEHE }}
|
||||
className={cn(
|
||||
'border-rand/40',
|
||||
volleStunde ? 'border-t' : 'border-t border-dashed',
|
||||
isOver && 'bg-betont/20',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AufgabenKachel({
|
||||
aufgabe,
|
||||
gesperrt,
|
||||
}: {
|
||||
aufgabe: PlanbareAufgabe
|
||||
gesperrt: boolean
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `aufgabe:${aufgabe.id}`,
|
||||
disabled: gesperrt,
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
|
||||
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-tight">{aufgabe.titel}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{aufgabe.bereichName && (
|
||||
<Kennzeichen>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
|
||||
/>
|
||||
{aufgabe.bereichName}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
<Kennzeichen>
|
||||
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||
</Kennzeichen>
|
||||
{aufgabe.faelligText && (
|
||||
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
|
||||
{aufgabe.faelligText}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
{aufgabe.bereitsGeplant > 0 && (
|
||||
<Kennzeichen art="betont">
|
||||
<CalendarClock className="size-3" aria-hidden />
|
||||
{aufgabe.bereitsGeplant}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeitblockbalken({
|
||||
block,
|
||||
oben,
|
||||
hoehe,
|
||||
gesperrt,
|
||||
onAusplanen,
|
||||
}: {
|
||||
block: RasterZeitblock
|
||||
oben: number
|
||||
hoehe: number
|
||||
gesperrt: boolean
|
||||
onAusplanen: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `block:${block.id}`,
|
||||
disabled: gesperrt,
|
||||
})
|
||||
|
||||
const farbe = block.bereichFarbe ?? 'var(--betont)'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
top: oben,
|
||||
height: hoehe,
|
||||
borderLeftColor: farbe,
|
||||
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
|
||||
>
|
||||
<div className="flex items-start gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||
aria-label={`${block.titel} verschieben`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="size-3 opacity-60" aria-hidden />
|
||||
</button>
|
||||
|
||||
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
|
||||
|
||||
{(block.offen || block.letzterFehler) && (
|
||||
<AlertTriangle
|
||||
className="text-warnung size-3 shrink-0"
|
||||
aria-label="Noch nicht im Kalender"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
art="leise"
|
||||
className="size-4 shrink-0 p-0"
|
||||
disabled={gesperrt}
|
||||
title="Ausplanen"
|
||||
onClick={onAusplanen}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
<span className="sr-only">Ausplanen</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,11 @@ export function Kopfleiste({
|
||||
{ pfad: '/', name: 'Start' },
|
||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||
{ pfad: '/woche', name: 'Woche' },
|
||||
{ pfad: '/projekte', name: 'Projekte' },
|
||||
{ pfad: '/ideen', name: 'Ideen' },
|
||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Wird von Next einmal beim Hochfahren des Servers ausgeführt.
|
||||
*
|
||||
* Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch
|
||||
* node-cron. Der Demo-Modus startet bewusst keine Jobs — die
|
||||
* Screenshot-Instanz soll sich nicht selbst verändern, während sie
|
||||
* abfotografiert wird.
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME !== 'nodejs') return
|
||||
if (process.env.DEMO_MODUS === 'true') return
|
||||
|
||||
const { jobsStarten } = await import('@/server/jobs')
|
||||
jobsStarten()
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arbeitsfensterAmTag,
|
||||
belegteMinuten,
|
||||
belegtZeit,
|
||||
freieFenster,
|
||||
kapazitaetAmTag,
|
||||
tagesBelegung,
|
||||
termineAmTag,
|
||||
ueberschneidungMinuten,
|
||||
type BelegenderTermin,
|
||||
type BereichKapazitaet,
|
||||
} from './belegung'
|
||||
|
||||
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
const bereich: BereichKapazitaet = {
|
||||
kapazitaetMo: 360,
|
||||
kapazitaetDi: 240,
|
||||
kapazitaetMi: 360,
|
||||
kapazitaetDo: 360,
|
||||
kapazitaetFr: 120,
|
||||
kapazitaetSa: 0,
|
||||
kapazitaetSo: 0,
|
||||
// 08:00 bis 17:00 Berliner Zeit.
|
||||
arbeitsfensterVon: 480,
|
||||
arbeitsfensterBis: 1020,
|
||||
}
|
||||
|
||||
function termin(
|
||||
vonUtc: string,
|
||||
bisUtc: string,
|
||||
teil: Partial<BelegenderTermin> = {},
|
||||
): BelegenderTermin {
|
||||
return {
|
||||
beginn: new Date(vonUtc),
|
||||
ende: new Date(bisUtc),
|
||||
ganztaegig: false,
|
||||
zeigtAls: 'GEBUCHT',
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe('belegtZeit', () => {
|
||||
it('zählt einen gewöhnlichen Termin', () => {
|
||||
expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt ganztägige Termine nicht', () => {
|
||||
// Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster.
|
||||
expect(
|
||||
belegtZeit(
|
||||
termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('zählt als frei markierte Termine nicht', () => {
|
||||
expect(
|
||||
belegtZeit(
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('zählt Vorbehalt und Abwesenheit sehr wohl', () => {
|
||||
for (const zeigtAls of [
|
||||
'MIT_VORBEHALT',
|
||||
'ABWESEND',
|
||||
'WOANDERS',
|
||||
'UNBEKANNT',
|
||||
] as const) {
|
||||
expect(
|
||||
belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ueberschneidungMinuten', () => {
|
||||
const fenster = {
|
||||
von: new Date('2026-03-17T08:00:00Z'),
|
||||
bis: new Date('2026-03-17T12:00:00Z'),
|
||||
}
|
||||
|
||||
it('rechnet eine vollständige Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('schneidet an den Rändern ab', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('ist ohne Überschneidung null', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbeitsfensterAmTag', () => {
|
||||
it('legt das Fenster in Berliner Ortszeit', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
// 08:00 Berlin = 07:00 UTC in der Winterzeit.
|
||||
expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
|
||||
expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
|
||||
})
|
||||
|
||||
it('verschiebt sich mit der Sommerzeit', () => {
|
||||
// Ab dem 29. März 2026 gilt in Berlin UTC+2.
|
||||
const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020)
|
||||
expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z')
|
||||
expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('kapazitaetAmTag', () => {
|
||||
it('nimmt den richtigen Wochentag', () => {
|
||||
expect(kapazitaetAmTag(bereich, dienstag)).toBe(240)
|
||||
// Montag, 16. März.
|
||||
expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360)
|
||||
// Sonntag, 22. März.
|
||||
expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('belegteMinuten', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
|
||||
it('ist ohne Termine null', () => {
|
||||
expect(belegteMinuten([], fenster)).toBe(0)
|
||||
})
|
||||
|
||||
it('summiert getrennte Termine', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(120)
|
||||
})
|
||||
|
||||
it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(180)
|
||||
})
|
||||
|
||||
it('legt lückenlos aneinandergrenzende Termine zusammen', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(120)
|
||||
})
|
||||
|
||||
it('zählt einen vollständig enthaltenen Termin nur einmal', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(240)
|
||||
})
|
||||
|
||||
it('schneidet an den Rändern des Arbeitsfensters ab', () => {
|
||||
// 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt.
|
||||
expect(
|
||||
belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('lässt Termine außerhalb des Fensters ganz weg', () => {
|
||||
// 20:00 Berlin, das Fenster endet um 17:00.
|
||||
expect(
|
||||
belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('lässt ganztägige und freie Termine weg', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }),
|
||||
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagesBelegung', () => {
|
||||
it('rechnet Kapazität und Termine gegeneinander', () => {
|
||||
const belegung = tagesBelegung(
|
||||
bereich,
|
||||
[termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')],
|
||||
dienstag,
|
||||
)
|
||||
expect(belegung.kapazitaet).toBe(240)
|
||||
expect(belegung.belegt).toBe(120)
|
||||
expect(belegung.frei).toBe(120)
|
||||
expect(belegung.prozent).toBe(50)
|
||||
expect(belegung.ueberbucht).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt einen überbuchten Tag', () => {
|
||||
const belegung = tagesBelegung(
|
||||
bereich,
|
||||
[termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')],
|
||||
dienstag,
|
||||
)
|
||||
expect(belegung.belegt).toBe(360)
|
||||
expect(belegung.frei).toBe(0)
|
||||
expect(belegung.ueberbucht).toBe(true)
|
||||
expect(belegung.prozent).toBe(150)
|
||||
})
|
||||
|
||||
it('behandelt einen Tag ohne Kapazität als voll', () => {
|
||||
// Sonntag: keine Kapazität, also gibt es dort nichts zu planen.
|
||||
const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z'))
|
||||
expect(belegung.kapazitaet).toBe(0)
|
||||
expect(belegung.prozent).toBe(100)
|
||||
expect(belegung.frei).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('freieFenster', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
|
||||
it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => {
|
||||
const luecken = freieFenster([], fenster)
|
||||
expect(luecken).toHaveLength(1)
|
||||
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
|
||||
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
|
||||
})
|
||||
|
||||
it('findet die Lücke zwischen zwei Terminen', () => {
|
||||
const luecken = freieFenster(
|
||||
[
|
||||
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
)
|
||||
expect(luecken).toHaveLength(1)
|
||||
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z')
|
||||
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z')
|
||||
})
|
||||
|
||||
it('lässt zu kurze Lücken weg', () => {
|
||||
const luecken = freieFenster(
|
||||
[
|
||||
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
// Nur zehn Minuten Pause.
|
||||
termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
15,
|
||||
)
|
||||
expect(luecken).toEqual([])
|
||||
})
|
||||
|
||||
it('ist bei durchgehender Belegung leer', () => {
|
||||
expect(
|
||||
freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('übergeht ganztägige Termine und findet den Tag frei', () => {
|
||||
const luecken = freieFenster(
|
||||
[termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })],
|
||||
fenster,
|
||||
)
|
||||
expect(luecken).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('termineAmTag', () => {
|
||||
it('nimmt, was den Berliner Kalendertag berührt', () => {
|
||||
const liste = [
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'),
|
||||
]
|
||||
expect(termineAmTag(liste, dienstag)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('nimmt auch einen Termin, der in den Tag hineinragt', () => {
|
||||
const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z')
|
||||
expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => {
|
||||
// 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag.
|
||||
const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z')
|
||||
expect(termineAmTag([davor], dienstag)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit'
|
||||
|
||||
/**
|
||||
* Wie voll ein Tag schon ist.
|
||||
*
|
||||
* Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in
|
||||
* Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer.
|
||||
*/
|
||||
|
||||
export type BelegenderTermin = {
|
||||
beginn: Date
|
||||
ende: Date
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
}
|
||||
|
||||
export type Zeitfenster = { von: Date; bis: Date }
|
||||
|
||||
/**
|
||||
* Zählt ein Termin gegen die Kapazität?
|
||||
*
|
||||
* Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender
|
||||
* blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||
* Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind.
|
||||
*/
|
||||
export function belegtZeit(termin: BelegenderTermin): boolean {
|
||||
if (termin.ganztaegig) return false
|
||||
if (termin.zeigtAls === 'FREI') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */
|
||||
export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number {
|
||||
const von = Math.max(a.von.getTime(), b.von.getTime())
|
||||
const bis = Math.min(a.bis.getTime(), b.bis.getTime())
|
||||
if (bis <= von) return 0
|
||||
return Math.round((bis - von) / 60_000)
|
||||
}
|
||||
|
||||
/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */
|
||||
export function arbeitsfensterAmTag(
|
||||
tag: Date,
|
||||
arbeitsfensterVon: number,
|
||||
arbeitsfensterBis: number,
|
||||
): Zeitfenster {
|
||||
const beginn = tagesBeginn(tag)
|
||||
return {
|
||||
von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000),
|
||||
bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine
|
||||
* belegt sind.
|
||||
*
|
||||
* Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht
|
||||
* doppelt zählt.
|
||||
*/
|
||||
export function belegteMinuten(
|
||||
termine: readonly BelegenderTermin[],
|
||||
fenster: Zeitfenster,
|
||||
): number {
|
||||
const abschnitte = termine
|
||||
.filter(belegtZeit)
|
||||
.map((t) => ({ von: t.beginn, bis: t.ende }))
|
||||
.filter((t) => ueberschneidungMinuten(t, fenster) > 0)
|
||||
.map((t) => ({
|
||||
von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())),
|
||||
bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())),
|
||||
}))
|
||||
.sort((a, b) => a.von.getTime() - b.von.getTime())
|
||||
|
||||
let summe = 0
|
||||
let offenVon: Date | null = null
|
||||
let offenBis: Date | null = null
|
||||
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (offenVon === null || offenBis === null) {
|
||||
offenVon = abschnitt.von
|
||||
offenBis = abschnitt.bis
|
||||
continue
|
||||
}
|
||||
if (abschnitt.von.getTime() <= offenBis.getTime()) {
|
||||
// Überlappt oder schließt an – zusammenlegen.
|
||||
if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis
|
||||
continue
|
||||
}
|
||||
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
|
||||
offenVon = abschnitt.von
|
||||
offenBis = abschnitt.bis
|
||||
}
|
||||
|
||||
if (offenVon !== null && offenBis !== null) {
|
||||
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
|
||||
}
|
||||
|
||||
return summe
|
||||
}
|
||||
|
||||
export type TagesBelegung = {
|
||||
/** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */
|
||||
kapazitaet: number
|
||||
/** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */
|
||||
belegt: number
|
||||
/** Was von der Kapazität übrig bleibt; nie negativ. */
|
||||
frei: number
|
||||
/** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */
|
||||
prozent: number
|
||||
/** Mehr Termine als Kapazität – der Tag ist überbucht. */
|
||||
ueberbucht: boolean
|
||||
}
|
||||
|
||||
export type BereichKapazitaet = {
|
||||
kapazitaetMo: number
|
||||
kapazitaetDi: number
|
||||
kapazitaetMi: number
|
||||
kapazitaetDo: number
|
||||
kapazitaetFr: number
|
||||
kapazitaetSa: number
|
||||
kapazitaetSo: number
|
||||
arbeitsfensterVon: number
|
||||
arbeitsfensterBis: number
|
||||
}
|
||||
|
||||
const KAPAZITAET_JE_TAG = [
|
||||
'kapazitaetMo',
|
||||
'kapazitaetDi',
|
||||
'kapazitaetMi',
|
||||
'kapazitaetDo',
|
||||
'kapazitaetFr',
|
||||
'kapazitaetSa',
|
||||
'kapazitaetSo',
|
||||
] as const
|
||||
|
||||
/** Kapazität des Bereichs an genau diesem Tag. */
|
||||
export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number {
|
||||
const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)]
|
||||
return feld ? bereich[feld] : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet Kapazität und Termine eines Tages gegeneinander.
|
||||
*
|
||||
* Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr
|
||||
* verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr
|
||||
* endet.
|
||||
*/
|
||||
export function tagesBelegung(
|
||||
bereich: BereichKapazitaet,
|
||||
termine: readonly BelegenderTermin[],
|
||||
tag: Date,
|
||||
): TagesBelegung {
|
||||
const kapazitaet = kapazitaetAmTag(bereich, tag)
|
||||
const fenster = arbeitsfensterAmTag(
|
||||
tag,
|
||||
bereich.arbeitsfensterVon,
|
||||
bereich.arbeitsfensterBis,
|
||||
)
|
||||
const belegt = belegteMinuten(termine, fenster)
|
||||
|
||||
return {
|
||||
kapazitaet,
|
||||
belegt,
|
||||
frei: Math.max(0, kapazitaet - belegt),
|
||||
prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100),
|
||||
ueberbucht: belegt > kapazitaet,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken,
|
||||
* in die ab M4 Zeitblöcke gelegt werden können.
|
||||
*/
|
||||
export function freieFenster(
|
||||
termine: readonly BelegenderTermin[],
|
||||
fenster: Zeitfenster,
|
||||
mindestensMinuten = 15,
|
||||
): Zeitfenster[] {
|
||||
const belegt = termine
|
||||
.filter(belegtZeit)
|
||||
.map((t) => ({
|
||||
von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())),
|
||||
bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())),
|
||||
}))
|
||||
.filter((t) => t.bis.getTime() > t.von.getTime())
|
||||
.sort((a, b) => a.von.getTime() - b.von.getTime())
|
||||
|
||||
const luecken: Zeitfenster[] = []
|
||||
let cursor = fenster.von
|
||||
|
||||
for (const abschnitt of belegt) {
|
||||
if (abschnitt.von.getTime() > cursor.getTime()) {
|
||||
luecken.push({ von: cursor, bis: abschnitt.von })
|
||||
}
|
||||
if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis
|
||||
}
|
||||
|
||||
if (cursor.getTime() < fenster.bis.getTime()) {
|
||||
luecken.push({ von: cursor, bis: fenster.bis })
|
||||
}
|
||||
|
||||
return luecken.filter(
|
||||
(l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten,
|
||||
)
|
||||
}
|
||||
|
||||
/** Termine, die an diesem Kalendertag überhaupt vorkommen. */
|
||||
export function termineAmTag<T extends { beginn: Date; ende: Date }>(
|
||||
termine: readonly T[],
|
||||
tag: Date,
|
||||
): T[] {
|
||||
const von = tagesBeginn(tag).getTime()
|
||||
const bis = tagesEnde(tag).getTime()
|
||||
return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google'
|
||||
import { AntwortUnbrauchbar } from './typen'
|
||||
|
||||
function fixture(name: string): unknown {
|
||||
return JSON.parse(
|
||||
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
|
||||
) as unknown
|
||||
}
|
||||
|
||||
describe('zeigtAlsAusGoogle', () => {
|
||||
it('kennt nur frei und gebucht', () => {
|
||||
expect(zeigtAlsAusGoogle('transparent')).toBe('FREI')
|
||||
expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT')
|
||||
})
|
||||
|
||||
it('nimmt ein fehlendes Feld als gebucht', () => {
|
||||
// Google lässt transparency weg, wenn der Termin die Zeit belegt.
|
||||
expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleZeitpunkt', () => {
|
||||
it('liest eine Zeit mit Versatz', () => {
|
||||
const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' })
|
||||
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z')
|
||||
expect(ergebnis.ganztaegig).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt ein reines Datum als ganztägig', () => {
|
||||
const ergebnis = googleZeitpunkt({ date: '2026-03-20' })
|
||||
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z')
|
||||
expect(ergebnis.ganztaegig).toBe(true)
|
||||
})
|
||||
|
||||
it('bricht ab, wenn beides fehlt', () => {
|
||||
expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('bricht bei unlesbaren Angaben ab', () => {
|
||||
expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar)
|
||||
expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleSeiteLesen', () => {
|
||||
it('liest die erste Seite samt Seitenverweis', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite1'))
|
||||
expect(seite.geaendert).toHaveLength(2)
|
||||
expect(seite.weiter).toBe('BEISPIEL-SEITE-2')
|
||||
expect(seite.deltaVerweis).toBeNull()
|
||||
})
|
||||
|
||||
it('rechnet den Versatz nach UTC um', () => {
|
||||
const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert
|
||||
expect(erster?.extId).toBe('beispiel-termin-a')
|
||||
expect(erster?.betreff).toBe('Zahnarzt')
|
||||
expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z')
|
||||
expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z')
|
||||
expect(erster?.ganztaegig).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt ganztägige Termine am reinen Datum', () => {
|
||||
const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1]
|
||||
expect(ganztaegig?.ganztaegig).toBe(true)
|
||||
expect(ganztaegig?.betreff).toBe('Urlaubstag')
|
||||
})
|
||||
|
||||
it('liest die letzte Seite mit dem Sync-Verweis', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite2'))
|
||||
expect(seite.weiter).toBeNull()
|
||||
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1')
|
||||
})
|
||||
|
||||
it('behandelt abgesagte Termine als entfernt', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite2'))
|
||||
expect(seite.entfernt).toEqual(['beispiel-termin-d'])
|
||||
expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c'])
|
||||
})
|
||||
|
||||
it('erkennt einen durchlässigen Termin als frei', () => {
|
||||
const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert
|
||||
expect(frei?.zeigtAls).toBe('FREI')
|
||||
})
|
||||
|
||||
it('kommt mit einer leeren Antwort zurecht', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-leer'))
|
||||
expect(seite.geaendert).toEqual([])
|
||||
expect(seite.entfernt).toEqual([])
|
||||
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2')
|
||||
})
|
||||
|
||||
it('weist eine Antwort mit falschem Aufbau ab', () => {
|
||||
expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('übergeht Einträge ohne Zeitraum', () => {
|
||||
const seite = googleSeiteLesen({
|
||||
items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }],
|
||||
})
|
||||
expect(seite.geaendert).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { z } from 'zod'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import {
|
||||
AntwortUnbrauchbar,
|
||||
type AbgleichSeite,
|
||||
type RohTermin,
|
||||
} from '@/lib/kalender/typen'
|
||||
|
||||
/**
|
||||
* Lesen der Antworten der Google-Kalender-API (`events.list`).
|
||||
*
|
||||
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
|
||||
* gemeinsame Form zurück.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime`
|
||||
* (mit Versatz). Genau eines von beidem.
|
||||
*/
|
||||
const googleZeit = z.looseObject({
|
||||
date: z.string().optional(),
|
||||
dateTime: z.string().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
})
|
||||
|
||||
const googleTermin = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
etag: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
start: googleZeit.optional(),
|
||||
end: googleZeit.optional(),
|
||||
transparency: z.string().optional(),
|
||||
})
|
||||
|
||||
const googleAntwort = z.looseObject({
|
||||
items: z.array(googleTermin).default([]),
|
||||
nextPageToken: z.string().optional(),
|
||||
nextSyncToken: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei,
|
||||
* alles andere (auch das Fehlen des Feldes) heißt gebucht.
|
||||
*/
|
||||
export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls {
|
||||
return transparency === 'transparent' ? 'FREI' : 'GEBUCHT'
|
||||
}
|
||||
|
||||
export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined }
|
||||
|
||||
/**
|
||||
* Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig
|
||||
* war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine
|
||||
* zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag.
|
||||
*/
|
||||
export function googleZeitpunkt(zeit: GoogleZeit): {
|
||||
zeitpunkt: Date
|
||||
ganztaegig: boolean
|
||||
} {
|
||||
if (zeit.date) {
|
||||
const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`)
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar')
|
||||
}
|
||||
return { zeitpunkt, ganztaegig: true }
|
||||
}
|
||||
|
||||
if (zeit.dateTime) {
|
||||
const zeitpunkt = new Date(zeit.dateTime)
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar')
|
||||
}
|
||||
return { zeitpunkt, ganztaegig: false }
|
||||
}
|
||||
|
||||
throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt')
|
||||
}
|
||||
|
||||
/** Liest eine Antwortseite von `events.list`. */
|
||||
export function googleSeiteLesen(inhalt: unknown): AbgleichSeite {
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau')
|
||||
}
|
||||
|
||||
const geaendert: RohTermin[] = []
|
||||
const entfernt: string[] = []
|
||||
|
||||
for (const eintrag of gelesen.data.items) {
|
||||
if (eintrag.status === 'cancelled') {
|
||||
entfernt.push(eintrag.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!eintrag.start || !eintrag.end) continue
|
||||
|
||||
const beginn = googleZeitpunkt(eintrag.start)
|
||||
const ende = googleZeitpunkt(eintrag.end)
|
||||
|
||||
geaendert.push({
|
||||
extId: eintrag.id,
|
||||
etag: eintrag.etag ?? null,
|
||||
betreff: eintrag.summary ?? null,
|
||||
beginn: beginn.zeitpunkt,
|
||||
ende: ende.zeitpunkt,
|
||||
ganztaegig: beginn.ganztaegig,
|
||||
zeigtAls: zeigtAlsAusGoogle(eintrag.transparency),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
geaendert,
|
||||
entfernt,
|
||||
weiter: gelesen.data.nextPageToken ?? null,
|
||||
deltaVerweis: gelesen.data.nextSyncToken ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph'
|
||||
import { AntwortUnbrauchbar } from './typen'
|
||||
|
||||
/** Beispielantworten aus test/fixtures, anonymisiert. */
|
||||
function fixture(name: string): unknown {
|
||||
return JSON.parse(
|
||||
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
|
||||
) as unknown
|
||||
}
|
||||
|
||||
describe('zeigtAlsAusGraph', () => {
|
||||
it('übersetzt die bekannten Werte', () => {
|
||||
expect(zeigtAlsAusGraph('free')).toBe('FREI')
|
||||
expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT')
|
||||
expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT')
|
||||
expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND')
|
||||
expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS')
|
||||
})
|
||||
|
||||
it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => {
|
||||
expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT')
|
||||
expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphZeitpunkt', () => {
|
||||
it('liest eine UTC-Angabe', () => {
|
||||
expect(
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T08:00:00.0000000',
|
||||
timeZone: 'UTC',
|
||||
}).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => {
|
||||
expect(
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T08:00:00.1234567',
|
||||
timeZone: 'UTC',
|
||||
}).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.123Z')
|
||||
})
|
||||
|
||||
it('kommt auch ohne Nachkommastellen zurecht', () => {
|
||||
expect(
|
||||
graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => {
|
||||
expect(() =>
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T09:00:00.0000000',
|
||||
timeZone: 'W. Europe Standard Time',
|
||||
}),
|
||||
).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('bricht bei unlesbarer Zeitangabe ab', () => {
|
||||
expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow(
|
||||
AntwortUnbrauchbar,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphSeiteLesen', () => {
|
||||
it('liest die erste Seite samt Verweis auf die nächste', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite1'))
|
||||
|
||||
expect(seite.geaendert).toHaveLength(2)
|
||||
expect(seite.entfernt).toEqual([])
|
||||
expect(seite.weiter).toContain('$skiptoken=')
|
||||
expect(seite.deltaVerweis).toBeNull()
|
||||
})
|
||||
|
||||
it('übernimmt Betreff, Kennung und Etag', () => {
|
||||
const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert
|
||||
expect(erster?.extId).toBe('BEISPIEL-TERMIN-1')
|
||||
expect(erster?.betreff).toBe('Wochenbesprechung')
|
||||
expect(erster?.etag).toContain('BEISPIELETAG1')
|
||||
expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z')
|
||||
expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z')
|
||||
expect(erster?.ganztaegig).toBe(false)
|
||||
expect(erster?.zeigtAls).toBe('GEBUCHT')
|
||||
})
|
||||
|
||||
it('erkennt ganztägige Termine', () => {
|
||||
const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1]
|
||||
expect(ganztaegig?.ganztaegig).toBe(true)
|
||||
expect(ganztaegig?.zeigtAls).toBe('ABWESEND')
|
||||
})
|
||||
|
||||
it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
|
||||
expect(seite.weiter).toBeNull()
|
||||
expect(seite.deltaVerweis).toContain('$deltatoken=')
|
||||
})
|
||||
|
||||
it('trennt Entferntes von Geändertem', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
|
||||
expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9'])
|
||||
expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3'])
|
||||
})
|
||||
|
||||
it('erkennt einen als frei markierten Termin', () => {
|
||||
const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert
|
||||
expect(frei?.zeigtAls).toBe('FREI')
|
||||
})
|
||||
|
||||
it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => {
|
||||
expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow(
|
||||
AntwortUnbrauchbar,
|
||||
)
|
||||
})
|
||||
|
||||
it('weist eine Antwort ohne value-Liste ab', () => {
|
||||
expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => {
|
||||
const seite = graphSeiteLesen({
|
||||
value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }],
|
||||
})
|
||||
expect(seite.geaendert).toEqual([])
|
||||
expect(seite.entfernt).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import {
|
||||
AntwortUnbrauchbar,
|
||||
type AbgleichSeite,
|
||||
type RohTermin,
|
||||
} from '@/lib/kalender/typen'
|
||||
|
||||
/**
|
||||
* Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`).
|
||||
*
|
||||
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
|
||||
* gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die
|
||||
* Beispielantworten in test/fixtures prüfbar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen
|
||||
* mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes
|
||||
* zurück, wäre jede Umrechnung geraten – dann lieber abbrechen.
|
||||
*/
|
||||
const graphZeit = z.object({
|
||||
dateTime: z.string().min(1),
|
||||
timeZone: z.string().min(1),
|
||||
})
|
||||
|
||||
const graphTermin = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
'@odata.etag': z.string().optional(),
|
||||
'@removed': z.looseObject({ reason: z.string().optional() }).optional(),
|
||||
subject: z.string().nullable().optional(),
|
||||
start: graphZeit.optional(),
|
||||
end: graphZeit.optional(),
|
||||
isAllDay: z.boolean().optional(),
|
||||
showAs: z.string().optional(),
|
||||
})
|
||||
|
||||
const graphAntwort = z.looseObject({
|
||||
value: z.array(graphTermin),
|
||||
'@odata.nextLink': z.string().optional(),
|
||||
'@odata.deltaLink': z.string().optional(),
|
||||
})
|
||||
|
||||
/** showAs von Graph auf unsere Belegungsarten. */
|
||||
export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls {
|
||||
switch (showAs) {
|
||||
case 'free':
|
||||
return 'FREI'
|
||||
case 'busy':
|
||||
return 'GEBUCHT'
|
||||
case 'tentative':
|
||||
return 'MIT_VORBEHALT'
|
||||
case 'oof':
|
||||
return 'ABWESEND'
|
||||
case 'workingElsewhere':
|
||||
return 'WOANDERS'
|
||||
default:
|
||||
return 'UNBEKANNT'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt.
|
||||
*
|
||||
* Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet
|
||||
* Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das
|
||||
* schweigend zu übergehen hieße, Termine um Stunden zu verschieben.
|
||||
*/
|
||||
export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date {
|
||||
if (zeit.timeZone.toUpperCase() !== 'UTC') {
|
||||
throw new AntwortUnbrauchbar(
|
||||
'Graph',
|
||||
`unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`,
|
||||
)
|
||||
}
|
||||
|
||||
// "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht
|
||||
// Date nicht, drei genügen.
|
||||
const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1')
|
||||
const zeitpunkt = new Date(`${gekuerzt}Z`)
|
||||
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar')
|
||||
}
|
||||
return zeitpunkt
|
||||
}
|
||||
|
||||
/** Liest eine Antwortseite von `/me/calendarView/delta`. */
|
||||
export function graphSeiteLesen(inhalt: unknown): AbgleichSeite {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau')
|
||||
}
|
||||
|
||||
const geaendert: RohTermin[] = []
|
||||
const entfernt: string[] = []
|
||||
|
||||
for (const eintrag of gelesen.data.value) {
|
||||
if (eintrag['@removed']) {
|
||||
entfernt.push(eintrag.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten.
|
||||
if (!eintrag.start || !eintrag.end) continue
|
||||
|
||||
geaendert.push({
|
||||
extId: eintrag.id,
|
||||
etag: eintrag['@odata.etag'] ?? null,
|
||||
betreff: eintrag.subject ?? null,
|
||||
beginn: graphZeitpunkt(eintrag.start),
|
||||
ende: graphZeitpunkt(eintrag.end),
|
||||
ganztaegig: eintrag.isAllDay === true,
|
||||
zeigtAls: zeigtAlsAusGraph(eintrag.showAs),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
geaendert,
|
||||
entfernt,
|
||||
weiter: gelesen.data['@odata.nextLink'] ?? null,
|
||||
deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
|
||||
/**
|
||||
* Gemeinsame Form eines Termins, unabhängig vom Anbieter.
|
||||
*
|
||||
* Graph und Google liefern sehr verschiedene Antworten; alles, was danach
|
||||
* kommt — Belegungsrechnung, Wochenansicht, Schreibweg ab M4 — arbeitet nur
|
||||
* noch mit dieser Form.
|
||||
*/
|
||||
export type RohTermin = {
|
||||
/** Kennung beim Anbieter. */
|
||||
extId: string
|
||||
/** Für bedingte Schreibzugriffe ab M4. */
|
||||
etag: string | null
|
||||
betreff: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis einer einzelnen Abfrageseite.
|
||||
*
|
||||
* Genau eines von `weiter` und `deltaVerweis` ist gesetzt: solange `weiter`
|
||||
* steht, gibt es weitere Seiten; kommt `deltaVerweis`, ist die Runde zu Ende
|
||||
* und der Verweis wird für den nächsten Abgleich aufbewahrt.
|
||||
*/
|
||||
export type AbgleichSeite = {
|
||||
geaendert: RohTermin[]
|
||||
/** Kennungen entfernter Termine. */
|
||||
entfernt: string[]
|
||||
/** Nächste Seite derselben Runde, sonst null. */
|
||||
weiter: string | null
|
||||
/** Ausgangspunkt für die nächste Runde, sonst null. */
|
||||
deltaVerweis: string | null
|
||||
}
|
||||
|
||||
/** Fehler beim Lesen einer Anbieterantwort – Inhalt nie im Klartext ins Log. */
|
||||
export class AntwortUnbrauchbar extends Error {
|
||||
constructor(anbieter: string, grund: string) {
|
||||
super(`Antwort von ${anbieter} nicht verwertbar: ${grund}`)
|
||||
this.name = 'AntwortUnbrauchbar'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Sitzungsverweis ist abgelaufen; es muss von vorn abgeglichen werden.
|
||||
* Graph antwortet dann mit 410, Google ebenfalls mit 410.
|
||||
*/
|
||||
export class VerweisAbgelaufen extends Error {
|
||||
constructor(anbieter: string) {
|
||||
super(`Der Abgleichverweis von ${anbieter} ist abgelaufen.`)
|
||||
this.name = 'VerweisAbgelaufen'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ZEILE_HOEHE,
|
||||
balkenlage,
|
||||
fensterHuelle,
|
||||
rasterBauen,
|
||||
schluesselFuer,
|
||||
spurenVerteilen,
|
||||
zeilenBeschriftung,
|
||||
zeitpunktAus,
|
||||
} from './woche'
|
||||
|
||||
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
describe('rasterBauen', () => {
|
||||
it('beginnt am Montag und hat sieben Spalten', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
expect(raster.spalten).toHaveLength(7)
|
||||
expect(raster.spalten[0]?.wochentag).toBe(0)
|
||||
expect(raster.spalten[6]?.wochentag).toBe(6)
|
||||
})
|
||||
|
||||
it('legt den Montag auf den richtigen Tag', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
// Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15.
|
||||
expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z')
|
||||
})
|
||||
|
||||
it('teilt das Fenster in Viertelstunden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 10 * 60)
|
||||
expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585])
|
||||
})
|
||||
|
||||
it('lässt die letzte Zeile vor dem Ende enden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 9 * 60)
|
||||
expect(raster.zeilen.at(-1)).toBe(525)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schluesselFuer und zeitpunktAus', () => {
|
||||
it('finden zurück zum Ausgangspunkt', () => {
|
||||
const schluessel = schluesselFuer(dienstag)
|
||||
// 09:00 Berlin am selben Tag.
|
||||
expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe(
|
||||
'2026-03-17T08:00:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('geben für denselben Tag denselben Schlüssel', () => {
|
||||
expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe(
|
||||
schluesselFuer(new Date('2026-03-17T20:00:00Z')),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fensterHuelle', () => {
|
||||
it('umspannt alle Arbeitsfenster mit etwas Luft', () => {
|
||||
const huelle = fensterHuelle([
|
||||
{ arbeitsfensterVon: 480, arbeitsfensterBis: 1020 },
|
||||
{ arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 },
|
||||
])
|
||||
expect(huelle.von).toBe(420)
|
||||
expect(huelle.bis).toBe(1320)
|
||||
})
|
||||
|
||||
it('bleibt innerhalb eines Tages', () => {
|
||||
const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }])
|
||||
expect(huelle.von).toBe(0)
|
||||
expect(huelle.bis).toBe(1440)
|
||||
})
|
||||
|
||||
it('nimmt ohne Bereiche einen brauchbaren Standard', () => {
|
||||
expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('balkenlage', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
const spalte = raster.spalten[1]!
|
||||
|
||||
it('setzt einen Block an die richtige Stelle', () => {
|
||||
// 09:00 bis 10:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T09:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
// Eine Stunde nach Fensterbeginn = vier Zeilen.
|
||||
expect(lage.oben).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.ausserhalb).toBe(false)
|
||||
})
|
||||
|
||||
it('schneidet oben ab, was vor dem Fenster beginnt', () => {
|
||||
// 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T06:00:00Z'),
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.oben).toBe(0)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('schneidet unten ab, was über das Fenster hinausgeht', () => {
|
||||
// 17:00 bis 20:00 Berlin, Fenster endet um 18:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T16:00:00Z'),
|
||||
new Date('2026-03-17T19:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('erkennt einen Block ganz außerhalb des Fensters', () => {
|
||||
// 22:00 bis 23:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T21:00:00Z'),
|
||||
new Date('2026-03-17T22:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.ausserhalb).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => {
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T08:15:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeilenBeschriftung', () => {
|
||||
it('beschriftet nur volle Stunden', () => {
|
||||
expect(zeilenBeschriftung(480)).toBe('08:00')
|
||||
expect(zeilenBeschriftung(495)).toBeNull()
|
||||
expect(zeilenBeschriftung(540)).toBe('09:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spurenVerteilen', () => {
|
||||
function eintrag(von: string, bis: string) {
|
||||
return { beginn: new Date(von), ende: new Date(bis) }
|
||||
}
|
||||
|
||||
it('gibt einem einzelnen Eintrag die volle Breite', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spur).toBe(0)
|
||||
expect(verteilt[0]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('legt zwei sich überschneidende Einträge nebeneinander', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1])
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt getrennte Einträge beide die volle Breite behalten', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.every((v) => v.spuren === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
// Beginnt nach dem ersten, kann dessen Spur übernehmen.
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0])
|
||||
// Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren.
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('trennt Gruppen, die sich nicht berühren', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spuren).toBe(2)
|
||||
expect(verteilt[1]?.spuren).toBe(2)
|
||||
expect(verteilt[2]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('behält jeden Eintrag genau einmal', () => {
|
||||
const eintraege = [
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
]
|
||||
expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit'
|
||||
import { RASTER_MINUTEN } from '@/lib/zeitblock'
|
||||
|
||||
/**
|
||||
* Aufbau des Wochenrasters.
|
||||
*
|
||||
* Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von
|
||||
* Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die
|
||||
* Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit.
|
||||
*/
|
||||
|
||||
/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */
|
||||
export const ZEILE_HOEHE = 14
|
||||
|
||||
export type Tagesspalte = {
|
||||
/** 00:00 dieses Tages in Europe/Berlin. */
|
||||
tagesBeginn: Date
|
||||
/** 0 = Montag. */
|
||||
wochentag: number
|
||||
/** ISO-Datum, taugt als Schlüssel. */
|
||||
schluessel: string
|
||||
}
|
||||
|
||||
export type Raster = {
|
||||
spalten: Tagesspalte[]
|
||||
/** Minuten seit Mitternacht, an denen die Zeilen beginnen. */
|
||||
zeilen: number[]
|
||||
vonMinute: number
|
||||
bisMinute: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Raster für die Woche, in der der Zeitpunkt liegt.
|
||||
*
|
||||
* Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die
|
||||
* Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was
|
||||
* geplant werden darf.
|
||||
*/
|
||||
export function rasterBauen(
|
||||
zeitpunkt: Date,
|
||||
vonMinute: number,
|
||||
bisMinute: number,
|
||||
): Raster {
|
||||
const tage = wochenTage(zeitpunkt)
|
||||
|
||||
const zeilen: number[] = []
|
||||
for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) {
|
||||
zeilen.push(minute)
|
||||
}
|
||||
|
||||
return {
|
||||
spalten: tage.map((tag) => ({
|
||||
tagesBeginn: tag,
|
||||
wochentag: wochentagIndex(tag),
|
||||
schluessel: schluesselFuer(tag),
|
||||
})),
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
}
|
||||
}
|
||||
|
||||
/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */
|
||||
export function schluesselFuer(tag: Date): string {
|
||||
const beginn = tagesBeginn(tag)
|
||||
return String(beginn.getTime())
|
||||
}
|
||||
|
||||
/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */
|
||||
export function zeitpunktAus(schluessel: string, minute: number): Date {
|
||||
return new Date(Number(schluessel) + minute * 60_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten.
|
||||
*
|
||||
* Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des
|
||||
* Rasters und ließe sich kaum greifen.
|
||||
*/
|
||||
export function fensterHuelle(
|
||||
bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[],
|
||||
luftMinuten = 60,
|
||||
): { von: number; bis: number } {
|
||||
if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 }
|
||||
|
||||
const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon))
|
||||
const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis))
|
||||
|
||||
return {
|
||||
von: Math.max(0, aufRasterMinute(von - luftMinuten)),
|
||||
bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)),
|
||||
}
|
||||
}
|
||||
|
||||
function aufRasterMinute(minute: number): number {
|
||||
return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN
|
||||
}
|
||||
|
||||
export type Balkenlage = {
|
||||
/** Abstand von oben in Pixeln. */
|
||||
oben: number
|
||||
/** Höhe in Pixeln. */
|
||||
hoehe: number
|
||||
/** Liegt der Block ganz außerhalb des sichtbaren Fensters? */
|
||||
ausserhalb: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo ein Zeitraum im Raster eines Tages liegt.
|
||||
*
|
||||
* Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst
|
||||
* würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen.
|
||||
*/
|
||||
export function balkenlage(
|
||||
beginn: Date,
|
||||
ende: Date,
|
||||
spalte: Tagesspalte,
|
||||
raster: Raster,
|
||||
): Balkenlage {
|
||||
const tagBeginn = spalte.tagesBeginn.getTime()
|
||||
const vonMinute = (beginn.getTime() - tagBeginn) / 60_000
|
||||
const bisMinute = (ende.getTime() - tagBeginn) / 60_000
|
||||
|
||||
if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) {
|
||||
return { oben: 0, hoehe: 0, ausserhalb: true }
|
||||
}
|
||||
|
||||
const sichtbarVon = Math.max(vonMinute, raster.vonMinute)
|
||||
const sichtbarBis = Math.min(bisMinute, raster.bisMinute)
|
||||
|
||||
return {
|
||||
oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
hoehe: Math.max(
|
||||
ZEILE_HOEHE,
|
||||
((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
),
|
||||
ausserhalb: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */
|
||||
export function zeilenBeschriftung(minute: number): string | null {
|
||||
return minute % 60 === 0 ? alsUhrzeit(minute) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt sich überschneidende Einträge auf Spuren nebeneinander.
|
||||
*
|
||||
* Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar.
|
||||
*/
|
||||
export function spurenVerteilen<T extends { beginn: Date; ende: Date }>(
|
||||
eintraege: readonly T[],
|
||||
): { eintrag: T; spur: number; spuren: number }[] {
|
||||
const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime())
|
||||
|
||||
// Gruppen bilden: alles, was sich über eine Kette von Überschneidungen
|
||||
// berührt, teilt sich dieselbe Anzahl Spuren.
|
||||
const gruppen: T[][] = []
|
||||
let aktuelle: T[] = []
|
||||
let gruppenEnde = 0
|
||||
|
||||
for (const eintrag of sortiert) {
|
||||
if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) {
|
||||
gruppen.push(aktuelle)
|
||||
aktuelle = []
|
||||
}
|
||||
aktuelle.push(eintrag)
|
||||
gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime())
|
||||
}
|
||||
if (aktuelle.length > 0) gruppen.push(aktuelle)
|
||||
|
||||
const ergebnis: { eintrag: T; spur: number; spuren: number }[] = []
|
||||
|
||||
for (const gruppe of gruppen) {
|
||||
const spurEnden: number[] = []
|
||||
const zuordnung: { eintrag: T; spur: number }[] = []
|
||||
|
||||
for (const eintrag of gruppe) {
|
||||
let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime())
|
||||
if (spur === -1) {
|
||||
spur = spurEnden.length
|
||||
spurEnden.push(0)
|
||||
}
|
||||
spurEnden[spur] = eintrag.ende.getTime()
|
||||
zuordnung.push({ eintrag, spur })
|
||||
}
|
||||
|
||||
for (const { eintrag, spur } of zuordnung) {
|
||||
ergebnis.push({ eintrag, spur, spuren: spurEnden.length })
|
||||
}
|
||||
}
|
||||
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RASTER_MINUTEN,
|
||||
ZEITBLOCK_MAX_MINUTEN,
|
||||
ZEITBLOCK_MIN_MINUTEN,
|
||||
abgleichEntscheiden,
|
||||
aufRaster,
|
||||
dauerMinuten,
|
||||
lagePruefen,
|
||||
mussSchreiben,
|
||||
ueberschneidetSich,
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
} from './zeitblock'
|
||||
|
||||
const beginn = new Date('2026-03-17T09:00:00Z')
|
||||
const ende = new Date('2026-03-17T10:00:00Z')
|
||||
|
||||
describe('zeitraumHash', () => {
|
||||
it('ist für denselben Zeitraum gleich', () => {
|
||||
expect(zeitraumHash(beginn, ende)).toBe(
|
||||
zeitraumHash(new Date(beginn), new Date(ende)),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn der Beginn wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn das Ende wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
|
||||
)
|
||||
})
|
||||
|
||||
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
|
||||
// Derselbe Augenblick, anders geschrieben.
|
||||
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
|
||||
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abgleichEntscheiden — der Schleifenschutz', () => {
|
||||
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
|
||||
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
|
||||
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
|
||||
})
|
||||
|
||||
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||
})
|
||||
|
||||
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
|
||||
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
|
||||
)
|
||||
|
||||
expect(entscheidung).toEqual({
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: verschobenerBeginn,
|
||||
ende: verschobenesEnde,
|
||||
})
|
||||
})
|
||||
|
||||
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{
|
||||
beginn: new Date('2026-03-17T11:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
},
|
||||
)
|
||||
expect(entscheidung.tun).toBe('uebernehmen')
|
||||
})
|
||||
|
||||
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
null,
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
|
||||
})
|
||||
|
||||
it('schaukelt sich über mehrere Runden nicht hoch', () => {
|
||||
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
|
||||
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
|
||||
for (let runde = 0; runde < 3; runde++) {
|
||||
const entscheidung = abgleichEntscheiden(stand, {
|
||||
beginn: stand.beginn,
|
||||
ende: stand.ende,
|
||||
})
|
||||
expect(entscheidung.tun).toBe('nichts')
|
||||
stand = { ...stand }
|
||||
}
|
||||
})
|
||||
|
||||
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
|
||||
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const neuesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
// Erste Runde: fremde Verschiebung wird übernommen.
|
||||
const erste = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(erste.tun).toBe('uebernehmen')
|
||||
|
||||
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
|
||||
// erkennt ihn wieder.
|
||||
const zweite = abgleichEntscheiden(
|
||||
{
|
||||
hash: zeitraumHash(neuerBeginn, neuesEnde),
|
||||
beginn: neuerBeginn,
|
||||
ende: neuesEnde,
|
||||
},
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(zweite.tun).toBe('nichts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mussSchreiben', () => {
|
||||
it('schreibt bei einer echten Verschiebung', () => {
|
||||
expect(
|
||||
mussSchreiben(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
new Date('2026-03-17T11:00:00Z'),
|
||||
new Date('2026-03-17T12:00:00Z'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
|
||||
// Etwa wenn nur der Aufgabentitel geändert wurde.
|
||||
expect(
|
||||
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('schreibt, solange noch nichts geschrieben wurde', () => {
|
||||
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dauerMinuten', () => {
|
||||
it('rechnet in Minuten', () => {
|
||||
expect(dauerMinuten(beginn, ende)).toBe(60)
|
||||
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufRaster', () => {
|
||||
it('rastet auf die nächste Viertelstunde ein', () => {
|
||||
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:00:00.000Z',
|
||||
)
|
||||
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:15:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
|
||||
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('lagePruefen', () => {
|
||||
it('nimmt eine gültige Lage an', () => {
|
||||
expect(lagePruefen(beginn, ende)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist ein Ende vor dem Beginn ab', () => {
|
||||
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
|
||||
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
|
||||
})
|
||||
|
||||
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
|
||||
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
|
||||
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
|
||||
|
||||
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
|
||||
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
|
||||
|
||||
const zuLang = new Date(
|
||||
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
|
||||
)
|
||||
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
|
||||
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ueberschneidetSich', () => {
|
||||
const block = { beginn, ende }
|
||||
|
||||
it('erkennt eine Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T09:30:00Z'),
|
||||
ende: new Date('2026-03-17T10:30:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt reines Aneinandergrenzen nicht', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: ende,
|
||||
ende: new Date('2026-03-17T11:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt vollständige Umschließung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt getrennte Zeiträume', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T14:00:00Z'),
|
||||
ende: new Date('2026-03-17T15:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeitblockSchema', () => {
|
||||
const gueltig = {
|
||||
aufgabeId: 'a1',
|
||||
kalenderkontoId: 'k1',
|
||||
beginn: '2026-03-17T09:00:00.000Z',
|
||||
ende: '2026-03-17T10:00:00.000Z',
|
||||
}
|
||||
|
||||
it('nimmt eine gültige Eingabe an', () => {
|
||||
const ergebnis = zeitblockSchema.parse(gueltig)
|
||||
expect(ergebnis.beginn).toBeInstanceOf(Date)
|
||||
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
|
||||
})
|
||||
|
||||
it('weist eine unmögliche Lage ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
|
||||
.success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('verlangt Aufgabe und Kalenderkonto', () => {
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Zeitblöcke und der Schleifenschutz.
|
||||
*
|
||||
* Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
|
||||
* Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||
* Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr
|
||||
* einer Endlosschleife:
|
||||
*
|
||||
* Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für
|
||||
* fremd → schreibt zurück → der Abgleich liest wieder → …
|
||||
*
|
||||
* Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt
|
||||
* sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten
|
||||
* Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es
|
||||
* passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt
|
||||
* der Kalender.
|
||||
*
|
||||
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fingerabdruck eines Zeitraums.
|
||||
*
|
||||
* Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
|
||||
* aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und
|
||||
* für die Planung zählt allein, wann der Block liegt.
|
||||
*/
|
||||
export function zeitraumHash(beginn: Date, ende: Date): string {
|
||||
return createHash('sha256')
|
||||
.update(`${beginn.toISOString()}|${ende.toISOString()}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/** Was beim Abgleich mit einem Zeitblock zu tun ist. */
|
||||
export type Abgleichentscheidung =
|
||||
| { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' }
|
||||
| { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date }
|
||||
| { tun: 'loesen'; grund: 'im-kalender-entfernt' }
|
||||
|
||||
export type ZeitblockStand = {
|
||||
/** Was zuletzt in den Kalender geschrieben wurde. */
|
||||
hash: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
}
|
||||
|
||||
export type KalenderStand = {
|
||||
beginn: Date
|
||||
ende: Date
|
||||
} | null
|
||||
|
||||
/**
|
||||
* Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll.
|
||||
*
|
||||
* Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer
|
||||
* das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so
|
||||
* — und merkt es sofort, wenn der Planer es wieder wegzieht.
|
||||
*/
|
||||
export function abgleichEntscheiden(
|
||||
zeitblock: ZeitblockStand,
|
||||
imKalender: KalenderStand,
|
||||
): Abgleichentscheidung {
|
||||
if (!imKalender) {
|
||||
// Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die
|
||||
// Aufgabe selbst bleibt bestehen.
|
||||
return { tun: 'loesen', grund: 'im-kalender-entfernt' }
|
||||
}
|
||||
|
||||
const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende)
|
||||
|
||||
// Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es
|
||||
// die eigene Änderung – nichts zu tun. Das bricht die Schleife.
|
||||
if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) {
|
||||
return { tun: 'nichts', grund: 'eigene-aenderung' }
|
||||
}
|
||||
|
||||
// Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts
|
||||
// zu tun – etwa beim allerersten Abgleich nach dem Anlegen.
|
||||
const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende)
|
||||
if (imKalenderHash === eigenerHash) {
|
||||
return { tun: 'nichts', grund: 'unveraendert' }
|
||||
}
|
||||
|
||||
return {
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: imKalender.beginn,
|
||||
ende: imKalender.ende,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss beim Speichern in den Kalender geschrieben werden?
|
||||
*
|
||||
* Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert,
|
||||
* bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu
|
||||
* behelligen.
|
||||
*/
|
||||
export function mussSchreiben(
|
||||
zeitblock: ZeitblockStand,
|
||||
neuerBeginn: Date,
|
||||
neuesEnde: Date,
|
||||
): boolean {
|
||||
return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde)
|
||||
}
|
||||
|
||||
// --- Lage und Dauer --------------------------------------------------------
|
||||
|
||||
export const ZEITBLOCK_MIN_MINUTEN = 15
|
||||
export const ZEITBLOCK_MAX_MINUTEN = 8 * 60
|
||||
/** Raster, in dem Zeitblöcke einrasten. */
|
||||
export const RASTER_MINUTEN = 15
|
||||
|
||||
export function dauerMinuten(beginn: Date, ende: Date): number {
|
||||
return Math.round((ende.getTime() - beginn.getTime()) / 60_000)
|
||||
}
|
||||
|
||||
/** Rundet einen Zeitpunkt auf das Raster ab. */
|
||||
export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date {
|
||||
const minuten = raster * 60_000
|
||||
return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten)
|
||||
}
|
||||
|
||||
export type LageFehler =
|
||||
'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null
|
||||
|
||||
/** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */
|
||||
export function lagePruefen(beginn: Date, ende: Date): LageFehler {
|
||||
if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn'
|
||||
|
||||
const dauer = dauerMinuten(beginn, ende)
|
||||
if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz'
|
||||
if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang'
|
||||
|
||||
if (
|
||||
beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 ||
|
||||
ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0
|
||||
) {
|
||||
return 'nicht-im-raster'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const LAGE_FEHLER_TEXT: Record<Exclude<LageFehler, null>, string> = {
|
||||
'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`,
|
||||
'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.',
|
||||
'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.',
|
||||
'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`,
|
||||
}
|
||||
|
||||
/** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */
|
||||
export function ueberschneidetSich(
|
||||
a: { beginn: Date; ende: Date },
|
||||
b: { beginn: Date; ende: Date },
|
||||
): boolean {
|
||||
return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime()
|
||||
}
|
||||
|
||||
// --- Eingaben --------------------------------------------------------------
|
||||
|
||||
export const zeitblockSchema = z
|
||||
.object({
|
||||
aufgabeId: z.string().min(1),
|
||||
kalenderkontoId: z.string().min(1),
|
||||
beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'),
|
||||
ende: z.coerce.date('Bitte ein gültiges Ende angeben.'),
|
||||
})
|
||||
.refine((w) => lagePruefen(w.beginn, w.ende) === null, {
|
||||
path: ['ende'],
|
||||
message: 'Die Lage des Zeitblocks stimmt nicht.',
|
||||
})
|
||||
|
||||
export type ZeitblockEingabe = z.infer<typeof zeitblockSchema>
|
||||
@@ -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,155 @@
|
||||
import 'server-only'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import { tagesBeginn, wochenBeginn } from '@/lib/zeit'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
/**
|
||||
* Alles, was die Wochenansicht braucht — in einem Zug geladen.
|
||||
*
|
||||
* Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen
|
||||
* getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich
|
||||
* verschieben, ein Termin ist fremd und wird nur angezeigt.
|
||||
*/
|
||||
|
||||
export type WochenZeitblock = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
aufgabeId: string
|
||||
titel: string
|
||||
bereichName: string | null
|
||||
bereichFarbe: string | null
|
||||
kalenderName: string
|
||||
letzterFehler: string | null
|
||||
/** Noch nicht in den Kalender geschrieben. */
|
||||
offen: boolean
|
||||
}
|
||||
|
||||
export type WochenTermin = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
betreff: string | null
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
kalenderName: string
|
||||
}
|
||||
|
||||
export type WocheDaten = {
|
||||
zeitbloecke: WochenZeitblock[]
|
||||
termine: WochenTermin[]
|
||||
}
|
||||
|
||||
/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */
|
||||
export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||
const montag = wochenBeginn(zeitpunkt)
|
||||
return {
|
||||
von: montag,
|
||||
bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()),
|
||||
}
|
||||
}
|
||||
|
||||
export async function wocheLaden(
|
||||
benutzerId: string,
|
||||
zeitpunkt: Date,
|
||||
): Promise<WocheDaten> {
|
||||
const fenster = wochenFenster(zeitpunkt)
|
||||
|
||||
const [zeitbloecke, termine] = await Promise.all([
|
||||
db.zeitblock.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
extId: true,
|
||||
letzterFehler: true,
|
||||
aufgabe: {
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bereich: { select: { name: true, farbe: true } },
|
||||
},
|
||||
},
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
db.termin.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId, aktiv: true },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
extId: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
betreff: true,
|
||||
ganztaegig: true,
|
||||
zeigtAls: true,
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
||||
// doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin.
|
||||
const eigeneExtIds = new Set(
|
||||
zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null),
|
||||
)
|
||||
|
||||
return {
|
||||
zeitbloecke: zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
aufgabeId: z.aufgabe.id,
|
||||
titel: z.aufgabe.titel,
|
||||
bereichName: z.aufgabe.bereich?.name ?? null,
|
||||
bereichFarbe: z.aufgabe.bereich?.farbe ?? null,
|
||||
kalenderName: z.kalenderkonto.anzeigename,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.extId === null,
|
||||
})),
|
||||
termine: termine
|
||||
.filter((t) => !eigeneExtIds.has(t.extId))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
kalenderName: t.kalenderkonto.anzeigename,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */
|
||||
export async function planbareAufgabenLaden(grenze = 50) {
|
||||
return db.aufgabe.findMany({
|
||||
where: {
|
||||
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||
typ: { not: 'IDEE' },
|
||||
},
|
||||
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
|
||||
take: grenze,
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
prioritaet: true,
|
||||
status: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
_count: { select: { zeitbloecke: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,352 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
LAGE_FEHLER_TEXT,
|
||||
lagePruefen,
|
||||
} from '@/lib/zeitblock'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
|
||||
import {
|
||||
KalenderGewinnt,
|
||||
terminAendern,
|
||||
terminAnlegen,
|
||||
terminEntfernen,
|
||||
} from '@/server/kalender/schreiben'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export type Rueckmeldung =
|
||||
{ ok: true; meldung: string; id?: string } | { ok: false; fehler: string }
|
||||
|
||||
async function akteurOderFehler() {
|
||||
try {
|
||||
return { akteur: await pruefeRolle('BENUTZER') }
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) {
|
||||
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört —
|
||||
* die Kennung geht über das Kalenderkonto in die where-Bedingung.
|
||||
*/
|
||||
async function eigenerZeitblock(id: string, benutzerId: string) {
|
||||
return db.zeitblock.findFirst({
|
||||
where: { id, kalenderkonto: { benutzerId } },
|
||||
select: {
|
||||
id: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
extId: true,
|
||||
etag: true,
|
||||
hash: true,
|
||||
aufgabe: { select: { id: true, titel: true, notiz: true, status: true } },
|
||||
kalenderkonto: {
|
||||
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Was im Kalender als Betreff steht. */
|
||||
function betreffFuer(titel: string): string {
|
||||
return titel
|
||||
}
|
||||
|
||||
// --- Anlegen ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Legt einen Zeitblock an und schreibt ihn in den Kalender.
|
||||
*
|
||||
* Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit
|
||||
* vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers
|
||||
* wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn
|
||||
* nachtragen.
|
||||
*/
|
||||
export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = zeitblockSchema.safeParse({
|
||||
aufgabeId: daten.get('aufgabeId'),
|
||||
kalenderkontoId: daten.get('kalenderkontoId'),
|
||||
beginn: daten.get('beginn'),
|
||||
ende: daten.get('ende'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
|
||||
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
|
||||
}
|
||||
|
||||
const [aufgabe, konto] = await Promise.all([
|
||||
db.aufgabe.findUnique({
|
||||
where: { id: gelesen.data.aufgabeId },
|
||||
select: { id: true, titel: true, notiz: true, status: true },
|
||||
}),
|
||||
db.kalenderkonto.findFirst({
|
||||
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
|
||||
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
|
||||
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
|
||||
|
||||
const zeitblock = await db.zeitblock.create({
|
||||
data: {
|
||||
aufgabeId: aufgabe.id,
|
||||
kalenderkontoId: konto.id,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
|
||||
if (aufgabe.status === 'OFFEN') {
|
||||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||
}
|
||||
|
||||
let schreibfehler: string | null = null
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(konto.id, jetzt())
|
||||
const ergebnis = await terminAnlegen({
|
||||
anbieter: konto.anbieter,
|
||||
kalenderId: konto.kalenderId,
|
||||
token,
|
||||
betreff: betreffFuer(aufgabe.titel),
|
||||
beschreibung: aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
extId: ergebnis.extId,
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
schreibfehler =
|
||||
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { letzterFehler: schreibfehler },
|
||||
})
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.angelegt',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: {
|
||||
aufgabe: aufgabe.titel,
|
||||
kalender: konto.anzeigename,
|
||||
geschrieben: schreibfehler === null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/')
|
||||
|
||||
return schreibfehler
|
||||
? {
|
||||
ok: false,
|
||||
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`,
|
||||
}
|
||||
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` }
|
||||
}
|
||||
|
||||
// --- Verschieben -----------------------------------------------------------
|
||||
|
||||
const verschieben = z.object({
|
||||
id: z.string().min(1),
|
||||
beginn: z.coerce.date(),
|
||||
ende: z.coerce.date(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Verschiebt einen Zeitblock und zieht den Kalender nach.
|
||||
*
|
||||
* Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen
|
||||
* angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern
|
||||
* meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen.
|
||||
*/
|
||||
export async function zeitblockVerschieben(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = verschieben.safeParse({
|
||||
id: daten.get('id'),
|
||||
beginn: daten.get('beginn'),
|
||||
ende: daten.get('ende'),
|
||||
})
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
|
||||
const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende)
|
||||
if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] }
|
||||
|
||||
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende },
|
||||
})
|
||||
|
||||
let meldung = `„${zeitblock.aufgabe.titel}" verschoben.`
|
||||
let ok = true
|
||||
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||
|
||||
if (zeitblock.extId) {
|
||||
const ergebnis = await terminAendern({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
extId: zeitblock.extId,
|
||||
etag: zeitblock.etag,
|
||||
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||
beschreibung: zeitblock.aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen.
|
||||
const ergebnis = await terminAnlegen({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||
beschreibung: zeitblock.aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
extId: ergebnis.extId,
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (fehler) {
|
||||
ok = false
|
||||
if (fehler instanceof KalenderGewinnt) {
|
||||
meldung =
|
||||
'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.'
|
||||
// Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde
|
||||
// Änderung als solche erkennt.
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { hash: null, letzterFehler: meldung },
|
||||
})
|
||||
} else {
|
||||
meldung =
|
||||
fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.'
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { letzterFehler: meldung },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.verschoben',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok },
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/')
|
||||
return ok ? { ok: true, meldung } : { ok: false, fehler: meldung }
|
||||
}
|
||||
|
||||
// --- Lösen -----------------------------------------------------------------
|
||||
|
||||
const loesen = z.object({ id: z.string().min(1) })
|
||||
|
||||
/** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */
|
||||
export async function zeitblockLoesen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = loesen.safeParse({ id: daten.get('id') })
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
|
||||
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||
|
||||
let hinweis = ''
|
||||
if (zeitblock.extId) {
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||
await terminEntfernen({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
extId: zeitblock.extId,
|
||||
etag: zeitblock.etag,
|
||||
})
|
||||
} catch {
|
||||
// Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der
|
||||
// Kalender gerade nicht erreichbar ist, wäre die Planung blockiert.
|
||||
hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.'
|
||||
}
|
||||
}
|
||||
|
||||
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||
|
||||
// Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen.
|
||||
const nochGeplant = await db.zeitblock.count({
|
||||
where: { aufgabeId: zeitblock.aufgabe.id },
|
||||
})
|
||||
if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') {
|
||||
await db.aufgabe.update({
|
||||
where: { id: zeitblock.aufgabe.id },
|
||||
data: { status: 'OFFEN' },
|
||||
})
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.geloest',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: { aufgabe: zeitblock.aufgabe.titel },
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/')
|
||||
return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` }
|
||||
}
|
||||
@@ -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,387 @@
|
||||
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 { rueckwegAnwenden } from '@/server/kalender/rueckweg'
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Gültiges Zugriffstoken für ein Konto – für den Schreibweg aus M4, der
|
||||
* nicht den ganzen Abgleich braucht.
|
||||
*/
|
||||
export async function gueltigesTokenFuer(
|
||||
kalenderkontoId: string,
|
||||
zeitpunkt: Date,
|
||||
): Promise<string> {
|
||||
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) throw new Error('Dieses Kalenderkonto gibt es nicht.')
|
||||
return gueltigesToken(konto, zeitpunkt)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
// Erst den Spiegel auffrischen, dann die eigenen Zeitblöcke damit
|
||||
// vergleichen: Was jemand im Kalender verschoben hat, holt der Planer
|
||||
// sich hier zurück.
|
||||
await rueckwegAnwenden(konto.id)
|
||||
|
||||
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,93 @@
|
||||
import 'server-only'
|
||||
import { abgleichEntscheiden, zeitraumHash } from '@/lib/zeitblock'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
/**
|
||||
* Der Rückweg: Änderungen an Zeitblöcken aus dem Kalender übernehmen.
|
||||
*
|
||||
* Läuft nach jedem Abgleich eines Kontos. Für jeden Zeitblock wird verglichen,
|
||||
* was im Kalender steht — der örtliche Spiegel (Tabelle `termin`) ist dafür
|
||||
* bereits auf dem neuesten Stand.
|
||||
*
|
||||
* **Der Kalender gewinnt.** Wer dort etwas verschiebt, meint es so; der Planer
|
||||
* zieht nach, statt zurückzuschreiben. Sonst zöge die App dem Benutzer den
|
||||
* Termin unter den Händen weg.
|
||||
*/
|
||||
|
||||
export type RueckwegBericht = {
|
||||
uebernommen: number
|
||||
geloest: number
|
||||
}
|
||||
|
||||
export async function rueckwegAnwenden(
|
||||
kalenderkontoId: string,
|
||||
): Promise<RueckwegBericht> {
|
||||
const zeitbloecke = await db.zeitblock.findMany({
|
||||
where: { kalenderkontoId, extId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
extId: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
hash: true,
|
||||
aufgabeId: true,
|
||||
},
|
||||
})
|
||||
if (zeitbloecke.length === 0) return { uebernommen: 0, geloest: 0 }
|
||||
|
||||
const extIds = zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null)
|
||||
|
||||
const termine = await db.termin.findMany({
|
||||
where: { kalenderkontoId, extId: { in: extIds } },
|
||||
select: { extId: true, beginn: true, ende: true, etag: true },
|
||||
})
|
||||
const nachExtId = new Map(termine.map((t) => [t.extId, t]))
|
||||
|
||||
let uebernommen = 0
|
||||
let geloest = 0
|
||||
|
||||
for (const zeitblock of zeitbloecke) {
|
||||
const imKalender = nachExtId.get(zeitblock.extId as string) ?? null
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitblock.hash, beginn: zeitblock.beginn, ende: zeitblock.ende },
|
||||
imKalender ? { beginn: imKalender.beginn, ende: imKalender.ende } : null,
|
||||
)
|
||||
|
||||
if (entscheidung.tun === 'nichts') continue
|
||||
|
||||
if (entscheidung.tun === 'loesen') {
|
||||
// Im Kalender gelöscht: der Zeitblock verliert seine Verankerung. Die
|
||||
// Aufgabe bleibt bestehen und fällt zurück auf offen, wenn nichts
|
||||
// anderes mehr für sie geplant ist.
|
||||
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||
geloest++
|
||||
|
||||
const nochGeplant = await db.zeitblock.count({
|
||||
where: { aufgabeId: zeitblock.aufgabeId },
|
||||
})
|
||||
if (nochGeplant === 0) {
|
||||
await db.aufgabe.updateMany({
|
||||
where: { id: zeitblock.aufgabeId, status: 'GEPLANT' },
|
||||
data: { status: 'OFFEN' },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
beginn: entscheidung.beginn,
|
||||
ende: entscheidung.ende,
|
||||
// Den Fingerabdruck auf den übernommenen Stand setzen, damit die
|
||||
// nächste Runde ihn wiedererkennt und nichts mehr tut.
|
||||
hash: zeitraumHash(entscheidung.beginn, entscheidung.ende),
|
||||
etag: imKalender?.etag ?? null,
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
uebernommen++
|
||||
}
|
||||
|
||||
return { uebernommen, geloest }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'server-only'
|
||||
import { z } from 'zod'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
|
||||
/**
|
||||
* Schreiben in die Kalender der Anbieter.
|
||||
*
|
||||
* Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match` und
|
||||
* dem zuletzt gelesenen Etag heraus. Hat inzwischen jemand anderes den Termin
|
||||
* geändert, antwortet der Anbieter mit 412 — dann **gewinnt der Kalender**,
|
||||
* und der Planer übernimmt dessen Stand, statt ihn zu überschreiben.
|
||||
*/
|
||||
|
||||
/** Der Termin wurde inzwischen anderswo geändert. */
|
||||
export class KalenderGewinnt extends Error {
|
||||
constructor(anbieter: string) {
|
||||
super(`Der Termin wurde in ${anbieter} zwischenzeitlich geändert.`)
|
||||
this.name = 'KalenderGewinnt'
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Termin ist im Kalender nicht mehr vorhanden. */
|
||||
export class TerminFort extends Error {
|
||||
constructor(anbieter: string) {
|
||||
super(`Der Termin ist in ${anbieter} nicht mehr vorhanden.`)
|
||||
this.name = 'TerminFort'
|
||||
}
|
||||
}
|
||||
|
||||
export type SchreibEingabe = {
|
||||
anbieter: KalenderAnbieter
|
||||
kalenderId: string
|
||||
token: string
|
||||
betreff: string
|
||||
beschreibung: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
}
|
||||
|
||||
export type SchreibErgebnis = { extId: string; etag: string | null }
|
||||
|
||||
const graphAntwort = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
'@odata.etag': z.string().optional(),
|
||||
})
|
||||
|
||||
const googleAntwort = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
etag: z.string().optional(),
|
||||
})
|
||||
|
||||
function graphBasis(kalenderId: string): string {
|
||||
return kalenderId === 'standard'
|
||||
? 'https://graph.microsoft.com/v1.0/me/events'
|
||||
: `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(kalenderId)}/events`
|
||||
}
|
||||
|
||||
function googleBasis(kalenderId: string): string {
|
||||
const kalender = encodeURIComponent(kalenderId === 'standard' ? 'primary' : kalenderId)
|
||||
return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events`
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Rumpf des Termins.
|
||||
*
|
||||
* Graph erwartet Zeit und Zone getrennt; wir schicken UTC, damit auf beiden
|
||||
* Wegen dasselbe gilt wie beim Lesen. Google nimmt einen ISO-Zeitpunkt mit
|
||||
* Versatz direkt entgegen.
|
||||
*/
|
||||
function rumpf(eingabe: SchreibEingabe): Record<string, unknown> {
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
return {
|
||||
subject: eingabe.betreff,
|
||||
body: { contentType: 'text', content: eingabe.beschreibung ?? '' },
|
||||
start: { dateTime: eingabe.beginn.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: eingabe.ende.toISOString(), timeZone: 'UTC' },
|
||||
showAs: 'busy',
|
||||
// Damit im Kalender erkennbar ist, woher der Termin stammt.
|
||||
categories: ['Planer'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary: eingabe.betreff,
|
||||
description: eingabe.beschreibung ?? '',
|
||||
start: { dateTime: eingabe.beginn.toISOString() },
|
||||
end: { dateTime: eingabe.ende.toISOString() },
|
||||
transparency: 'opaque',
|
||||
source: { title: 'Planer', url: process.env.APP_URL ?? '' },
|
||||
}
|
||||
}
|
||||
|
||||
async function senden(
|
||||
anbieter: KalenderAnbieter,
|
||||
adresse: string,
|
||||
verfahren: 'POST' | 'PATCH' | 'DELETE',
|
||||
token: string,
|
||||
inhalt: Record<string, unknown> | null,
|
||||
etag: string | null,
|
||||
): Promise<Response> {
|
||||
const kopfzeilen: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
if (inhalt) kopfzeilen['Content-Type'] = 'application/json'
|
||||
// Ohne Etag kein If-Match: ein Schreibzugriff ohne Vergleichsstand darf
|
||||
// nicht als "unverändert" durchgehen.
|
||||
if (etag) kopfzeilen['If-Match'] = etag
|
||||
|
||||
const antwort = await fetch(adresse, {
|
||||
method: verfahren,
|
||||
headers: kopfzeilen,
|
||||
...(inhalt ? { body: JSON.stringify(inhalt) } : {}),
|
||||
})
|
||||
|
||||
if (antwort.status === 412) throw new KalenderGewinnt(anbieter)
|
||||
if (antwort.status === 404 || antwort.status === 410) throw new TerminFort(anbieter)
|
||||
|
||||
return antwort
|
||||
}
|
||||
|
||||
/** Legt einen Termin an. */
|
||||
export async function terminAnlegen(eingabe: SchreibEingabe): Promise<SchreibErgebnis> {
|
||||
const adresse =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'POST',
|
||||
eingabe.token,
|
||||
rumpf(eingabe),
|
||||
null,
|
||||
)
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Der Termin ließ sich nicht anlegen (${antwort.status}).`)
|
||||
}
|
||||
|
||||
const inhalt: unknown = await antwort.json()
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) throw new Error('Die Antwort von Graph war nicht verwertbar.')
|
||||
return { extId: gelesen.data.id, etag: gelesen.data['@odata.etag'] ?? null }
|
||||
}
|
||||
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) throw new Error('Die Antwort von Google war nicht verwertbar.')
|
||||
return { extId: gelesen.data.id, etag: gelesen.data.etag ?? null }
|
||||
}
|
||||
|
||||
/** Ändert einen bestehenden Termin. */
|
||||
export async function terminAendern(
|
||||
eingabe: SchreibEingabe & { extId: string; etag: string | null },
|
||||
): Promise<SchreibErgebnis> {
|
||||
const basis =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'PATCH',
|
||||
eingabe.token,
|
||||
rumpf(eingabe),
|
||||
eingabe.etag,
|
||||
)
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Der Termin ließ sich nicht ändern (${antwort.status}).`)
|
||||
}
|
||||
|
||||
const inhalt: unknown = await antwort.json()
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
return {
|
||||
extId: eingabe.extId,
|
||||
etag: gelesen.success ? (gelesen.data['@odata.etag'] ?? null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
return {
|
||||
extId: eingabe.extId,
|
||||
etag: gelesen.success ? (gelesen.data.etag ?? null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Termin. Ist er ohnehin schon fort, gilt das als erledigt —
|
||||
* das Ziel ist erreicht.
|
||||
*/
|
||||
export async function terminEntfernen(eingabe: {
|
||||
anbieter: KalenderAnbieter
|
||||
kalenderId: string
|
||||
token: string
|
||||
extId: string
|
||||
etag: string | null
|
||||
}): Promise<void> {
|
||||
const basis =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||
|
||||
try {
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'DELETE',
|
||||
eingabe.token,
|
||||
null,
|
||||
eingabe.etag,
|
||||
)
|
||||
if (!antwort.ok && antwort.status !== 204) {
|
||||
throw new Error(`Der Termin ließ sich nicht entfernen (${antwort.status}).`)
|
||||
}
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof TerminFort) return
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'server-only'
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import type { Faehigkeit } from '@/lib/instanz'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
/**
|
||||
* Zustandswert für den OAuth-Ablauf.
|
||||
*
|
||||
* Er trägt Anbieter und Benutzer und ist mit AUTH_SECRET signiert. Ohne das
|
||||
* könnte jemand einen Rückleit-Aufruf unterschieben und ein fremdes
|
||||
* Kalenderkonto an das eigene Konto hängen.
|
||||
*/
|
||||
|
||||
const TRENNER = '.'
|
||||
|
||||
export function faehigkeitFuer(anbieter: KalenderAnbieter): Faehigkeit {
|
||||
return anbieter === 'GRAPH' ? 'kalender:graph' : 'kalender:google'
|
||||
}
|
||||
|
||||
export function istAnbieter(wert: string): wert is Lowercase<KalenderAnbieter> {
|
||||
return wert === 'graph' || wert === 'google'
|
||||
}
|
||||
|
||||
export function alsAnbieter(wert: string): KalenderAnbieter {
|
||||
return wert.toUpperCase() as KalenderAnbieter
|
||||
}
|
||||
|
||||
function signieren(nutzlast: string): string {
|
||||
return createHmac('sha256', umgebung().AUTH_SECRET).update(nutzlast).digest('base64url')
|
||||
}
|
||||
|
||||
/** Erzeugt einen signierten Zustandswert. */
|
||||
export function zustandErzeugen(
|
||||
anbieter: KalenderAnbieter,
|
||||
benutzerId: string,
|
||||
jetzt: Date = new Date(),
|
||||
): string {
|
||||
const nutzlast = Buffer.from(
|
||||
JSON.stringify({
|
||||
a: anbieter,
|
||||
b: benutzerId,
|
||||
z: jetzt.getTime(),
|
||||
n: randomBytes(9).toString('base64url'),
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64url')
|
||||
|
||||
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`
|
||||
}
|
||||
|
||||
export type Zustand = { anbieter: KalenderAnbieter; benutzerId: string }
|
||||
|
||||
/** Gültigkeit des Zustandswerts – die Zustimmung dauert keine Stunde. */
|
||||
const ZUSTAND_GUELTIG_MINUTEN = 30
|
||||
|
||||
/**
|
||||
* Prüft den Zustandswert. Gibt null zurück, wenn er nicht stimmt, verändert
|
||||
* wurde oder zu alt ist.
|
||||
*/
|
||||
export function zustandLesen(
|
||||
wert: string | null,
|
||||
jetzt: Date = new Date(),
|
||||
): Zustand | null {
|
||||
if (!wert) return null
|
||||
|
||||
const [nutzlast, unterschrift] = wert.split(TRENNER)
|
||||
if (!nutzlast || !unterschrift) return null
|
||||
|
||||
const erwartet = Buffer.from(signieren(nutzlast), 'utf8')
|
||||
const erhalten = Buffer.from(unterschrift, 'utf8')
|
||||
if (erwartet.length !== erhalten.length) return null
|
||||
if (!timingSafeEqual(erwartet, erhalten)) return null
|
||||
|
||||
try {
|
||||
const inhalt = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as {
|
||||
a?: unknown
|
||||
b?: unknown
|
||||
z?: unknown
|
||||
}
|
||||
|
||||
if (typeof inhalt.a !== 'string' || typeof inhalt.b !== 'string') return null
|
||||
if (typeof inhalt.z !== 'number') return null
|
||||
if (jetzt.getTime() - inhalt.z > ZUSTAND_GUELTIG_MINUTEN * 60_000) return null
|
||||
if (inhalt.a !== 'GRAPH' && inhalt.a !== 'GOOGLE') return null
|
||||
|
||||
return { anbieter: inhalt.a, benutzerId: inhalt.b }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Die Verschlüsselung liegt in src/server, weil sie AUTH_SECRET braucht.
|
||||
* Für den Test wird die Umgebung ersetzt und "server-only" auf ein leeres
|
||||
* Modul umgebogen — sonst bricht der Import außerhalb einer Serverumgebung ab.
|
||||
*/
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/server/umgebung', () => ({
|
||||
umgebung: () => ({ AUTH_SECRET: 'test-geheimnis-fuer-die-ableitung-1234567890' }),
|
||||
}))
|
||||
|
||||
const { entschluesseln, schluesselVergessen, verschluesseln } =
|
||||
await import('@/server/krypto')
|
||||
|
||||
describe('verschluesseln und entschluesseln', () => {
|
||||
beforeEach(() => {
|
||||
schluesselVergessen()
|
||||
})
|
||||
|
||||
it('gibt zurück, was hineinging', () => {
|
||||
const klartext = 'ya29.beispiel-aktualisierungstoken'
|
||||
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext)
|
||||
})
|
||||
|
||||
it('kommt mit Umlauten und langen Texten zurecht', () => {
|
||||
const klartext = 'Rückgabewert mit Umlauten: äöüß — ' + 'x'.repeat(4000)
|
||||
expect(entschluesseln(verschluesseln(klartext))).toBe(klartext)
|
||||
})
|
||||
|
||||
it('erzeugt bei gleichem Text verschiedene Chiffren', () => {
|
||||
// Sonst ließe sich an der Datenbank ablesen, welche Konten dasselbe
|
||||
// Token tragen.
|
||||
expect(verschluesseln('gleich')).not.toBe(verschluesseln('gleich'))
|
||||
})
|
||||
|
||||
it('lässt den Klartext nicht durchscheinen', () => {
|
||||
expect(verschluesseln('geheim')).not.toContain('geheim')
|
||||
})
|
||||
|
||||
it('gibt für nichts auch nichts zurück', () => {
|
||||
expect(entschluesseln(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist Veränderungen ab, statt Unsinn zu liefern', () => {
|
||||
const chiffre = verschluesseln('unverändert')
|
||||
const teile = chiffre.split(':')
|
||||
// Ein Bit im Inhalt kippen.
|
||||
const inhalt = Buffer.from(teile[3] ?? '', 'base64url')
|
||||
inhalt[0] = (inhalt[0] ?? 0) ^ 0x01
|
||||
teile[3] = inhalt.toString('base64url')
|
||||
|
||||
expect(entschluesseln(teile.join(':'))).toBeNull()
|
||||
})
|
||||
|
||||
it('weist eine fremde Form ab', () => {
|
||||
expect(entschluesseln('einfach nur Text')).toBeNull()
|
||||
expect(entschluesseln('v2:a:b:c')).toBeNull()
|
||||
expect(entschluesseln('v1:zu:wenige')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
/**
|
||||
* Verschlüsselung für Zugangs- und Aktualisierungstoken der Kalenderkonten.
|
||||
*
|
||||
* Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst
|
||||
* direkten Zugriff auf fremde Kalender und Postfächer. Das wiegt schwerer als
|
||||
* bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen.
|
||||
*
|
||||
* Der Schlüssel wird aus AUTH_SECRET abgeleitet, nicht separat konfiguriert:
|
||||
* eine zusätzliche Pflichtvariable wäre eine weitere Stelle, an der bei der
|
||||
* Einrichtung etwas schiefgeht. Wer AUTH_SECRET wechselt, muss die
|
||||
* Kalenderkonten neu verbinden – das ist gewollt, denn mit dem alten Geheimnis
|
||||
* sind auch die alten Sitzungen ungültig.
|
||||
*/
|
||||
|
||||
const ALGORITHMUS = 'aes-256-gcm'
|
||||
const SCHLUESSEL_LAENGE = 32
|
||||
const IV_LAENGE = 12
|
||||
const MARKE = 'v1'
|
||||
|
||||
let zwischengespeichert: Buffer | null = null
|
||||
|
||||
function schluessel(): Buffer {
|
||||
if (zwischengespeichert) return zwischengespeichert
|
||||
const abgeleitet = hkdfSync(
|
||||
'sha256',
|
||||
Buffer.from(umgebung().AUTH_SECRET, 'utf8'),
|
||||
Buffer.alloc(0),
|
||||
Buffer.from('planer:kalender-token', 'utf8'),
|
||||
SCHLUESSEL_LAENGE,
|
||||
)
|
||||
zwischengespeichert = Buffer.from(abgeleitet)
|
||||
return zwischengespeichert
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselt einen Text. Das Ergebnis ist selbstbeschreibend:
|
||||
* "v1:<iv>:<marke>:<inhalt>", alles base64url.
|
||||
*/
|
||||
export function verschluesseln(klartext: string): string {
|
||||
const iv = randomBytes(IV_LAENGE)
|
||||
const chiffre = createCipheriv(ALGORITHMUS, schluessel(), iv)
|
||||
const inhalt = Buffer.concat([chiffre.update(klartext, 'utf8'), chiffre.final()])
|
||||
const echtheitsmarke = chiffre.getAuthTag()
|
||||
|
||||
return [
|
||||
MARKE,
|
||||
iv.toString('base64url'),
|
||||
echtheitsmarke.toString('base64url'),
|
||||
inhalt.toString('base64url'),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsselt einen Text. Gibt null zurück, wenn er nicht zum Schlüssel
|
||||
* passt oder verändert wurde – das ist kein Absturzgrund, sondern heißt:
|
||||
* das Konto muss neu verbunden werden.
|
||||
*/
|
||||
export function entschluesseln(gespeichert: string | null): string | null {
|
||||
if (!gespeichert) return null
|
||||
|
||||
const teile = gespeichert.split(':')
|
||||
if (teile.length !== 4 || teile[0] !== MARKE) return null
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(teile[1] ?? '', 'base64url')
|
||||
const echtheitsmarke = Buffer.from(teile[2] ?? '', 'base64url')
|
||||
const inhalt = Buffer.from(teile[3] ?? '', 'base64url')
|
||||
|
||||
const dechiffre = createDecipheriv(ALGORITHMUS, schluessel(), iv)
|
||||
dechiffre.setAuthTag(echtheitsmarke)
|
||||
return Buffer.concat([dechiffre.update(inhalt), dechiffre.final()]).toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur für Tests: erzwingt beim nächsten Aufruf ein erneutes Ableiten. */
|
||||
export function schluesselVergessen(): void {
|
||||
zwischengespeichert = null
|
||||
}
|
||||
@@ -30,6 +30,17 @@ const schema = z.object({
|
||||
WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'),
|
||||
WEBAUTHN_ORIGIN: z.url(),
|
||||
|
||||
// --- Kalenderanbieter (M3) ---
|
||||
// Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte
|
||||
// tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts.
|
||||
GRAPH_CLIENT_ID: z.string().trim().default(''),
|
||||
GRAPH_CLIENT_SECRET: z.string().trim().default(''),
|
||||
/// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||
GRAPH_TENANT_ID: z.string().trim().default('organizations'),
|
||||
|
||||
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
||||
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
||||
|
||||
DEMO_MODUS: jaNein.default(false),
|
||||
})
|
||||
|
||||
@@ -37,6 +48,9 @@ export type Umgebung = z.infer<typeof schema> & {
|
||||
instanz: Instanz
|
||||
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
|
||||
oidcAktiv: boolean
|
||||
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
||||
graphAktiv: boolean
|
||||
googleAktiv: boolean
|
||||
}
|
||||
|
||||
let zwischengespeichert: Umgebung | null = null
|
||||
@@ -56,6 +70,8 @@ function lesen(): Umgebung {
|
||||
oidcAktiv: Boolean(
|
||||
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET,
|
||||
),
|
||||
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
||||
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kind": "calendar#events",
|
||||
"etag": "\"BEISPIEL-LISTE-3\"",
|
||||
"summary": "person@example.invalid",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"nextSyncToken": "BEISPIEL-SYNC-2",
|
||||
"items": []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"kind": "calendar#events",
|
||||
"etag": "\"BEISPIEL-LISTE-1\"",
|
||||
"summary": "person@example.invalid",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"nextPageToken": "BEISPIEL-SEITE-2",
|
||||
"items": [
|
||||
{
|
||||
"kind": "calendar#event",
|
||||
"etag": "\"BEISPIELETAG10\"",
|
||||
"id": "beispiel-termin-a",
|
||||
"status": "confirmed",
|
||||
"summary": "Zahnarzt",
|
||||
"updated": "2026-03-10T09:00:00.000Z",
|
||||
"start": { "dateTime": "2026-03-17T14:00:00+01:00", "timeZone": "Europe/Berlin" },
|
||||
"end": { "dateTime": "2026-03-17T15:00:00+01:00", "timeZone": "Europe/Berlin" }
|
||||
},
|
||||
{
|
||||
"kind": "calendar#event",
|
||||
"etag": "\"BEISPIELETAG11\"",
|
||||
"id": "beispiel-termin-b",
|
||||
"status": "confirmed",
|
||||
"summary": "Urlaubstag",
|
||||
"updated": "2026-03-11T09:00:00.000Z",
|
||||
"start": { "date": "2026-03-20" },
|
||||
"end": { "date": "2026-03-21" }
|
||||
}
|
||||
]
|
||||
}
|
||||