17 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 6d69eebd4d chore(release): 0.8.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:24:31 +02:00
Florian WildeandClaude Opus 5 96e105fe92 docs: DEVLOG-Eintrag fuer M8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:21:00 +02:00
Florian WildeandClaude Opus 5 0ccb21303d docs: MCP beschreiben, Fahrplan abschliessen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:19:30 +02:00
Florian WildeandClaude Opus 5 bdc4260d59 feat: MCP-Server ueber denselben Token wie die API
Sechs Werkzeuge: Aufgaben suchen, Woche ansehen, Kalender auflisten,
Aufgabe anlegen, abhaken und einplanen. Welche davon ein Modell zu sehen
bekommt, haengt an den Rechten des Tokens — was nicht erlaubt ist, taucht
gar nicht erst in der Liste auf. Ein Sprachmodell bekommt keinen eigenen,
weicheren Zugang.

Bewusst wenige und grob geschnittene Werkzeuge. Ein Modell, das zwischen
zwanzig fast gleichen waehlen muss, waehlt falsch; und jedes weitere ist
eine zusaetzliche Moeglichkeit, versehentlich etwas zu aendern.

Zustandslos: Jede Anfrage steht fuer sich. Der Planer hat keine
langlaufenden Vorgaenge, und eine Sitzungsverwaltung waere Zustand, den
mehrere Container nicht teilen koennten.

Jede schreibende Nutzung steht im Protokoll. Ein Modell, das Aufgaben
anlegt, muss genauso nachvollziehbar sein wie ein Mensch — eher mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:31:19 +02:00
Florian WildeandClaude Opus 5 5f45dff1a7 refactor: Einplanen aus der Serveraktion herausloesen
Die Oberflaeche und der MCP-Server sollen dieselbe Stelle benutzen. Zwei
Umsetzungen desselben Vorgangs waeren zwei Gelegenheiten, die Regeln
unterschiedlich hinzuschreiben — und der Kalenderschreibweg samt
Schleifenschutz ist nichts, was man zweimal richtig hinbekommt.

Der Kern liegt bewusst nicht in einer Datei mit 'use server': Dort wird
jede exportierte Funktion zu einem Endpunkt, den der Browser aufrufen
kann. Eine Funktion, die den Akteur als Parameter nimmt, waere dann ein
offenes Tor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:31:17 +02:00
Florian WildeandClaude Opus 5 e2642e51a7 Merge: Meilenstein 7 – Schnellerfassung und API (0.7.0)
Zwei Wege hinein, die es bisher nicht gab: eine Erfassungsseite fuers
Handy, die auch im Funkloch nichts verliert, und eine oeffentliche
Schnittstelle fuer fremde Anwendungen.

Die Schnellerfassung merkt sich Erfasstes zuerst im Geraet und schickt es
dann. Andersherum waere es weg, wenn der Browser mitten im Senden
geschlossen wird. Jede Erfassung traegt eine Kennung, damit eine doppelt
zugestellte Nachricht keine zweite Aufgabe ergibt.

Von einem Token liegt nur der Hash in der Datenbank; der Wert erscheint
genau einmal. Keines der drei Rechte schliesst ein anderes ein — ein
Dienst, der nur meldet, braucht den Bestand nicht zu sehen. Was von
aussen kommt, landet in der Inbox: Die Einordnung bleibt bei dem, der
plant.

Nebenbei ist die Navigation geteilt worden. Sie hatte mit dem
dreizehnten Eintrag erneut Punkte am rechten Rand verschluckt; sie nur
zu verbreitern war nach M6 die falsche Antwort.

Geprueft vor dem Merge: npm run pruefen mit 331 Unit-Tests, 62
Integrationspruefungen, Migrationstest und 33 E2E-Tests.

Offen: Die Handbuchbilder der Erfassungsseite zeigen noch eine falsche
Inbox-Zahl. Der Fehler selbst ist behoben, nur der Bildlauf kam danach
nicht mehr durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:08:41 +02:00
Florian WildeandClaude Opus 5 09ef12920c chore(release): 0.7.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:29:42 +02:00
Florian WildeandClaude Opus 5 6b14981766 docs: Fahrplan, Aenderungen und DEVLOG fuer M7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:34:16 +02:00
Florian WildeandClaude Opus 5 f9a616e596 docs: Handbuchbilder fuer M7 aufnehmen
Die Erfassungsseite zaehlte die Inbox selbst und kam dabei auf eine
andere Zahl als der Zaehler in der Kopfleiste: Ideen zaehlten mit. Sie
benutzt jetzt dieselbe Abfrage. Aufgefallen ist der Widerspruch nur im
aufgenommenen Bild — im Code sah jede Seite fuer sich richtig aus.

Die Bilder der Erfassungsseite zeigen noch die alte Zahl: Der Bildlauf
liess sich danach nicht mehr durchbringen, weil Docker auf diesem Rechner
staendig haengen blieb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:01:32 +02:00
Florian WildeandClaude Opus 5 7919149879 fix: Navigation aufteilen, statt sie weiter zu dehnen
Mit dem dreizehnten Eintrag verschluckte die Leiste erneut Punkte am
rechten Rand — nach M6 zum zweiten Mal. Das Problem ist nicht die Breite,
sondern die Vermischung: Einrichtung und Verwaltung sucht man nicht beim
Arbeiten. Alltag bleibt in der Leiste, alles andere liegt hinter "Mehr".

Ein <details> statt eines Menues aus Skript: Es klappt ohne JavaScript
auf und braucht keine Zustandsverwaltung.

Zurueckgezogene Tokens stehen jetzt unten. Was noch Zugang hat, ist das,
wonach man sucht.

Zwei E2E-Tests greifen den neuen Weg ueber "Mehr" auf. Ein dritter wartet
das Neuladen ab, bevor er navigiert — die Meldung kommt frueher als die
fertige Liste, und Chromium bricht die Navigation sonst ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:52:35 +02:00
Florian WildeandClaude Opus 5 2e57713a8e docs: Handbuchseite zum Erfassen und Beschreibung der Schnittstelle
Die Beschreibung sagt auch, was die Schnittstelle bewusst nicht kann —
Bereich setzen, Aufgaben loeschen, Zeitbloecke anfassen. Das ist eine
Entscheidung und kein Versehen, und wer davorsteht, soll es erfahren,
statt es fuer eine Luecke zu halten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:42:47 +02:00
Florian WildeandClaude Opus 5 10b49cf8df test: Die Tuer der Schnittstelle scharf pruefen
Zehn Faelle, davon sieben, die scheitern muessen: ohne Token, mit
erfundenem Token, mit zu wenig Rechten in beide Richtungen, abgelaufen,
zurueckgezogen, ohne Titel — und einer, der belegt, dass Status und
Bereich aus der Anfrage nicht uebernommen werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:22:48 +02:00
Florian WildeandClaude Opus 5 9565b9a920 feat: Schnellerfassung vom Handy, auch ohne Netz
Ein Feld, ein Knopf, grosse Flaeche. Was ohne Netz erfasst wird, liegt in
IndexedDB, bis es hinausgeht — nicht in localStorage: Ein Eintrag, der
beim Aufraeumen des Speichers verschwindet, ist eine verlorene Aufgabe.

Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren, wenn
der Browser mitten im Senden geschlossen wird. Jede Erfassung traegt eine
vom Geraet vergebene Kennung, damit eine doppelt zugestellte Nachricht
keine zweite Aufgabe ergibt.

Der Dienstarbeiter haelt genau diese eine Seite offline bereit und sonst
nichts. Ein halb veralteter Planer waere schlimmer als gar keiner — ein
Eingabefeld dagegen veraltet nicht, und ohne die Seite naehme die
Warteschlange im Geraet niemandem etwas ab.

Dazu das Manifest, damit sich der Planer als Anwendung ablegen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:09:39 +02:00
Florian WildeandClaude Opus 5 b17c42af56 feat: Tokens verwalten
Anlegen ist Verwaltungssache: Ein Token ist ein Zugang zur ganzen
Anwendung, nicht zu einer Aufgabe.

Beim Anlegen erscheint der Wert ein einziges Mal, mit dem Hinweis, dass
er danach weg ist, und einem curl-Aufruf zum Ausprobieren. Ein Ablauf
laesst sich setzen — ein Token mit Ablauf ist einer, den man nicht
vergisst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:09:36 +02:00
Florian WildeandClaude Opus 5 6d3ebd05ae feat: API v1 fuer Aufgaben
Lesen, erfassen, aendern — mehr nicht. Status, Bereich und Projekt lassen
sich von aussen nicht setzen: Was hereinkommt, landet in der Inbox und
wird hier einsortiert, sonst raeumt ein fremder Dienst die eigene Ordnung
um.

Mit einer Kennung ist das Erfassen wiederholbar: Zweimal dasselbe ergibt
eine Aufgabe, nicht zwei. Das ist der Unterschied zwischen einer
Schnittstelle, die man gefahrlos wiederholen kann, und einer, die bei
jedem Netzhaenger Karteileichen anlegt.

Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token. Lesen
nicht: Ein Dienst, der im Minutentakt abfragt, wuerde das Protokoll
unbrauchbar machen.

Alle Fehlschlaege sehen von aussen gleich aus. Ob ein Token unbekannt,
abgelaufen oder widerrufen ist, geht niemanden etwas an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:09:05 +02:00
Florian WildeandClaude Opus 5 d5c7974af3 feat: Datenmodell und Regeln fuer die oeffentliche Schnittstelle
Gespeichert wird nur der Hash eines Tokens. Der Klartext existiert genau
einmal — beim Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort;
es gibt keinen Grund, es zurueckzulesen, und jeden Grund, es nicht zu
koennen.

Drei Rechte, und keines schliesst ein anderes ein. Ein Dienst, der nur
meldet "hier ist etwas Neues", braucht den Bestand nicht zu sehen — genau
das ist der haeufigste Fall und der, bei dem ein mitgelesener Token am
wenigsten anrichtet.

Der sichtbare Anfang "planer_..." ist Absicht: Wer so einen Wert
versehentlich in ein oeffentliches Verzeichnis schiebt, soll ihn beim
Durchsehen erkennen.

30 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:09:03 +02:00
Florian WildeandClaude Opus 5 bbac268c59 Merge: Meilenstein 6 – Integrationen (0.6.0)
Aufgaben entstehen jetzt auch dort, wo sie anfallen: Zammad-Tickets werden
zu Aufgaben, eine Telegram-Nachricht wird zur Aufgabe, und der Spiegel
bringt dienstliche Belegung nach Hause — ohne Titel.

Ueber allem steht eine Regel: Das fremde System fuehrt Titel, Notiz,
Verweis und Faelligkeit. Bereich, Projekt, Prioritaet und geschaetzter
Aufwand gehoeren der Planung und werden nie von aussen ueberschrieben.
Ohne diese Trennung waere die Wochenplanung nach jedem Lauf kaputt.

Der Wartungsplaner ist bewusst nicht angebunden: Die Schnittstelle steht
noch nicht fest. Die Aufnahmestelle ist vorbereitet, und die Seite
"Integrationen" fuehrt ihn als sichtbare Luecke statt als Leerstelle.

Geprueft vor dem Merge: npm run pruefen mit 301 Unit-Tests, 56
Integrationspruefungen, Migrationstest und 23 E2E-Tests. Der Spiegel ist
end-to-end geprueft, weil beide Enden dieselbe Anwendung sind; Zammad und
Telegram nur gegen die Logik — dafuer fehlen Zugangsdaten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:13:04 +02:00
100 changed files with 4384 additions and 172 deletions
+54
View File
@@ -6,6 +6,60 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.8.0] – 2026-09-13
### Hinzugefügt
- **MCP-Server** unter `/api/mcp` mit sechs Werkzeugen: Aufgaben suchen,
Woche ansehen, Kalender auflisten, Aufgabe anlegen, abhaken und einplanen.
Beschreibung in [docs/MCP.md](docs/MCP.md).
- Die Werkzeugliste richtet sich nach den Rechten des Tokens — was ein Token
nicht darf, sieht das Modell gar nicht erst.
### Geändert
- Das Einplanen liegt jetzt an einer Stelle, die sowohl die Oberfläche als
auch der MCP-Server benutzen. Ein über MCP angelegter Zeitblock verhält
sich damit genau wie ein von Hand gezogener.
### Sicherheit
- MCP benutzt dieselben Tokens und dieselben Rechte wie die Schnittstelle.
Ein Sprachmodell bekommt keinen eigenen Zugang.
- Jede schreibende Nutzung steht im Protokoll, mit dem Vermerk, dass sie
über MCP kam.
- Der Server kann weder löschen noch Bereiche, Projekte, Kalenderkonten,
Einstellungen oder Tokens anfassen.
## [0.7.0] – 2026-09-13
### Hinzugefügt
- **Schnellerfassung vom Handy** unter _Erfassen_: ein Feld, ein Knopf. Was
ohne Netz erfasst wird, bleibt im Gerät und geht hinaus, sobald wieder eine
Verbindung da ist — mit einer Kennung je Erfassung, damit eine doppelt
zugestellte Nachricht keine zweite Aufgabe ergibt.
- Der Planer lässt sich als **App auf dem Startbildschirm ablegen**
(Manifest, eigenes Symbol, eigener Startpunkt).
- **Öffentliche Schnittstelle** unter `/api/v1`: Aufgaben lesen, anlegen,
ändern und abhaken. Beschreibung in [docs/API.md](docs/API.md).
- **Tokens je Anwendung** mit Rechten je Token, wahlweise mit Ablauf,
verwaltbar unter _API_.
- Handbuchseite zur Schnellerfassung.
### Sicherheit
- Von einem Token liegt nur der Fingerabdruck in der Datenbank. Der Wert
erscheint genau einmal — beim Anlegen.
- Die drei Rechte schließen einander nicht ein: Ein Dienst, der nur erfasst,
kann den Bestand nicht lesen.
- Jeder Fehlschlag beim Ausweisen sieht gleich aus. Ob ein Token unbekannt,
abgelaufen oder zurückgezogen ist, verrät die Schnittstelle nicht.
- Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token — auch jeder
abgewiesene Zugriff.
- Die Schnittstelle kann weder löschen noch Bereiche, Projekte, Zeitblöcke
oder Kalender anfassen.
## [0.6.0] – 2026-09-12 ## [0.6.0] – 2026-09-12
### Hinzugefügt ### Hinzugefügt
+9 -3
View File
@@ -39,6 +39,12 @@ wird.
- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und - **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
Titel Titel
- **Schnellerfassung vom Handy**, die auch im Funkloch nichts verliert; der
Planer lässt sich als App auf dem Startbildschirm ablegen
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
- **MCP-Server** für Sprachmodelle — dieselben Tokens, dieselben Rechte
([Beschreibung](docs/MCP.md))
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -47,10 +53,10 @@ wird.
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch - Hell und Dunkel, Oberfläche durchgehend auf Deutsch
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck - Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
### Geplant ### Was noch kommen könnte
Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server. Der [Fahrplan](ROADMAP.md) ist abgearbeitet. Was danach denkbar wäre, steht
Siehe [ROADMAP.md](ROADMAP.md). dort unter „Ideen für später" — und was bewusst nicht kommt, gleich darunter.
## Bildergalerie ## Bildergalerie
+34 -8
View File
@@ -11,8 +11,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
| [M6](#m6--integrationen) | Integrationen | erledigt | | [M6](#m6--integrationen) | Integrationen | erledigt |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | erledigt |
| [M8](#m8--mcp) | MCP | geplant | | [M8](#m8--mcp) | MCP | erledigt |
--- ---
@@ -134,15 +134,41 @@ im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
## M7 – Schnellerfassung und API ## M7 – Schnellerfassung und API
- [ ] Erfassung vom Handy, auch offline **Erledigt am 12.09.2026 mit Version 0.7.0.**
- [ ] Öffentliche API mit Token je Anwendung
- [ ] Rechte je Token, jede Nutzung im Protokoll **Ziel:** Etwas festhalten können, wo man gerade steht — und andere
Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu geben.
- [x] Erfassung vom Handy, auch offline
- [x] Öffentliche API mit Token je Anwendung
- [x] Rechte je Token, jede schreibende Nutzung im Protokoll
- [x] Als App ablegbar (Manifest, eigenes Symbol)
**Offen geblieben:** Die Bilder der Erfassungsseite im Handbuch zeigen noch
eine falsche Inbox-Zahl; der Fehler selbst ist behoben, nur der Bildlauf
kam danach nicht mehr durch.
**Bewusst offen gelassen:** Die Schnittstelle kann keine Bereiche, Projekte
oder Zeitblöcke anfassen und nichts löschen. Was von außen kommt, landet in
der Inbox; die Einordnung bleibt bei dem, der plant. Auch eine Begrenzung der
Aufrufzahl gibt es nicht — bei einer Handvoll eigener Tokens wäre sie mehr
Zeremonie als Schutz.
## M8 – MCP ## M8 – MCP
- [ ] MCP-Server über `@modelcontextprotocol/sdk` **Erledigt am 13.09.2026 mit Version 0.8.0.**
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll **Ziel:** Ein Sprachmodell soll den Alltag erleichtern können — ohne einen
eigenen, weicheren Zugang zu bekommen als jede andere fremde Anwendung.
- [x] MCP-Server über `@modelcontextprotocol/sdk`
- [x] Lesen und Anlegen von Aufgaben und Zeitblöcken
- [x] Rechte wie bei der API, jede schreibende Nutzung im Protokoll
- [x] Werkzeugliste richtet sich nach den Rechten des Tokens
**Bewusst offen gelassen:** Der Server kann nichts löschen, keine Bereiche
oder Projekte anlegen und keine Einstellungen anfassen. Ein Modell soll den
Alltag erleichtern, nicht die Ordnung umbauen.
--- ---
+175 -1
View File
@@ -13,7 +13,181 @@ die Übersicht aus `scripts/zeit.sh summe`.
| M4 | claude | 167 | 2,78 | | M4 | claude | 167 | 2,78 |
| M5 | claude | 420 | 7,00 | | M5 | claude | 420 | 7,00 |
| M6 | claude | 733 | 12,22 | | M6 | claude | 733 | 12,22 |
| **Gesamt** | | **1566** | **26,10** | | M7 | claude | 886 | 14,77 |
| M8 | claude | 69 | 1,15 |
| **Gesamt** | | **2521** | **42,02** |
---
## 2026-09-13 – M8: MCP
**Dauer:** 69 Minuten (14:10–15:19), Art `claude`.
> Der kürzeste Meilenstein — weil das Fundament schon stand. Tokens, Rechte
> und Protokoll kamen mit M7, der Kalenderschreibweg mit M4. Übrig blieb das
> Zusammenstecken.
**Ziel der Sitzung:** Ein Sprachmodell soll den Alltag erleichtern können —
ohne einen eigenen, weicheren Zugang zu bekommen als jede andere fremde
Anwendung.
### Erledigt
- MCP-Server unter `/api/mcp` über `@modelcontextprotocol/sdk`
- Sechs Werkzeuge: suchen, Woche ansehen, Kalender auflisten, anlegen,
abhaken, einplanen
- Das Einplanen aus der Serveraktion herausgelöst, damit Oberfläche und MCP
dieselbe Stelle benutzen
- 6 neue E2E-Tests, Beschreibung in `docs/MCP.md`
### Entscheidungen mit Begründung
**Dieselben Tokens, dieselben Rechte wie die Schnittstelle.** Ein
Sprachmodell ist aus Sicht des Planers eine fremde Anwendung wie jede andere.
Ein eigener Zugangsweg wäre ein zweiter Satz Regeln — und der zweite Satz ist
immer der, den man vergisst zu pflegen.
**Die Werkzeugliste richtet sich nach den Rechten.** Was ein Token nicht
darf, taucht gar nicht erst auf. Ein Modell soll nicht gegen eine Tür laufen,
die für es verschlossen ist; und eine Fehlermeldung als Antwort auf einen
Werkzeugaufruf ist verschwendeter Kontext.
**Wenige, grob geschnittene Werkzeuge.** Ein Modell, das zwischen zwanzig
fast gleichen wählen muss, wählt falsch. Jedes weitere Werkzeug ist zudem
eine zusätzliche Möglichkeit, versehentlich etwas zu ändern.
**Der Server kann nichts löschen.** Abhaken ja, wegräumen nein. Auch keine
Bereiche, Projekte oder Einstellungen anfassen. Ein Modell soll den Alltag
erleichtern, nicht die Ordnung umbauen.
**Das Einplanen liegt jetzt an einer gemeinsamen Stelle** — und die liegt
bewusst **nicht** in einer Datei mit `'use server'`. Dort wird jede
exportierte Funktion zu einem Endpunkt, den der Browser aufrufen kann; eine
Funktion, die den Akteur als Parameter nimmt, wäre dann ein offenes Tor.
**Zustandslos.** Der Planer hat keine langlaufenden Vorgänge, und eine
Sitzungsverwaltung wäre Zustand, den mehrere Container nicht teilen könnten.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Der SDK-Transport erwartet Node-Streams, Next liefert `Request` | Das SDK bringt seit 1.30 `WebStandardStreamableHTTPServerTransport` mit — `handleRequest(Request): Promise<Response>` passt direkt. |
| ESLint beanstandete das Auslesen der Formularfelder | `FormData.get` kann auch eine Datei liefern; die würde beim Stringify zu `[object Object]`. Ein Helfer nimmt jetzt nur Text. |
### Offene Punkte
- Der Server ist gegen die eigenen Tests geprüft, nicht gegen einen echten
Client. Ein Lauf mit Claude Code gegen die laufende Instanz wäre der
Beweis, dass Handschlag und Werkzeugbeschreibungen auch dort taugen.
- Die Handbuchbilder sind unverändert aus M7 — der MCP-Server hat keine
Oberfläche, es gibt nichts abzubilden.
### Commits
- 5f45dff refactor: Einplanen aus der Serveraktion herauslösen
- bdc4260 feat: MCP-Server über denselben Token wie die API
- (docs) MCP beschreiben, Fahrplan abschließen
---
## 2026-09-13 – M7: Schnellerfassung und API
**Dauer:** 886 Minuten (10:15–01:01), Art `claude`.
> Wie schon bei M6 sagt die Zahl mehr über den Rechner als über die Arbeit:
> Fünf hängende Docker-Bauten, einer davon mit einem einzelnen Schritt über
> drei Stunden. Die Bildaufnahme musste achtmal angestoßen werden.
**Ziel der Sitzung:** Etwas festhalten können, wo man gerade steht — und
andere Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu
geben.
### Erledigt
- Regeln der Schnittstelle als reine Logik: Rechte, Tokenaufbau, Eingaben
(30 Tests)
- `/api/v1`: Aufgaben lesen, anlegen, ändern, abhaken
- Tokens mit Rechten und wahlweise Ablauf, verwaltbar in der Anwendung
- Schnellerfassung fürs Handy mit Warteschlange in IndexedDB
- Manifest und Symbol: der Planer lässt sich als App ablegen
- 10 neue E2E-Tests, 6 neue Integrationsprüfungen, Handbuchseite, API-Doku
### Entscheidungen mit Begründung
**Von einem Token liegt nur der Hash in der Datenbank.** Ein Token ist ein
Passwort; es gibt keinen Grund, es zurücklesen zu können, und jeden Grund,
es nicht zu können. SHA-256 ohne Salz ist hier richtig — der Wert hat 32
Byte Zufall, gegen so etwas hilft kein Wörterbuch, und die Prüfung muss bei
jeder Anfrage schnell sein.
**Kein Recht schließt ein anderes ein.** „Erfassen" darf nicht lesen. Ein
Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand nicht zu
sehen — und das ist der häufigste Fall.
**Der sichtbare Anfang heißt `planer_`.** Wer so einen Wert versehentlich in
ein öffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen.
**`/selbst` braucht kein Recht.** Sonst müsste man beim Einrichten raten, ob
der Token falsch ist oder nur zu wenig darf.
**Die Schnittstelle kann Bereich und Projekt nicht setzen.** Was von außen
kommt, landet in der Inbox. Die Einordnung ist Sache dessen, der plant,
nicht dessen, der meldet. Löschen kann sie auch nicht: Ein Fehler im fremden
Skript soll keine Planung vernichten.
**Erst anstellen, dann senden.** Die Schnellerfassung schreibt zuerst in die
Warteschlange des Geräts und versucht dann zu senden. Andersherum wäre der
Eintrag weg, wenn der Browser mitten im Senden geschlossen wird.
**IndexedDB statt localStorage.** Das sind echte Daten des Benutzers. Ein
Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
Aufgabe — genau das, was der Planer verhindern soll.
**Der Dienstarbeiter hält genau eine Seite vor.** Ein halb veralteter Planer
wäre schlimmer als gar keiner; ein leeres Eingabefeld dagegen veraltet
nicht. Und ohne die Seite nützte die Warteschlange niemandem: Man käme gar
nicht erst hin.
**Die Navigation wurde geteilt statt weiter gedehnt.** Nach M6 hatte ich die
Leiste verbreitert; mit dem dreizehnten Eintrag verschluckte sie erneut
Punkte. Das war die falsche Antwort: Das Problem ist nicht die Breite,
sondern die Vermischung. Alltag steht in der Leiste, Einrichtung und
Verwaltung liegen hinter „Mehr".
### Probleme und Lösungen
| Problem | Lösung |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Die Erfassungsseite zählte die Inbox anders als die Kopfleiste | Sie zählte Ideen mit. Jetzt benutzt sie dieselbe Abfrage. Derselbe Fehlertyp wie in M2 — und wieder nur im Bild aufgefallen, im Code sah jede Seite für sich richtig aus. |
| Die Leiste verschluckte mit dem dreizehnten Eintrag wieder Punkte | Aufgeteilt statt verbreitert, siehe oben. Zwei E2E-Tests greifen den neuen Weg über „Mehr" auf. |
| Ein E2E-Test brach mit `ERR_ABORTED` ab | Die Erfolgsmeldung kommt, bevor das Neuladen der Liste fertig ist; wer dann navigiert, bricht es ab. Der Test wartet jetzt darauf. |
| `exactOptionalPropertyTypes` und Prismas Update-Typen vertragen kein `undefined` | Das Datenobjekt wird Feld für Feld gebaut statt per Spread. Ein gesetztes `undefined` ist etwas anderes als ein fehlendes Feld. |
| Ein Devanagari-Zeichen war in ein Token-Präfix der Demo-Daten geraten | Entfernt. Beim Schreiben über die Shell passiert so etwas; die Datei danach durchsucht. |
| Fünf hängende Docker-Bauten, einer über drei Stunden | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Zweimal scheiterte zusätzlich das Anlegen des Compose-Netzes. |
### Offene Punkte
- **Die Bilder der Erfassungsseite zeigen die alte Inbox-Zahl.** Der Bildlauf
ließ sich nach der Korrektur nicht mehr durchbringen. Ein Lauf, wenn Docker
wieder mitspielt, räumt das auf.
- Keine Begrenzung der Aufrufzahl je Token. Bei einer Handvoll eigener
Tokens wäre sie mehr Zeremonie als Schutz — bei einem Token, der außer
Haus geht, sähe das anders aus.
- Die Warteschlange kennt keinen Abbruch: Ein Eintrag, der dauerhaft
scheitert, wird ewig erneut versucht. Bisher gibt es keinen Fall, in dem
das passieren könnte, ohne dass gleichzeitig die Anmeldung fehlt.
### Commits
- d5c7974 feat: Datenmodell und Regeln für die öffentliche Schnittstelle
- 6d3ebd0 feat: API v1 für Aufgaben
- b17c42a feat: Tokens verwalten
- 9565b9a feat: Schnellerfassung vom Handy, auch ohne Netz
- 10b49cf test: Die Tür der Schnittstelle scharf prüfen
- 2e57713 docs: Handbuchseite zum Erfassen und Beschreibung der Schnittstelle
- 7919149 fix: Navigation aufteilen, statt sie weiter zu dehnen
- f9a616e docs: Handbuchbilder für M7 aufnehmen
--- ---
+2
View File
@@ -5,3 +5,5 @@ datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig 2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig 2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig 2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
2026-09-13;14:10;15:19;69;M8;claude;M8 MCP fertig
1 datum start ende minuten meilenstein art notiz
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
7 2026-09-11 20:47 09:00 733 M6 claude M6 Integrationen fertig
8 2026-09-12 10:15 01:01 886 M7 claude M7 Schnellerfassung und API fertig
9 2026-09-13 14:10 15:19 69 M8 claude M8 MCP fertig
+158
View File
@@ -0,0 +1,158 @@
# Öffentliche Schnittstelle
Für fremde Anwendungen, Skripte und Automaten. Wer den Planer selbst bedient,
braucht sie nicht.
Basisadresse: `<APP_URL>/api/v1`
## Ausweisen
Jede Anfrage trägt einen Token:
```bash
curl -H "Authorization: Bearer planer_ab12cd34_…" \
https://planer.example.de/api/v1/selbst
```
Alternativ `X-Planer-Token: planer_…`, falls das Werkzeug keine
Authorization-Kopfzeile kann.
Tokens legst du in der Anwendung unter **API** an (nur Verwaltung). Der Wert
erscheint **einmal**; danach liegt in der Datenbank nur sein Fingerabdruck.
Wer ihn verliert, legt einen neuen an.
## Rechte
Drei Stück, und **keines schließt ein anderes ein**:
| Recht | Was damit geht |
| ----------- | -------------------------------------- |
| `lesen` | Aufgaben abfragen |
| `erfassen` | Neue Aufgaben anlegen — mehr nicht |
| `schreiben` | Bestehende Aufgaben ändern und abhaken |
Ein Dienst, der nur meldet „hier ist etwas Neues", bekommt `erfassen` und
sonst nichts. Das ist der häufigste Fall und der, bei dem ein mitgelesener
Token am wenigsten anrichtet.
## Fehler
| Status | Bedeutung |
| ------ | ----------------------------------------------------------- |
| 400 | Die Anfrage war fehlerhaft (Feldliste steht in der Antwort) |
| 401 | Kein gültiger Token |
| 403 | Dieser Token darf das nicht |
| 404 | Gibt es nicht |
401 sieht immer gleich aus — ob ein Token unbekannt, abgelaufen oder
zurückgezogen ist, verrät die Schnittstelle nicht. Von außen soll sich nicht
abfragen lassen, welche Tokens es gibt.
## Endpunkte
### `GET /selbst`
Wer bin ich, was darf ich? Braucht **kein** Recht — sonst müsste man raten,
ob der Token falsch ist oder nur zu wenig darf.
```json
{ "name": "Wartungsplaner-Import", "rechte": ["erfassen"] }
```
### `GET /aufgaben` · Recht `lesen`
| Parameter | Standard | Bedeutung |
| --------- | -------- | ------------------- |
| `grenze` | 25 | Höchstens 100 |
| `versatz` | 0 | Zum Blättern |
| `offen` | — | `true` oder `false` |
```json
{
"aufgaben": [
{
"id": "clx…",
"titel": "#4711 Drucker klemmt",
"notiz": null,
"status": "OFFEN",
"prioritaet": 3,
"aufwandMinuten": null,
"faelligAm": null,
"quelle": "API",
"ref": "ticket-4711",
"url": "https://…",
"erstelltAm": "2026-03-17T08:20:00.000Z",
"bereich": null,
"projekt": null
}
],
"gesamt": 42,
"grenze": 25,
"versatz": 0
}
```
### `POST /aufgaben` · Recht `erfassen`
```bash
curl -X POST https://planer.example.de/api/v1/aufgaben \
-H "Authorization: Bearer planer_…" \
-H "Content-Type: application/json" \
-d '{"titel":"Drucker klemmt","ref":"ticket-4711"}'
```
| Feld | Pflicht | Bemerkung |
| ---------------- | ------- | ----------------------------------- |
| `titel` | ja | bis 200 Zeichen |
| `notiz` | nein | bis 4000 Zeichen |
| `ref` | nein | Kennung beim Aufrufer — siehe unten |
| `url` | nein | Verweis ins Ursprungssystem |
| `faelligAm` | nein | `JJJJ-MM-TT` |
| `prioritaet` | nein | 1 (dringend) bis 5 (irgendwann) |
| `aufwandMinuten` | nein | 1 bis 1440 |
Antwort: `201` mit der angelegten Aufgabe.
**`ref` macht den Aufruf wiederholbar.** Zweimal dieselbe Kennung ergibt
**eine** Aufgabe, nicht zwei — der zweite Aufruf frischt Titel, Notiz und
Verweis auf. Ohne `ref` entsteht jedes Mal eine neue Aufgabe; dann hast du es
so gewollt.
Benutze `ref`, wann immer es drüben eine stabile Kennung gibt. Das ist der
Unterschied zwischen einer Schnittstelle, die man nach einem Netzhänger
gefahrlos wiederholen kann, und einer, die dabei Karteileichen anlegt.
### `GET /aufgaben/<id>` · Recht `lesen`
### `PATCH /aufgaben/<id>` · Recht `schreiben`
```bash
curl -X PATCH https://planer.example.de/api/v1/aufgaben/clx… \
-H "Authorization: Bearer planer_…" \
-H "Content-Type: application/json" \
-d '{"erledigt":true}'
```
Änderbar sind `titel`, `notiz`, `faelligAm`, `prioritaet`, `aufwandMinuten`
und `erledigt`. Mindestens ein Feld muss dabei sein.
## Was die Schnittstelle bewusst **nicht** kann
- **Bereich und Projekt setzen.** Was von außen kommt, landet in der Inbox.
Die Einordnung ist Sache dessen, der plant, nicht dessen, der meldet —
sonst räumt ein fremder Dienst die eigene Ordnung um.
- **Aufgaben löschen.** Abhaken ja, löschen nein. Ein Fehler im fremden
Skript soll keine Planung vernichten.
- **Zeitblöcke, Bereiche, Projekte oder Kalender anfassen.**
Fehlt dir etwas davon, ist das eine Entscheidung und kein Versehen — sag
Bescheid, dann wird darüber geredet.
## Protokoll
Jede **schreibende** Nutzung landet im Protokoll, mit dem Token, über den sie
kam. Lesen nicht: Ein Dienst, der im Minutentakt abfragt, würde das Protokoll
unbrauchbar machen.
Auch ein abgewiesener Zugriff wird vermerkt. Ein Dienst, der immer wieder an
dieselbe Wand läuft, ist entweder falsch eingerichtet oder versucht etwas.
+83
View File
@@ -0,0 +1,83 @@
# MCP-Server
Der Planer spricht das Model Context Protocol. Damit kann ein Sprachmodell
Aufgaben suchen, anlegen, abhaken und einplanen — mit denselben Tokens und
denselben Rechten wie die [Schnittstelle](API.md).
Adresse: `<APP_URL>/api/mcp`
## Einrichten
1. In der Anwendung unter **API** einen Token anlegen. Gib ihm nur, was das
Modell wirklich braucht.
2. Den Client auf die Adresse zeigen lassen, mit dem Token als Bearer.
Für Claude Code:
```bash
claude mcp add planer --transport http https://planer.example.de/api/mcp \
--header "Authorization: Bearer planer_…"
```
Für Clients, die nur stdio können, dazwischen `mcp-remote` setzen:
```json
{
"mcpServers": {
"planer": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://planer.example.de/api/mcp",
"--header",
"Authorization: Bearer planer_…"
]
}
}
}
```
## Werkzeuge
Welche ein Modell zu sehen bekommt, hängt am Token. Was nicht erlaubt ist,
taucht gar nicht erst in der Liste auf — ein Modell soll nicht gegen eine Tür
laufen, die für es verschlossen ist.
| Werkzeug | Recht | Was es tut |
| -------------------- | ----------- | ----------------------------------------------- |
| `aufgaben_suchen` | `lesen` | Aufgaben auflisten, optional gefiltert |
| `woche_ansehen` | `lesen` | Zeitblöcke einer Woche mit Summe |
| `kalender_auflisten` | `lesen` | Verbundene Kalender samt Kennung |
| `aufgabe_anlegen` | `erfassen` | Neue Aufgabe in der Inbox |
| `aufgabe_abhaken` | `schreiben` | Aufgabe auf erledigt setzen |
| `zeitblock_anlegen` | `schreiben` | Aufgabe einplanen und in den Kalender schreiben |
Zum Einplanen braucht es die Kennung eines Kalenderkontos — daher erst
`kalender_auflisten`, dann `zeitblock_anlegen`.
`zeitblock_anlegen` geht durch dieselbe Stelle wie das Ziehen in der
Wochenansicht: Rasterprüfung, Kalenderschreibweg, Schleifenschutz, Protokoll.
Ein über MCP angelegter Zeitblock verhält sich also genau wie ein von Hand
gezogener.
## Was der Server bewusst **nicht** kann
- **Löschen.** Weder Aufgaben noch Zeitblöcke. Abhaken ja, wegräumen nein.
- **Bereich oder Projekt setzen.** Was hier entsteht, landet in der Inbox.
- **Bereiche, Projekte oder Kalenderkonten anlegen und ändern.**
- **Einstellungen oder Tokens anfassen.**
Ein Modell soll den Alltag erleichtern, nicht die Ordnung umbauen.
## Protokoll
Jede schreibende Nutzung landet im Protokoll — mit dem Vermerk, dass sie über
MCP kam, und mit dem Token. Ein Modell, das Aufgaben anlegt, muss genauso
nachvollziehbar sein wie ein Mensch. Eher noch mehr.
## Ohne Sitzungen
Der Server ist zustandslos: Jede Anfrage steht für sich. Der Planer hat keine
langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den mehrere
Container nicht teilen könnten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 350 KiB

+11 -18
View File
@@ -13,34 +13,27 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung, 3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
Inbox leeren, Filter, Status und Fälligkeit Inbox leeren, Filter, Status und Fälligkeit
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist 4. **[Schnellerfassung vom Handy](erfassen.md)** – als App ablegen, auch
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit ohne Netz erfassen
5. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
6. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
Fortschritt Fortschritt
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google 7. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
verbinden, Belegung verstehen verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den 8. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und 9. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
Telegram einrichten, Ruhezeiten setzen Telegram einrichten, Ruhezeiten setzen
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel 10. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
zwischen Firma und Daheim zwischen Firma und Daheim
## Verwaltung ## Verwaltung
10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat 11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
--- ---
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
Für Sprachmodelle: [MCP-Server](../MCP.md).
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md). Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md). Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
+61
View File
@@ -0,0 +1,61 @@
# Schnellerfassung vom Handy
Die Seite **Erfassen** ist für den Moment gemacht, in dem dir etwas einfällt
und du keine Zeit hast, es einzusortieren: ein Feld, ein Knopf, fertig.
![Die Seite Erfassen](../bilder/erfassen-hell-handy.png)
Alles, was hier hereinkommt, landet in der **Inbox** — ohne Bereich, ohne
Termin. Das Einsortieren passiert später am Rechner, wenn Ruhe dafür ist.
## Als Anwendung ablegen
Der Planer lässt sich auf dem Startbildschirm ablegen und verhält sich dann
wie eine App: eigenes Symbol, kein Browserrahmen, direkt auf dieser Seite.
| Gerät | Weg |
| --------------------- | -------------------------------------------- |
| Android (Chrome) | Menü ⋮ → **App installieren** |
| iPhone (Safari) | Teilen-Symbol → **Zum Home-Bildschirm** |
| Rechner (Chrome/Edge) | Symbol in der Adresszeile → **Installieren** |
## Ohne Netz
**Das ist der eigentliche Punkt dieser Seite.** Im Keller, im Aufzug, im Zug
zwischen zwei Funkzellen: Erfasst wird trotzdem.
Was passiert:
1. Das Erfasste wird **zuerst im Gerät gemerkt**, dann verschickt. Nicht
andersherum — sonst wäre es weg, wenn du den Browser mitten im Senden
schließt.
2. Ohne Netz bleibt es liegen und steht unter dem Feld als „wartet".
3. Sobald wieder Netz da ist, geht es von selbst hinaus. Du bekommst eine
kurze Rückmeldung.
Der Knopf **Jetzt versuchen** stößt das von Hand an, wenn du nicht warten
magst.
> **Geht dabei etwas verloren?** Nein. Jede Erfassung bekommt eine eigene
> Kennung, die mitgeschickt wird. Kommt dieselbe Nachricht zweimal an — weil
> die Verbindung beim ersten Versuch abbrach, die Anfrage aber trotzdem
> durchging —, entsteht trotzdem nur **eine** Aufgabe.
Die Warteschlange liegt in der Datenbank des Browsers, nicht im flüchtigen
Speicher. Sie übersteht das Schließen des Tabs und einen Neustart des
Geräts.
Damit die Seite ohne Netz überhaupt aufgeht, hält der Planer genau **diese
eine Seite** vor. Alle anderen Seiten holt er immer frisch: Ein halb
veralteter Planer wäre schlimmer als gar keiner — ein leeres Eingabefeld
dagegen veraltet nicht.
## Warum nicht die Erfassung auf der Startseite?
Die gibt es weiterhin, und sie ist am Rechner meist der kürzere Weg. Der
Unterschied ist die Verlässlichkeit: Die Schnellerfassung ist die einzige
Stelle, die ohne Netz nichts verliert.
---
Weiter zur [Inbox](aufgaben.md) oder zurück zum [Handbuch](README.md).
+7 -1
View File
@@ -50,7 +50,11 @@ test.describe('Anmeldung', () => {
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat. // Alltagspunkte stehen in der Leiste, Einrichtung liegt hinter "Mehr".
await expect(page.getByRole('link', { name: 'Aufgaben', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeHidden()
await page.getByText('Mehr', { exact: true }).click()
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible() await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
await page.getByTitle('Abmelden').click() await page.getByTitle('Abmelden').click()
@@ -64,6 +68,8 @@ test.describe('Anmeldung', () => {
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
// Das Protokoll liegt hinter "Mehr" – aufklappen, dann hin.
await page.getByText('Mehr', { exact: true }).click()
await page.getByRole('link', { name: 'Protokoll' }).click() await page.getByRole('link', { name: 'Protokoll' }).click()
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
}) })
+187
View File
@@ -0,0 +1,187 @@
import { createHash, randomBytes } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { EMAIL, VERBINDUNG } from './hilfe'
/**
* Die öffentliche Schnittstelle.
*
* Geprüft wird vor allem, was **nicht** geht: ohne Token, mit falschem
* Token, mit zu wenig Rechten, mit abgelaufenem oder zurückgezogenem Token.
* Der gute Fall ist schnell erzählt — die Tür ist das Interessante.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-API-${Date.now()}`
/** Legt einen Token direkt in der Datenbank an und gibt den Klartext zurück. */
async function tokenBauen(
benutzerId: string,
rechte: string,
zusatz: { gueltigBis?: Date; widerrufenAm?: Date } = {},
): Promise<string> {
const praefix = randomBytes(4).toString('hex')
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
await db.apiToken.create({
data: {
benutzerId,
name: `${PRAEFIX} ${rechte}`,
praefix,
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
rechte,
...zusatz,
},
})
return klartext
}
test.describe('API v1', () => {
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
let benutzerId = ''
test.beforeAll(async () => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
benutzerId = benutzer.id
})
test.afterAll(async () => {
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'api.' } } })
await db.$disconnect()
})
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
const antwort = await request.get('/api/v1/aufgaben')
expect(antwort.status()).toBe(401)
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
expect(antwort.headers()['www-authenticate']).toBe('Bearer')
})
test('weist einen erfundenen Token ab', async ({ request }) => {
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer planer_abcdefgh_${'x'.repeat(43)}` },
})
expect(antwort.status()).toBe(401)
})
test('sagt einem gültigen Token, was er darf', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen,erfassen')
const antwort = await request.get('/api/v1/selbst', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.ok()).toBe(true)
expect(await antwort.json()).toMatchObject({ rechte: ['lesen', 'erfassen'] })
})
test('lässt einen Nur-Lesen-Token nicht erfassen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: { titel: `${PRAEFIX} darf nicht` },
})
expect(antwort.status()).toBe(403)
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
})
test('lässt einen Nur-Erfassen-Token nicht lesen', async ({ request }) => {
// Erfassen schließt Lesen ausdrücklich nicht ein.
const token = await tokenBauen(benutzerId, 'erfassen')
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(403)
})
test('weist einen abgelaufenen Token ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen', {
gueltigBis: new Date(Date.now() - 60_000),
})
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(401)
})
test('weist einen zurückgezogenen Token ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen', { widerrufenAm: new Date() })
const antwort = await request.get('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
})
expect(antwort.status()).toBe(401)
})
test('erfasst, wiederholt sich nicht und ändert', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen,erfassen,schreiben')
const kopf = { Authorization: `Bearer ${token}` }
const ref = `${PRAEFIX}-ref`
const erste = await request.post('/api/v1/aufgaben', {
headers: kopf,
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
})
expect(erste.status()).toBe(201)
const angelegt = (await erste.json()) as { id: string; titel: string }
// Dasselbe noch einmal: eine Aufgabe, nicht zwei.
const zweite = await request.post('/api/v1/aufgaben', {
headers: kopf,
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
})
expect(zweite.ok()).toBe(true)
expect(await db.aufgabe.count({ where: { quelle: 'API', quelleRef: ref } })).toBe(1)
// Abhaken über die Schnittstelle.
const geaendert = await request.patch(`/api/v1/aufgaben/${angelegt.id}`, {
headers: kopf,
data: { erledigt: true },
})
expect(geaendert.ok()).toBe(true)
expect((await geaendert.json()) as { status: string }).toMatchObject({
status: 'ERLEDIGT',
})
// Und im Protokoll steht, dass es über die Schnittstelle kam.
const protokoll = await db.auditEintrag.findFirst({
where: { aktion: 'api.geaendert', objektId: angelegt.id },
select: { details: true },
})
expect(protokoll).not.toBeNull()
expect(JSON.stringify(protokoll?.details)).toContain('api')
})
test('lehnt eine Aufgabe ohne Titel ab', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: { titel: ' ' },
})
expect(antwort.status()).toBe(400)
})
test('setzt weder Status noch Bereich aus der Anfrage', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
const bereich = await db.bereich.findFirst({ select: { id: true } })
const antwort = await request.post('/api/v1/aufgaben', {
headers: { Authorization: `Bearer ${token}` },
data: {
titel: `${PRAEFIX} Einordnung bleibt hier`,
status: 'ERLEDIGT',
bereichId: bereich?.id ?? null,
},
})
expect(antwort.status()).toBe(201)
const angelegt = await db.aufgabe.findFirstOrThrow({
where: { titel: `${PRAEFIX} Einordnung bleibt hier` },
select: { status: true, bereichId: true },
})
// Was von außen kommt, landet offen in der Inbox.
expect(angelegt.status).toBe('OFFEN')
expect(angelegt.bereichId).toBeNull()
})
})
+4
View File
@@ -52,6 +52,10 @@ test.describe('Aufgaben', () => {
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id) await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible() await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
// Die Meldung kommt, bevor das Neuladen der Liste fertig ist. Wer jetzt
// navigiert, bricht es ab — und Chromium meldet ERR_ABORTED.
await page.waitForLoadState('networkidle')
// Aus der Inbox ist sie damit verschwunden. // Aus der Inbox ist sie damit verschwunden.
await page.goto('/inbox') await page.goto('/inbox')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
+173
View File
@@ -0,0 +1,173 @@
import { createHash, randomBytes } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type APIRequestContext } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { EMAIL, VERBINDUNG } from './hilfe'
/**
* Der MCP-Endpunkt.
*
* Er spricht JSON-RPC, also wird hier auch JSON-RPC gesprochen — kein
* Nachbau der Bibliothek, sondern echte Aufrufe gegen den laufenden Server.
*
* Der wichtigste Punkt: Die Werkzeugliste hängt an den Rechten des Tokens.
* Was ein Token nicht darf, soll ein Modell gar nicht erst sehen.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-MCP-${Date.now()}`
async function tokenBauen(benutzerId: string, rechte: string): Promise<string> {
const praefix = randomBytes(4).toString('hex')
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
await db.apiToken.create({
data: {
benutzerId,
name: `${PRAEFIX} ${rechte}`,
praefix,
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
rechte,
},
})
return klartext
}
type Antwort = {
result?: { tools?: { name: string }[]; content?: { text: string }[]; isError?: boolean }
error?: { message: string }
}
/** Ein JSON-RPC-Aufruf gegen den Endpunkt. */
async function rufen(
request: APIRequestContext,
token: string | null,
methode: string,
parameter: Record<string, unknown> = {},
) {
const antwort = await request.post('/api/mcp', {
headers: {
'Content-Type': 'application/json',
// Der Streamable-HTTP-Transport besteht auf beidem.
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
data: { jsonrpc: '2.0', id: 1, method: methode, params: parameter },
})
return antwort
}
/** Der Handschlag, den jede MCP-Verbindung zuerst führt. */
const HANDSCHLAG = {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'e2e', version: '1.0.0' },
}
test.describe('MCP', () => {
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
let benutzerId = ''
test.beforeAll(async () => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
benutzerId = benutzer.id
})
test.afterAll(async () => {
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'mcp.' } } })
await db.$disconnect()
})
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
const antwort = await rufen(request, null, 'initialize', HANDSCHLAG)
expect(antwort.status()).toBe(401)
})
test('führt den Handschlag mit gültigem Token', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
const antwort = await rufen(request, token, 'initialize', HANDSCHLAG)
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as {
result?: { serverInfo?: { name: string } }
}
expect(inhalt.result?.serverInfo?.name).toBe('planer')
})
test('zeigt nur die Werkzeuge, die der Token erlaubt', async ({ request }) => {
const nurLesen = await tokenBauen(benutzerId, 'lesen')
await rufen(request, nurLesen, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, nurLesen, 'tools/list')
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
(w) => w.name,
)
expect(namen).toContain('aufgaben_suchen')
expect(namen).toContain('woche_ansehen')
// Erfassen und Schreiben fehlen dem Token – und damit der Liste.
expect(namen).not.toContain('aufgabe_anlegen')
expect(namen).not.toContain('aufgabe_abhaken')
expect(namen).not.toContain('zeitblock_anlegen')
})
test('zeigt einem Erfassen-Token nur das Anlegen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, token, 'tools/list')
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
(w) => w.name,
)
expect(namen).toEqual(['aufgabe_anlegen'])
})
test('legt eine Aufgabe an und protokolliert das', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const titel = `${PRAEFIX} Angebot schreiben`
const antwort = await rufen(request, token, 'tools/call', {
name: 'aufgabe_anlegen',
arguments: { titel, aufwandMinuten: 45 },
})
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as Antwort
expect(inhalt.result?.content?.[0]?.text).toContain('Angelegt')
const aufgabe = await db.aufgabe.findFirstOrThrow({
where: { titel },
select: { quelle: true, bereichId: true, status: true, aufwandMinuten: true },
})
expect(aufgabe.quelle).toBe('MCP')
expect(aufgabe.aufwandMinuten).toBe(45)
// Wie über die Schnittstelle: offen und in der Inbox.
expect(aufgabe.status).toBe('OFFEN')
expect(aufgabe.bereichId).toBeNull()
const eintrag = await db.auditEintrag.findFirst({
where: { aktion: 'mcp.erfasst' },
select: { details: true },
})
expect(JSON.stringify(eintrag?.details)).toContain('mcp')
})
test('lässt ein Werkzeug ohne Recht gar nicht erst aufrufen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, token, 'tools/call', {
name: 'aufgabe_anlegen',
arguments: { titel: `${PRAEFIX} darf nicht` },
})
const inhalt = (await antwort.json()) as Antwort
// Unbekanntes Werkzeug: Es ist für diesen Token nicht registriert.
expect(inhalt.error ?? inhalt.result?.isError).toBeTruthy()
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
})
})
+786 -31
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.6.0", "version": "0.8.0",
"private": true, "private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module", "type": "module",
@@ -42,6 +42,7 @@
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@modelcontextprotocol/sdk": "^1.30.0",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.2.6",
+38
View File
@@ -489,6 +489,44 @@ async function main(): Promise<void> {
}) })
} }
// --- API-Tokens -----------------------------------------------------------
// Erfundene Hashes: Mit diesen Zeilen laesst sich nichts aufrufen, sie
// zeigen nur, wie die Liste aussieht.
await db.apiToken.deleteMany({})
await db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: 'Wartungsplaner-Import',
praefix: 'k7m2xq4b',
hash: 'demo-1',
rechte: 'erfassen',
erstelltAm: new Date(Date.UTC(2026, 0, 14, 9, 30)),
letzteNutzung: new Date(Date.UTC(2026, 2, 17, 5, 12)),
},
})
await db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: 'Auswertung Homelab',
praefix: 'r3wp9dt4',
hash: 'demo-2',
rechte: 'lesen',
gueltigBis: new Date(Date.UTC(2026, 8, 30, 22, 0)),
erstelltAm: new Date(Date.UTC(2026, 1, 2, 18, 45)),
},
})
await db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: 'Altes Skript',
praefix: 'z8n4vt6c',
hash: 'demo-3',
rechte: 'lesen,schreiben',
erstelltAm: new Date(Date.UTC(2025, 10, 3, 11, 0)),
widerrufenAm: new Date(Date.UTC(2026, 1, 20, 8, 15)),
},
})
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
} }
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "api_token" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"praefix" TEXT NOT NULL,
"hash" TEXT NOT NULL,
"rechte" TEXT NOT NULL,
"gueltigBis" TIMESTAMP(3),
"letzteNutzung" TIMESTAMP(3),
"widerrufenAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_token_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_token_praefix_key" ON "api_token"("praefix");
-- CreateIndex
CREATE UNIQUE INDEX "api_token_hash_key" ON "api_token"("hash");
-- CreateIndex
CREATE INDEX "api_token_benutzerId_idx" ON "api_token"("benutzerId");
-- AddForeignKey
ALTER TABLE "api_token" ADD CONSTRAINT "api_token_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32
View File
@@ -39,6 +39,7 @@ model Benutzer {
pushGeraete PushGeraet[] pushGeraete PushGeraet[]
meldungen Meldungsnachweis[] meldungen Meldungsnachweis[]
telegramChats TelegramChat[] telegramChats TelegramChat[]
apiTokens ApiToken[]
@@index([aktiv]) @@index([aktiv])
@@map("benutzer") @@map("benutzer")
@@ -538,3 +539,34 @@ model TelegramKopplung {
@@index([gueltigBis]) @@index([gueltigBis])
@@map("telegram_kopplung") @@map("telegram_kopplung")
} }
/// Zugangstoken fuer die oeffentliche Schnittstelle.
///
/// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim
/// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert,
/// legt ein neues an, statt das alte irgendwo nachzuschlagen.
model ApiToken {
id String @id @default(cuid())
benutzerId String
name String
/// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar.
praefix String @unique
/// SHA-256 des vollstaendigen Tokens.
hash String @unique
/// Kommagetrennt, siehe src/lib/api.ts.
rechte String
gueltigBis DateTime?
letzteNutzung DateTime?
/// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll.
widerrufenAm DateTime?
erstelltAm DateTime @default(now())
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
@@map("api_token")
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "Planer",
"short_name": "Planer",
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke.",
"lang": "de",
"dir": "ltr",
"start_url": "/erfassen",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f7f8fa",
"theme_color": "#f7f8fa",
"icons": [
{
"src": "/symbol.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/symbol-flaeche.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Erfassen",
"short_name": "Erfassen",
"url": "/erfassen"
},
{
"name": "Heute",
"short_name": "Heute",
"url": "/"
}
]
}
+59 -5
View File
@@ -1,18 +1,72 @@
/* /*
* Service Worker des Planers. * Service Worker des Planers.
* *
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die * Zwei Aufgaben: Push-Nachrichten anzeigen, und die Seite "Erfassen" auch
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb * ohne Netz bereithalten.
* veralteter Planer wäre schlimmer als gar keiner. *
* Zwischengespeichert wird **genau diese eine Seite** und sonst nichts. Ein
* halb veralteter Planer wäre schlimmer als gar keiner — ein Eingabefeld
* dagegen veraltet nicht, und ohne die Seite nützte die Warteschlange im
* Gerät niemandem: Man käme gar nicht erst hin.
*/ */
self.addEventListener('install', () => { const LAGER = 'planer-erfassen-v1'
const OFFLINE_SEITE = '/erfassen'
self.addEventListener('install', (ereignis) => {
ereignis.waitUntil(
caches
.open(LAGER)
.then((lager) => lager.add(new Request(OFFLINE_SEITE, { cache: 'reload' })))
.catch(() => undefined),
)
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten. // Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
self.skipWaiting() self.skipWaiting()
}) })
self.addEventListener('activate', (ereignis) => { self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(self.clients.claim()) ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(namen.filter((n) => n !== LAGER).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
)
})
self.addEventListener('fetch', (ereignis) => {
const anfrage = ereignis.request
if (anfrage.method !== 'GET') return
const adresse = new URL(anfrage.url)
if (adresse.origin !== self.location.origin) return
if (adresse.pathname !== OFFLINE_SEITE) return
// Zuerst das Netz: Wer online ist, bekommt die frische Seite. Erst wenn das
// scheitert, kommt die aufgehobene Fassung.
ereignis.respondWith(
fetch(anfrage)
.then((antwort) => {
if (antwort.ok) {
const kopie = antwort.clone()
void caches.open(LAGER).then((lager) => lager.put(OFFLINE_SEITE, kopie))
}
return antwort
})
.catch(() =>
caches
.match(OFFLINE_SEITE)
.then(
(aufgehoben) =>
aufgehoben ??
new Response('Offline und nichts aufgehoben.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
}),
),
),
)
}) })
self.addEventListener('push', (ereignis) => { self.addEventListener('push', (ereignis) => {
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
<!-- Maskierbare Fassung: Das Motiv sitzt in der sicheren Zone, damit ein
Betriebssystem daraus einen Kreis oder ein Quadrat schneiden kann. -->
<rect width="64" height="64" fill="#2563eb" />
<path d="M20 34l8 8 16-18" fill="none" stroke="#ffffff" stroke-width="6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 435 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
<!-- Ein Blatt mit Haken: Liste plus Erledigtes. Bewusst ohne Farbverlauf,
damit es auch als kleines Symbol lesbar bleibt. -->
<rect x="10" y="6" width="44" height="52" rx="8" fill="#2563eb" />
<rect x="20" y="2" width="8" height="10" rx="4" fill="#1e40af" />
<rect x="36" y="2" width="8" height="10" rx="4" fill="#1e40af" />
<path d="M21 33l8 8 15-16" fill="none" stroke="#ffffff" stroke-width="6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 573 B

+2
View File
@@ -37,6 +37,7 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'anmelden', pfad: '/anmelden' }, { name: 'anmelden', pfad: '/anmelden' },
{ name: 'start', pfad: '/' }, { name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' }, { name: 'inbox', pfad: '/inbox' },
{ name: 'erfassen', pfad: '/erfassen' },
{ name: 'aufgaben', pfad: '/aufgaben' }, { name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'woche', pfad: '/woche' }, { name: 'woche', pfad: '/woche' },
{ name: 'projekte', pfad: '/projekte' }, { name: 'projekte', pfad: '/projekte' },
@@ -50,6 +51,7 @@ const AUFNAHMEN: Aufnahme[] = [
pfad: '/einstellungen', pfad: '/einstellungen',
ausschnitt: '[data-bild="benachrichtigungen"]', ausschnitt: '[data-bild="benachrichtigungen"]',
}, },
{ name: 'api', pfad: '/verwaltung/api' },
{ name: 'protokoll', pfad: '/verwaltung/protokoll' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' },
] ]
+42
View File
@@ -67,6 +67,7 @@ async function aufraeumen(): Promise<void> {
}) })
await db.telegramChat.deleteMany({ where: { chatId: testChatId } }) await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } }) await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
await db.apiToken.deleteMany({ where: { name: { startsWith: kennung } } })
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -395,6 +396,43 @@ async function main(): Promise<void> {
}), }),
) )
// --- API-Tokens ----------------------------------------------------------
const token = await db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-token`,
praefix: kennung.slice(-8),
hash: `hash-${kennung}`,
rechte: 'lesen',
},
})
pruefe('API-Token anlegen', token.id.length > 0)
pruefe('Ein frischer Token gilt unbegrenzt', token.gueltigBis === null)
pruefe('Ein frischer Token ist nicht widerrufen', token.widerrufenAm === null)
await scheitert('Dasselbe Praefix nur einmal', () =>
db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-zweiter`,
praefix: kennung.slice(-8),
hash: `anderer-hash-${kennung}`,
rechte: 'lesen',
},
}),
)
await scheitert('Denselben Hash nur einmal', () =>
db.apiToken.create({
data: {
benutzerId: benutzer.id,
name: `${kennung}-dritter`,
praefix: 'anders12',
hash: `hash-${kennung}`,
rechte: 'lesen',
},
}),
)
await db.benutzer.delete({ where: { id: benutzer.id } }) await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -420,6 +458,10 @@ async function main(): Promise<void> {
'Push-Geräte verschwinden mit dem Benutzer', 'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0, (await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
) )
pruefe(
'API-Tokens verschwinden mit dem Benutzer',
(await db.apiToken.count({ where: { name: { startsWith: kennung } } })) === 0,
)
pruefe( pruefe(
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer', 'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0, (await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { CloudOff, Loader2, Plus, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { abarbeiten, alle, anstellen, type Eintrag } from '@/lib/warteschlange'
/**
* Schnellerfassung fürs Handy.
*
* Ein Feld, ein Knopf, große Fläche. Was erfasst wird, geht sofort hinaus —
* und wenn kein Netz da ist, wartet es im Gerät, bis wieder eines da ist.
*
* Der Unterschied zur Erfassung auf der Startseite ist nicht die Optik,
* sondern die Verlässlichkeit: Hier darf nichts verloren gehen, auch nicht
* im Funkloch.
*/
export function Erfassung() {
const [titel, setTitel] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [wartend, setWartend] = useState<Eintrag[]>([])
// Vor der Hydratation weiss der Server nichts vom Netz des Geraets;
// "null" heisst schlicht: noch nicht nachgesehen.
const [online, setOnline] = useState<boolean | null>(null)
const feld = useRef<HTMLTextAreaElement>(null)
const nachsehen = useCallback(async () => {
try {
setWartend(await alle())
} catch {
// Ohne IndexedDB gibt es keine Warteschlange; erfassen geht trotzdem.
}
}, [])
const leeren = useCallback(async () => {
const bericht = await abarbeiten()
if (bericht.gesendet > 0) {
toast.success(
bericht.gesendet === 1
? 'Eine gemerkte Aufgabe ist raus.'
: `${bericht.gesendet} gemerkte Aufgaben sind raus.`,
)
}
await nachsehen()
}, [nachsehen])
useEffect(() => {
function wiederDa() {
setOnline(true)
void leeren()
}
function weg() {
setOnline(false)
}
window.addEventListener('online', wiederDa)
window.addEventListener('offline', weg)
// Den Anfangszustand nicht im Rumpf setzen, sondern als Antwort auf das
// Aussenwelt-Ereignis "die Seite ist jetzt da".
const ersteRunde = window.setTimeout(() => {
setOnline(navigator.onLine)
void nachsehen()
void leeren()
}, 0)
return () => {
window.clearTimeout(ersteRunde)
window.removeEventListener('online', wiederDa)
window.removeEventListener('offline', weg)
}
}, [leeren, nachsehen])
// Der Dienstarbeiter hält die Seite selbst offline bereit. Ohne ihn wäre
// die Warteschlange sinnlos: Man käme gar nicht erst auf die Seite.
useEffect(() => {
if ('serviceWorker' in navigator) {
void navigator.serviceWorker.register('/sw.js').catch(() => undefined)
}
}, [])
async function erfassen(ereignis: React.FormEvent) {
ereignis.preventDefault()
const text = titel.trim()
if (text.length === 0) return
setLaeuft(true)
try {
// Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren,
// wenn der Browser mitten im Senden geschlossen wird.
const eintrag = await anstellen(text)
setTitel('')
feld.current?.focus()
const bericht = await abarbeiten()
await nachsehen()
toast.success(
bericht.gesendet > 0
? 'Erfasst. Liegt in der Inbox.'
: 'Gemerkt. Geht raus, sobald wieder Netz da ist.',
)
// Der Eintrag selbst interessiert nur, wenn er liegen bleibt.
void eintrag
} catch {
toast.error('Das hat nicht geklappt.')
} finally {
setLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
<form onSubmit={(e) => void erfassen(e)} className="flex flex-col gap-3">
<label htmlFor="titel" className="sr-only">
Was ist zu tun?
</label>
<textarea
id="titel"
ref={feld}
value={titel}
onChange={(e) => setTitel(e.target.value)}
onKeyDown={(e) => {
// Auf dem Handy ist Enter ein Zeilenumbruch; am Rechner will man
// damit abschicken.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
void erfassen(e)
}
}}
rows={3}
maxLength={200}
autoFocus
placeholder="Was ist zu tun?"
className="border-rand bg-karte focus-visible:ring-betont/40 w-full resize-none rounded-lg border p-4 text-base outline-none focus-visible:ring-2"
/>
<Button
type="submit"
disabled={laeuft || titel.trim().length === 0}
className="h-12 text-base"
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Plus aria-hidden />
)}
Erfassen
</Button>
</form>
{online === false && (
<p className="text-gedaempft-vordergrund flex items-center gap-2 text-sm">
<CloudOff className="size-4 shrink-0" aria-hidden />
Kein Netz. Erfasstes wird gemerkt und später verschickt.
</p>
)}
{wartend.length > 0 && (
<div className="border-rand flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{wartend.length === 1 ? 'Eine wartet' : `${wartend.length} warten`}
</p>
<Button
art="leise"
groesse="klein"
disabled={laeuft}
onClick={() => void leeren()}
>
<RefreshCw aria-hidden />
Jetzt versuchen
</Button>
</div>
<ul className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
{wartend.map((e) => (
<li key={e.ref} className="truncate">
{e.titel}
</li>
))}
</ul>
</div>
)}
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Inbox } from 'lucide-react'
import { Erfassung } from '@/app/(app)/erfassen/erfassung'
import { zaehlstaende } from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Erfassen' }
export default async function ErfassenSeite() {
await verlangeAnmeldung('/erfassen')
// Dieselbe Zaehlung wie in der Kopfleiste. Eine eigene Abfrage zaehlte
// Ideen mit und widersprach dem Zaehler eine Zeile darueber.
const { inbox } = await zaehlstaende()
return (
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Erfassen</h1>
<p className="text-gedaempft-vordergrund text-sm">
Alles landet in der Inbox und wird später einsortiert. Ohne Netz wird es
gemerkt.
</p>
</div>
<Erfassung />
<Link
href="/inbox"
className="text-gedaempft-vordergrund hover:text-vordergrund flex items-center gap-2 text-sm"
>
<Inbox className="size-4" aria-hidden />
{inbox === 0
? 'Inbox ist leer'
: inbox === 1
? 'Eine Aufgabe in der Inbox'
: `${inbox} Aufgaben in der Inbox`}
</Link>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import type { Metadata } from 'next'
import { KeyRound } from 'lucide-react'
import { TokenVerwaltung } from '@/app/(app)/verwaltung/api/token-verwaltung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import { rechteLesen } from '@/lib/api'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'API-Tokens' }
export default async function ApiSeite() {
await verlangeRolle('ADMIN', '/verwaltung/api')
const tokens = await db.apiToken.findMany({
// Gueltige zuerst, Zurueckgezogenes ans Ende: Was noch Zugang hat, ist
// das, wonach man sucht.
orderBy: [{ widerrufenAm: { sort: 'asc', nulls: 'first' } }, { erstelltAm: 'desc' }],
select: {
id: true,
name: true,
praefix: true,
rechte: true,
gueltigBis: true,
letzteNutzung: true,
widerrufenAm: true,
erstelltAm: true,
},
})
const adresse = umgebung().APP_URL
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<KeyRound className="text-gedaempft-vordergrund size-6" aria-hidden />
<div>
<h1 className="text-2xl font-semibold tracking-tight">API-Tokens</h1>
<p className="text-gedaempft-vordergrund text-sm">
Zugänge für fremde Anwendungen — mit so wenig Rechten wie möglich.
</p>
</div>
</div>
<Card>
<CardKopf>
<CardTitel className="text-base">Tokens</CardTitel>
<CardBeschreibung>
Ein Token ist ein Passwort. Er wird beim Anlegen ein einziges Mal angezeigt;
danach liegt in der Datenbank nur noch sein Fingerabdruck. Wer ihn verliert,
legt einen neuen an — nachschlagen lässt er sich nicht.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<TokenVerwaltung
adresse={adresse}
tokens={tokens.map((t) => ({
id: t.id,
name: t.name,
praefix: t.praefix,
rechte: rechteLesen(t.rechte),
gueltigBis: t.gueltigBis ? alsDatumUhrzeit(t.gueltigBis) : null,
letzteNutzung: t.letzteNutzung ? alsDatumUhrzeit(t.letzteNutzung) : null,
widerrufen: t.widerrufenAm !== null,
erstelltAm: alsDatumUhrzeit(t.erstelltAm),
}))}
/>
</CardInhalt>
</Card>
</div>
)
}
@@ -0,0 +1,244 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { RECHTE, RECHT_BESCHREIBUNG, RECHT_NAME, type Recht } from '@/lib/api'
import { tokenErzeugen, tokenZuruecknehmen } from '@/server/aktionen/api'
export type TokenAnzeige = {
id: string
name: string
praefix: string
rechte: Recht[]
gueltigBis: string | null
letzteNutzung: string | null
widerrufen: boolean
erstelltAm: string
}
export function TokenVerwaltung({
tokens,
adresse,
}: {
tokens: TokenAnzeige[]
adresse: string
}) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [offen, setOffen] = useState(false)
const [frisch, setFrisch] = useState<{ name: string; klartext: string } | null>(null)
function anlegen(daten: FormData) {
starte(async () => {
const antwort = await tokenErzeugen(daten)
if (antwort.ok) {
setFelder({})
setOffen(false)
setFrisch({ name: antwort.name, klartext: antwort.klartext })
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
function zurueckziehen(id: string) {
starte(async () => {
const daten = new FormData()
daten.set('id', id)
const antwort = await tokenZuruecknehmen(daten)
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
return (
<div className="flex flex-col gap-6">
{/* Der frisch erzeugte Token – einmalig */}
{frisch && (
<div className="border-betont bg-betont/5 rounded-md border p-4">
<p className="text-sm font-medium">„{frisch.name}&ldquo; ist angelegt.</p>
<p className="text-gedaempft-vordergrund mt-1 text-xs">
Jetzt kopieren. Sobald du diese Seite verlässt, ist der Wert weg — in der
Datenbank liegt nur sein Fingerabdruck.
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<code className="border-rand bg-karte grow overflow-x-auto rounded-md border px-3 py-2 font-mono text-xs">
{frisch.klartext}
</code>
<Button
art="umriss"
onClick={() => {
void navigator.clipboard
.writeText(frisch.klartext)
.then(() => toast.success('Kopiert.'))
.catch(() => toast.error('Das Kopieren hat nicht geklappt.'))
}}
>
<Copy aria-hidden />
Kopieren
</Button>
<Button art="leise" onClick={() => setFrisch(null)}>
Fertig
</Button>
</div>
<p className="text-gedaempft-vordergrund mt-3 text-xs">
Ausprobieren:{' '}
<code className="text-xs">
curl -H &quot;Authorization: Bearer …&quot; {adresse}/api/v1/selbst
</code>
</p>
</div>
)}
{/* Liste */}
{tokens.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
Noch kein Token vergeben. Ohne Token kommt von außen nichts herein.
</p>
) : (
<ul className="divide-rand divide-y">
{tokens.map((token) => (
<li key={token.id} className="flex flex-wrap items-center gap-3 py-3">
<KeyRound
className={
token.widerrufen
? 'text-gedaempft-vordergrund size-4 shrink-0'
: 'text-erfolg size-4 shrink-0'
}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm">
<span className={token.widerrufen ? 'line-through' : ''}>
{token.name}
</span>
<code className="text-gedaempft-vordergrund text-xs">
planer_{token.praefix}_…
</code>
{token.widerrufen && (
<Kennzeichen art="neutral">Zurückgezogen</Kennzeichen>
)}
{token.rechte.map((r) => (
<Kennzeichen key={r} art="betont">
{RECHT_NAME[r]}
</Kennzeichen>
))}
</p>
<p className="text-gedaempft-vordergrund text-xs">
Angelegt {token.erstelltAm}
{token.gueltigBis ? ` · gilt bis ${token.gueltigBis}` : ''}
{token.letzteNutzung
? ` · zuletzt benutzt ${token.letzteNutzung}`
: ' · noch nie benutzt'}
</p>
</div>
{!token.widerrufen && (
<Button
art="leise"
groesse="quadrat"
title="Zurückziehen"
disabled={laeuft}
onClick={() => zurueckziehen(token.id)}
>
<Trash2 aria-hidden />
<span className="sr-only">Zurückziehen</span>
</Button>
)}
</li>
))}
</ul>
)}
{/* Neu */}
{offen ? (
<form
className="border-rand flex flex-col gap-4 rounded-md border p-4"
action={anlegen}
>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Wofür ist der Token?</Label>
<Input
id="name"
name="name"
placeholder="z. B. Wartungsplaner-Import"
aria-invalid={Boolean(felder.name)}
required
/>
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
</div>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Was darf er?</legend>
{RECHTE.map((recht) => (
<label key={recht} className="flex items-start gap-3">
<input
type="checkbox"
name="rechte"
value={recht}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{RECHT_NAME[recht]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{RECHT_BESCHREIBUNG[recht]}
</span>
</span>
</label>
))}
{felder.rechte && <p className="text-gefahr text-xs">{felder.rechte}</p>}
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="gueltigTage">Gültig für (Tage)</Label>
<Input
id="gueltigTage"
name="gueltigTage"
type="number"
min={1}
max={3650}
placeholder="leer = unbegrenzt"
aria-invalid={Boolean(felder.gueltigTage)}
/>
<p className="text-gedaempft-vordergrund text-xs">
Ein Token mit Ablauf ist einer, den man nicht vergisst.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Anlegen
</Button>
<Button type="button" art="leise" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<div>
<Button art="umriss" onClick={() => setOffen(true)}>
<Plus aria-hidden />
Token anlegen
</Button>
</div>
)}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
import { db } from '@/server/db'
export const dynamic = 'force-dynamic'
/**
* Die Annahmestelle der Schnellerfassung vom Handy.
*
* Getrennt von der öffentlichen Schnittstelle: Hier zählt die Sitzung, kein
* Token. Das ist derselbe Benutzer, der auch die Oberfläche bedient — nur
* eben über eine Seite, die auch ohne Netz funktioniert.
*
* Jede Erfassung trägt eine vom Gerät vergebene Kennung. Wird eine Nachricht
* doppelt zugestellt — weil das Netz beim ersten Versuch wegbrach, die
* Anfrage aber trotzdem ankam —, entsteht keine zweite Aufgabe.
*/
const eingabe = z.object({
titel: z.string().trim().min(1).max(200),
/** Vom Gerät vergeben; macht den Aufruf wiederholbar. */
ref: z.string().trim().min(8).max(64),
})
export async function POST(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = eingabe.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const { titel, ref } = gelesen.data
const vorhanden = await db.aufgabe.findUnique({
where: { quelle_quelleRef: { quelle: 'MANUELL', quelleRef: ref } },
select: { id: true, titel: true },
})
// Schon da: Die Antwort ist dieselbe wie beim ersten Mal. Das Gerät soll
// seinen Eintrag aus der Warteschlange nehmen dürfen, ohne zu grübeln.
if (vorhanden) {
return NextResponse.json({ ok: true, id: vorhanden.id, doppelt: true })
}
const aufgabe = await db.aufgabe.create({
data: { titel, quelle: 'MANUELL', quelleRef: ref },
select: { id: true },
})
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'aufgabe.erfasst',
objektTyp: 'Aufgabe',
objektId: aufgabe.id,
details: { titel, ueber: 'schnellerfassung' },
})
return NextResponse.json({ ok: true, id: aufgabe.id, doppelt: false })
}
+66
View File
@@ -0,0 +1,66 @@
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
import { tokenAusKopfzeilen } from '@/lib/api'
import { tokenPruefen } from '@/server/api/token'
import { serverBauen } from '@/server/mcp/werkzeuge'
export const dynamic = 'force-dynamic'
/**
* Der MCP-Endpunkt.
*
* Ausgewiesen wird sich mit demselben Token wie bei `/api/v1` — ein
* Sprachmodell bekommt keinen eigenen, weicheren Zugang. Welche Werkzeuge es
* überhaupt zu sehen bekommt, hängt an den Rechten des Tokens: Was nicht
* erlaubt ist, taucht gar nicht erst in der Liste auf.
*
* Ohne Sitzungen: Jede Anfrage steht für sich. Der Planer hat keine
* langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den
* mehrere Container nicht teilen könnten.
*/
async function bedienen(anfrage: Request): Promise<Response> {
const aufrufer = await tokenPruefen(
tokenAusKopfzeilen(
anfrage.headers.get('Authorization'),
anfrage.headers.get('X-Planer-Token'),
),
)
if (!aufrufer) {
return Response.json(
{
jsonrpc: '2.0',
error: { code: -32001, message: 'Kein gültiger Token.' },
id: null,
},
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer' } },
)
}
const server = serverBauen(aufrufer)
const transport = new WebStandardStreamableHTTPServerTransport({
// Zustandslos: kein sessionIdGenerator.
enableJsonResponse: true,
})
await server.connect(transport)
try {
return await transport.handleRequest(anfrage)
} finally {
// Jede Anfrage bekommt einen frischen Server; sonst blieben Verbindungen
// offen, die niemand mehr schliesst.
await transport.close()
await server.close()
}
}
export async function POST(anfrage: Request) {
return bedienen(anfrage)
}
export async function GET(anfrage: Request) {
return bedienen(anfrage)
}
export async function DELETE(anfrage: Request) {
return bedienen(anfrage)
}
+143
View File
@@ -0,0 +1,143 @@
import { aenderungEingabe } from '@/lib/api'
import type { Prisma } from '@/generated/prisma/client'
import { db } from '@/server/db'
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
import { jetzt } from '@/server/zeitgeber'
export const dynamic = 'force-dynamic'
/**
* Eine einzelne Aufgabe lesen und ändern.
*
* Ändern heißt: Titel, Notiz, Fälligkeit, Priorität, Aufwand — und abhaken.
* Bereich und Projekt bleiben außen vor; die Einordnung ist Sache dessen, der
* plant, nicht dessen, der meldet.
*/
const AUSWAHL = {
id: true,
titel: true,
notiz: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
quelle: true,
quelleRef: true,
quelleUrl: true,
erstelltAm: true,
bereich: { select: { name: true } },
projekt: { select: { name: true } },
} as const
type Zeile = {
id: string
titel: string
notiz: string | null
status: string
prioritaet: number
aufwandMinuten: number | null
faelligAm: Date | null
quelle: string
quelleRef: string | null
quelleUrl: string | null
erstelltAm: Date
bereich: { name: string } | null
projekt: { name: string } | null
}
function alsAntwort(a: Zeile) {
return {
id: a.id,
titel: a.titel,
notiz: a.notiz,
status: a.status,
prioritaet: a.prioritaet,
aufwandMinuten: a.aufwandMinuten,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
quelle: a.quelle,
ref: a.quelleRef,
url: a.quelleUrl,
erstelltAm: a.erstelltAm.toISOString(),
bereich: a.bereich?.name ?? null,
projekt: a.projekt?.name ?? null,
}
}
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const geprueft = await wache(anfrage, 'lesen')
if (!geprueft.ok) return geprueft.antwort
const { id } = await params
const aufgabe = await db.aufgabe.findUnique({ where: { id }, select: AUSWAHL })
if (!aufgabe) return abgelehnt('nichtGefunden')
return daten(alsAntwort(aufgabe))
}
export async function PATCH(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const geprueft = await wache(anfrage, 'schreiben')
if (!geprueft.ok) return geprueft.antwort
const { aufrufer } = geprueft
const { id } = await params
const rumpf = await rumpfLesen(anfrage)
if (rumpf === undefined) return abgelehnt('ungueltig')
const gelesen = aenderungEingabe.safeParse(rumpf)
if (!gelesen.success) {
return daten(
{
fehler: 'Die Anfrage war fehlerhaft.',
felder: gelesen.error.issues.map((f) => ({
feld: f.path.join('.'),
grund: f.message,
})),
},
400,
)
}
const vorhanden = await db.aufgabe.findUnique({
where: { id },
select: { id: true, status: true },
})
if (!vorhanden) return abgelehnt('nichtGefunden')
const { erledigt, faelligAm, titel, notiz, prioritaet, aufwandMinuten } = gelesen.data
const zeitpunkt = jetzt()
// Feld fuer Feld statt Spread: Mit exactOptionalPropertyTypes ist ein
// gesetztes "undefined" etwas anderes als ein fehlendes Feld, und Prisma
// wuerde daraus einen Schreibversuch machen.
const werte: Prisma.AufgabeUpdateInput = {}
if (titel !== undefined) werte.titel = titel
if (notiz !== undefined) werte.notiz = notiz
if (prioritaet !== undefined) werte.prioritaet = prioritaet
if (aufwandMinuten !== undefined) werte.aufwandMinuten = aufwandMinuten
if (faelligAm !== undefined) {
werte.faelligAm = faelligAm === null ? null : new Date(faelligAm)
}
if (erledigt !== undefined) {
werte.status = erledigt ? 'ERLEDIGT' : 'OFFEN'
werte.erledigtAm = erledigt ? zeitpunkt : null
}
const aufgabe = await db.aufgabe.update({
where: { id: vorhanden.id },
data: werte,
select: AUSWAHL,
})
await apiProtokoll(aufrufer, 'api.geaendert', aufgabe.id, {
felder: Object.keys(gelesen.data),
})
return daten(alsAntwort(aufgabe))
}
+151
View File
@@ -0,0 +1,151 @@
import { abfrage, aufgabeEingabe } from '@/lib/api'
import type { Prisma } from '@/generated/prisma/client'
import { db } from '@/server/db'
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
export const dynamic = 'force-dynamic'
/**
* Aufgaben lesen und anlegen.
*
* Was hier hereinkommt, landet in der **Inbox**: ohne Bereich, ohne Projekt,
* mit Quelle `API`. Ein fremder Dienst soll melden, was es zu tun gibt — wo
* es hingehört, wird hier entschieden.
*/
/** Wie eine Aufgabe nach außen aussieht. Bewusst schmal und stabil. */
function alsAntwort(a: {
id: string
titel: string
notiz: string | null
status: string
prioritaet: number
aufwandMinuten: number | null
faelligAm: Date | null
quelle: string
quelleRef: string | null
quelleUrl: string | null
erstelltAm: Date
bereich: { name: string } | null
projekt: { name: string } | null
}) {
return {
id: a.id,
titel: a.titel,
notiz: a.notiz,
status: a.status,
prioritaet: a.prioritaet,
aufwandMinuten: a.aufwandMinuten,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
quelle: a.quelle,
ref: a.quelleRef,
url: a.quelleUrl,
erstelltAm: a.erstelltAm.toISOString(),
bereich: a.bereich?.name ?? null,
projekt: a.projekt?.name ?? null,
}
}
const AUSWAHL = {
id: true,
titel: true,
notiz: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
quelle: true,
quelleRef: true,
quelleUrl: true,
erstelltAm: true,
bereich: { select: { name: true } },
projekt: { select: { name: true } },
} as const
export async function GET(anfrage: Request) {
const geprueft = await wache(anfrage, 'lesen')
if (!geprueft.ok) return geprueft.antwort
const suche = new URL(anfrage.url).searchParams
const gelesen = abfrage.safeParse({
grenze: suche.get('grenze') ?? undefined,
versatz: suche.get('versatz') ?? undefined,
offen: suche.get('offen'),
})
if (!gelesen.success) return abgelehnt('ungueltig')
const { grenze, versatz, offen } = gelesen.data
const wo: Prisma.AufgabeWhereInput =
offen === null
? {}
: offen
? { status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }
: { status: { in: ['ERLEDIGT', 'VERWORFEN'] } }
const [zeilen, gesamt] = await Promise.all([
db.aufgabe.findMany({
where: wo,
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { erstelltAm: 'desc' }],
take: grenze,
skip: versatz,
select: AUSWAHL,
}),
db.aufgabe.count({ where: wo }),
])
// Lesen wird nicht protokolliert: Es ändert nichts, und ein Dienst, der im
// Minutentakt abfragt, würde das Protokoll unbrauchbar machen.
return daten({ aufgaben: zeilen.map(alsAntwort), gesamt, grenze, versatz })
}
export async function POST(anfrage: Request) {
const geprueft = await wache(anfrage, 'erfassen')
if (!geprueft.ok) return geprueft.antwort
const { aufrufer } = geprueft
const rumpf = await rumpfLesen(anfrage)
if (rumpf === undefined) return abgelehnt('ungueltig')
const gelesen = aufgabeEingabe.safeParse(rumpf)
if (!gelesen.success) {
return daten(
{
fehler: 'Die Anfrage war fehlerhaft.',
felder: gelesen.error.issues.map((f) => ({
feld: f.path.join('.'),
grund: f.message,
})),
},
400,
)
}
const eingabe = gelesen.data
const werte = {
titel: eingabe.titel,
notiz: eingabe.notiz,
quelle: 'API' as const,
quelleUrl: eingabe.url,
// Wie im Formular: Ein reines Datum wird zu Mitternacht UTC. Der Planer
// rechnet Faelligkeiten ohnehin auf Berliner Kalendertage um.
faelligAm: eingabe.faelligAm ? new Date(eingabe.faelligAm) : null,
...(eingabe.prioritaet ? { prioritaet: eingabe.prioritaet } : {}),
...(eingabe.aufwandMinuten ? { aufwandMinuten: eingabe.aufwandMinuten } : {}),
}
// Mit Kennung ist der Aufruf wiederholbar: Zweimal dasselbe ergibt eine
// Aufgabe, nicht zwei. Ohne Kennung entsteht jedes Mal eine neue – dann
// hat der Aufrufer es so gewollt.
const aufgabe = eingabe.ref
? await db.aufgabe.upsert({
where: { quelle_quelleRef: { quelle: 'API', quelleRef: eingabe.ref } },
update: { titel: werte.titel, notiz: werte.notiz, quelleUrl: werte.quelleUrl },
create: { ...werte, quelleRef: eingabe.ref },
select: AUSWAHL,
})
: await db.aufgabe.create({ data: werte, select: AUSWAHL })
await apiProtokoll(aufrufer, 'api.erfasst', aufgabe.id, { ref: eingabe.ref })
return daten(alsAntwort(aufgabe), 201)
}
+26
View File
@@ -0,0 +1,26 @@
import { tokenAusKopfzeilen } from '@/lib/api'
import { tokenPruefen } from '@/server/api/token'
import { abgelehnt, daten } from '@/server/api/wache'
export const dynamic = 'force-dynamic'
/**
* „Wer bin ich, und was darf ich?"
*
* Der erste Aufruf beim Einrichten. Er braucht **kein** Recht — sonst müsste
* man raten, ob der Token falsch ist oder nur zu wenig darf, und genau diese
* Ratestunde soll niemand haben.
*/
export async function GET(anfrage: Request) {
const aufrufer = await tokenPruefen(
tokenAusKopfzeilen(
anfrage.headers.get('Authorization'),
anfrage.headers.get('X-Planer-Token'),
),
)
if (!aufrufer) return abgelehnt('nichtAngemeldet')
// Kein Wort über den Benutzer außer dem Namen: Die Schnittstelle ist kein
// Adressbuch.
return daten({ name: aufrufer.name, rechte: aufrufer.rechte })
}
+7
View File
@@ -11,6 +11,13 @@ export const metadata: Metadata = {
description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.', description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.',
applicationName: 'Planer', applicationName: 'Planer',
robots: { index: false, follow: false }, robots: { index: false, follow: false },
manifest: '/manifest.webmanifest',
icons: {
icon: '/symbol.svg',
apple: '/symbol.svg',
},
// Auf dem iPhone laeuft eine hinzugefuegte Seite sonst mit Browserleiste.
appleWebApp: { capable: true, title: 'Planer', statusBarStyle: 'default' },
} }
export const viewport: Viewport = { export const viewport: Viewport = {
+65 -8
View File
@@ -3,7 +3,7 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { LogOut, Moon, Shield, Sun } from 'lucide-react' import { ChevronDown, LogOut, Moon, Shield, Sun } from 'lucide-react'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import type { Rolle } from '@/generated/prisma/enums' import type { Rolle } from '@/generated/prisma/enums'
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen' import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
@@ -12,6 +12,16 @@ import { Button } from '@/components/ui/button'
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number } type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
/**
* Die Leiste traegt nur, was taeglich gebraucht wird.
*
* Mit jedem Meilenstein kam ein Punkt dazu, und zweimal hat die Leiste
* daraufhin still Eintraege am rechten Rand verschluckt. Das Problem ist
* nicht die Breite, sondern die Vermischung: Einrichtung und Verwaltung
* sucht man nicht beim Arbeiten, sondern wenn man sie braucht. Sie liegen
* deshalb hinter "Mehr".
*/
export function Kopfleiste({ export function Kopfleiste({
name, name,
email, email,
@@ -28,20 +38,27 @@ export function Kopfleiste({
}) { }) {
const EINTRAEGE: Eintrag[] = [ const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' }, { pfad: '/', name: 'Start' },
{ pfad: '/erfassen', name: 'Erfassen' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox }, { pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' }, { pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/woche', name: 'Woche' }, { pfad: '/woche', name: 'Woche' },
{ pfad: '/projekte', name: 'Projekte' }, { pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' }, { pfad: '/ideen', name: 'Ideen' },
]
const WEITERE: Eintrag[] = [
{ pfad: '/bereiche', name: 'Bereiche' }, { pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/integrationen', name: 'Integrationen' }, { pfad: '/integrationen', name: 'Integrationen' },
{ pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/api', name: 'API-Tokens', nurAdmin: true },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
] ]
const pfad = usePathname() const pfad = usePathname()
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle)) const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
const weitere = WEITERE.filter((e) => !e.nurAdmin || istAdmin(rolle))
const imMenue = weitere.some((e) => pfad.startsWith(e.pfad))
return ( return (
<header className="border-rand bg-karte border-b"> <header className="border-rand bg-karte border-b">
@@ -50,13 +67,7 @@ export function Kopfleiste({
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
die zweite Zeile zu drängen. die zweite Zeile zu drängen.
*/} */}
{/* <div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-4 py-3 sm:gap-5">
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
ist schlimmer als eine, die nicht buendig mit dem Text steht.
*/}
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight"> <Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
Planer Planer
</Link> </Link>
@@ -95,6 +106,52 @@ export function Kopfleiste({
</Link> </Link>
) )
})} })}
{/*
Ein <details> statt eines Menues aus Skript: Es klappt ohne
JavaScript auf, schliesst sich beim Klick daneben und braucht
keine Zustandsverwaltung.
*/}
<details className="group relative shrink-0" name="kopfleiste">
<summary
className={cn(
'flex cursor-pointer list-none items-center gap-1 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
imMenue
? 'bg-gedaempft text-vordergrund font-medium'
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
)}
>
Mehr
<ChevronDown
className="size-3.5 transition-transform group-open:rotate-180"
aria-hidden
/>
</summary>
<div className="border-rand bg-karte absolute right-0 z-20 mt-1 flex w-56 flex-col rounded-md border p-1 shadow-md">
{weitere.map((eintrag) => {
const aktiv = pfad.startsWith(eintrag.pfad)
return (
<Link
key={eintrag.pfad}
href={eintrag.pfad}
aria-current={aktiv ? 'page' : undefined}
className={cn(
'rounded-md px-3 py-2 text-sm whitespace-nowrap transition-colors',
aktiv
? 'bg-gedaempft text-vordergrund font-medium'
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
)}
>
{eintrag.nurAdmin && (
<Shield className="mr-1 inline size-3.5" aria-hidden />
)}
{eintrag.name}
</Link>
)
})}
</div>
</details>
</nav> </nav>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
+177
View File
@@ -0,0 +1,177 @@
import { describe, expect, test } from 'vitest'
import {
aenderungEingabe,
abfrage,
aufgabeEingabe,
darf,
rechteLesen,
tokenAusKopfzeilen,
tokenZerlegen,
type Recht,
} from '@/lib/api'
describe('darf', () => {
test('lässt durch, was ausdrücklich erlaubt ist', () => {
expect(darf(['lesen'], 'lesen')).toBe(true)
})
test('Erfassen schließt Lesen nicht ein', () => {
// Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den
// Bestand nicht zu sehen.
expect(darf(['erfassen'], 'lesen')).toBe(false)
})
test('Schreiben schließt Erfassen nicht ein', () => {
expect(darf(['schreiben'], 'erfassen')).toBe(false)
})
test('mehrere Rechte gelten nebeneinander', () => {
const rechte: Recht[] = ['lesen', 'erfassen']
expect(darf(rechte, 'lesen')).toBe(true)
expect(darf(rechte, 'erfassen')).toBe(true)
expect(darf(rechte, 'schreiben')).toBe(false)
})
})
describe('rechteLesen', () => {
test('liest die gespeicherte Liste', () => {
expect(rechteLesen('lesen,erfassen')).toEqual(['lesen', 'erfassen'])
})
test('verträgt Leerzeichen', () => {
expect(rechteLesen(' lesen , schreiben ')).toEqual(['lesen', 'schreiben'])
})
test('wirft Unbekanntes weg, statt daran zu scheitern', () => {
// Ein Recht, das es nicht mehr gibt, darf keine Anfrage abbrechen –
// es soll schlicht nicht gelten.
expect(rechteLesen('lesen,zaubern')).toEqual(['lesen'])
})
test('aus leer wird leer', () => {
expect(rechteLesen('')).toEqual([])
})
})
describe('tokenZerlegen', () => {
const gueltig = `planer_ab12cd34_${'x'.repeat(43)}`
test('zerlegt einen gültigen Token', () => {
expect(tokenZerlegen(gueltig)).toEqual({
praefix: 'ab12cd34',
geheimnis: 'x'.repeat(43),
})
})
test('verträgt Leerzeichen drumherum', () => {
expect(tokenZerlegen(` ${gueltig} `)?.praefix).toBe('ab12cd34')
})
test('lehnt eine fremde Marke ab', () => {
expect(tokenZerlegen(`fremd_ab12cd34_${'x'.repeat(43)}`)).toBeNull()
})
test('lehnt ein zu kurzes Präfix ab', () => {
expect(tokenZerlegen(`planer_ab12_${'x'.repeat(43)}`)).toBeNull()
})
test('lehnt ein zu kurzes Geheimnis ab', () => {
expect(tokenZerlegen('planer_ab12cd34_kurz')).toBeNull()
})
test('lehnt leeren Text ab', () => {
expect(tokenZerlegen('')).toBeNull()
})
})
describe('tokenAusKopfzeilen', () => {
test('liest Bearer', () => {
expect(tokenAusKopfzeilen('Bearer abc', null)).toBe('abc')
})
test('liest Bearer auch klein geschrieben', () => {
expect(tokenAusKopfzeilen('bearer abc', null)).toBe('abc')
})
test('liest die eigene Kopfzeile', () => {
expect(tokenAusKopfzeilen(null, 'abc')).toBe('abc')
})
test('Bearer geht vor', () => {
expect(tokenAusKopfzeilen('Bearer aus-bearer', 'aus-eigener')).toBe('aus-bearer')
})
test('ohne beides null', () => {
expect(tokenAusKopfzeilen(null, null)).toBeNull()
})
test('leerer Bearer zählt nicht', () => {
expect(tokenAusKopfzeilen('Bearer ', null)).toBeNull()
})
})
describe('aufgabeEingabe', () => {
test('nimmt das Nötigste an', () => {
const gelesen = aufgabeEingabe.safeParse({ titel: 'Angebot schreiben' })
expect(gelesen.success).toBe(true)
expect(gelesen.success && gelesen.data.ref).toBeNull()
})
test('lehnt einen leeren Titel ab', () => {
expect(aufgabeEingabe.safeParse({ titel: ' ' }).success).toBe(false)
})
test('lehnt einen unmöglichen Aufwand ab', () => {
// Mehr als ein Tag am Stück ist keine Aufgabe mehr, sondern ein Projekt.
expect(aufgabeEingabe.safeParse({ titel: 'x', aufwandMinuten: 2000 }).success).toBe(
false,
)
})
test('lehnt eine kaputte Fälligkeit ab', () => {
expect(aufgabeEingabe.safeParse({ titel: 'x', faelligAm: 'morgen' }).success).toBe(
false,
)
})
test('kennt Status, Bereich und Projekt nicht', () => {
const gelesen = aufgabeEingabe.safeParse({
titel: 'x',
status: 'ERLEDIGT',
bereichId: 'b1',
})
expect(gelesen.success).toBe(true)
expect(gelesen.success && 'status' in gelesen.data).toBe(false)
expect(gelesen.success && 'bereichId' in gelesen.data).toBe(false)
})
})
describe('aenderungEingabe', () => {
test('nimmt eine einzelne Änderung an', () => {
expect(aenderungEingabe.safeParse({ erledigt: true }).success).toBe(true)
})
test('lehnt eine leere Änderung ab', () => {
expect(aenderungEingabe.safeParse({}).success).toBe(false)
})
})
describe('abfrage', () => {
test('setzt Voreinstellungen', () => {
const gelesen = abfrage.safeParse({})
expect(gelesen.success && gelesen.data).toEqual({
grenze: 25,
versatz: 0,
offen: null,
})
})
test('deckelt die Grenze', () => {
expect(abfrage.safeParse({ grenze: '5000' }).success).toBe(false)
})
test('liest offen als Wahrheitswert', () => {
const gelesen = abfrage.safeParse({ offen: 'true' })
expect(gelesen.success && gelesen.data.offen).toBe(true)
})
})
+169
View File
@@ -0,0 +1,169 @@
import { z } from 'zod'
/**
* Regeln der öffentlichen Schnittstelle.
*
* Ein Token ist ein Passwort, das jemand in eine fremde Anwendung einträgt.
* Deshalb gilt hier dieselbe Vorsicht wie bei der Anmeldung: so wenig Rechte
* wie möglich, jede Nutzung nachvollziehbar, und der Klartext existiert genau
* einmal — beim Anlegen.
*
* Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit.
*/
// --- Rechte ----------------------------------------------------------------
export const RECHTE = ['lesen', 'erfassen', 'schreiben'] as const
export type Recht = (typeof RECHTE)[number]
export const RECHT_NAME: Record<Recht, string> = {
lesen: 'Lesen',
erfassen: 'Erfassen',
schreiben: 'Ändern',
}
export const RECHT_BESCHREIBUNG: Record<Recht, string> = {
lesen: 'Aufgaben, Bereiche und Projekte abfragen.',
erfassen: 'Neue Aufgaben anlegen — mehr nicht.',
schreiben: 'Bestehende Aufgaben ändern und abhaken.',
}
/**
* Erfassen schließt Lesen **nicht** ein.
*
* Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand
* nicht zu sehen. Genau das ist der häufigste Fall — und der, bei dem ein
* mitgelesenes Token am wenigsten anrichtet.
*/
export function darf(rechte: readonly Recht[], gebraucht: Recht): boolean {
return rechte.includes(gebraucht)
}
export function rechteLesen(gespeichert: string): Recht[] {
return gespeichert
.split(',')
.map((r) => r.trim())
.filter((r): r is Recht => (RECHTE as readonly string[]).includes(r))
}
// --- Aufbau des Tokens -----------------------------------------------------
/**
* `planer_<praefix>_<geheimnis>`
*
* Das Präfix steht im Klartext in der Datenbank und macht die Zeile
* auffindbar, ohne dass irgendwo der ganze Token liegt. Es taugt zugleich als
* Wiedererkennung in der Liste — „welchen habe ich da eigentlich vergeben?".
*
* Der sichtbare Anfang ist auch der Grund für das Wort „planer" davor: Wer
* einen solchen Wert versehentlich in ein öffentliches Verzeichnis schiebt,
* soll ihn beim Durchsehen erkennen.
*/
export const TOKEN_MARKE = 'planer'
export const PRAEFIX_LAENGE = 8
const TOKEN_MUSTER = /^planer_([A-Za-z0-9]{8})_([A-Za-z0-9_-]{32,86})$/
export type TokenTeile = { praefix: string; geheimnis: string }
export function tokenZerlegen(wert: string): TokenTeile | null {
const treffer = TOKEN_MUSTER.exec(wert.trim())
if (!treffer) return null
const [, praefix, geheimnis] = treffer
if (!praefix || !geheimnis) return null
return { praefix, geheimnis }
}
/**
* Liest den Token aus der Kopfzeile.
*
* Erlaubt sind `Authorization: Bearer <token>` und — weil manche Werkzeuge
* nichts anderes können — `X-Planer-Token: <token>`.
*/
export function tokenAusKopfzeilen(
autorisierung: string | null,
eigene: string | null,
): string | null {
if (autorisierung?.toLowerCase().startsWith('bearer ')) {
return autorisierung.slice(7).trim() || null
}
return eigene?.trim() || null
}
// --- Eingaben --------------------------------------------------------------
/**
* Eine Aufgabe, wie sie von außen hereinkommt.
*
* Bewusst schmal: Status, Bereich und Projekt lassen sich über die
* Schnittstelle nicht setzen. Was von außen kommt, landet in der Inbox und
* wird hier einsortiert — sonst räumt ein fremder Dienst die eigene Ordnung um.
*/
export const aufgabeEingabe = z.object({
titel: z.string().trim().min(1, 'Ohne Titel geht es nicht.').max(200),
notiz: z.string().trim().max(4000).nullable().default(null),
/**
* Kennung beim Aufrufer. Zweimal dieselbe ergibt **eine** Aufgabe.
*
* Das ist der Unterschied zwischen einer Schnittstelle, die man gefahrlos
* wiederholen kann, und einer, die bei jedem Netzhänger Karteileichen
* anlegt.
*/
ref: z.string().trim().min(1).max(64).nullable().default(null),
url: z.url().max(500).nullable().default(null),
faelligAm: z.iso.date().nullable().default(null),
prioritaet: z.coerce.number().int().min(1).max(5).nullable().default(null),
aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().default(null),
})
export type AufgabeEingabe = z.infer<typeof aufgabeEingabe>
/** Was sich an einer bestehenden Aufgabe ändern lässt. */
export const aenderungEingabe = z
.object({
titel: z.string().trim().min(1).max(200).optional(),
notiz: z.string().trim().max(4000).nullable().optional(),
faelligAm: z.iso.date().nullable().optional(),
prioritaet: z.coerce.number().int().min(1).max(5).optional(),
aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().optional(),
erledigt: z.boolean().optional(),
})
.refine((w) => Object.keys(w).length > 0, 'Es wurde nichts zum Ändern angegeben.')
export type AenderungEingabe = z.infer<typeof aenderungEingabe>
export const tokenEingabe = z.object({
name: z.string().trim().min(1, 'Der Token braucht einen Namen.').max(60),
rechte: z.array(z.enum(RECHTE)).min(1, 'Mindestens ein Recht.'),
/** Tage bis zum Ablauf; null heißt unbegrenzt. */
gueltigTage: z.coerce.number().int().min(1).max(3650).nullable().default(null),
})
// --- Antworten -------------------------------------------------------------
/**
* Fehlerantworten sind knapp und immer gleich aufgebaut.
*
* Kein Hinweis darauf, ob ein Token unbekannt, abgelaufen oder widerrufen
* ist: Von außen soll sich nicht abfragen lassen, welche Tokens es gibt.
*/
export const FEHLER = {
nichtAngemeldet: { status: 401, fehler: 'Kein gültiger Token.' },
nichtErlaubt: { status: 403, fehler: 'Dieser Token darf das nicht.' },
ungueltig: { status: 400, fehler: 'Die Anfrage war fehlerhaft.' },
nichtGefunden: { status: 404, fehler: 'Gibt es nicht.' },
} as const
/** Wie viele Datensätze eine Abfrage höchstens liefert. */
export const SEITE_MAX = 100
export const SEITE_STANDARD = 25
export const abfrage = z.object({
grenze: z.coerce.number().int().min(1).max(SEITE_MAX).default(SEITE_STANDARD),
versatz: z.coerce.number().int().min(0).default(0),
offen: z
.enum(['true', 'false'])
.nullable()
.default(null)
.transform((w) => (w === null ? null : w === 'true')),
})
+138
View File
@@ -0,0 +1,138 @@
/**
* Warteschlange der Schnellerfassung.
*
* Was ohne Netz erfasst wird, liegt im Browser, bis es hinausgeht. IndexedDB
* statt localStorage, weil das hier echte Daten des Benutzers sind: Ein
* Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
* Aufgabe — und damit genau das, was der Planer verhindern soll.
*
* Läuft nur im Browser.
*/
const DATENBANK = 'planer-erfassung'
const LADEN = 'warteschlange'
const FASSUNG = 1
export type Eintrag = {
/** Vom Gerät vergeben; macht das Senden wiederholbar. */
ref: string
titel: string
erfasstAm: number
/** Wie oft das Senden schon scheiterte. */
versuche: number
}
function oeffnen(): Promise<IDBDatabase> {
return new Promise((fertig, fehler) => {
const anfrage = indexedDB.open(DATENBANK, FASSUNG)
anfrage.onupgradeneeded = () => {
const db = anfrage.result
if (!db.objectStoreNames.contains(LADEN)) {
db.createObjectStore(LADEN, { keyPath: 'ref' })
}
}
anfrage.onsuccess = () => fertig(anfrage.result)
anfrage.onerror = () =>
fehler(anfrage.error ?? new Error('IndexedDB ging nicht auf.'))
})
}
async function mitLaden<T>(
art: IDBTransactionMode,
tun: (laden: IDBObjectStore) => IDBRequest,
): Promise<T> {
const db = await oeffnen()
try {
return await new Promise<T>((fertig, fehler) => {
const vorgang = db.transaction(LADEN, art)
const anfrage = tun(vorgang.objectStore(LADEN))
anfrage.onsuccess = () => fertig(anfrage.result as T)
anfrage.onerror = () => fehler(anfrage.error ?? new Error('IndexedDB streikt.'))
})
} finally {
db.close()
}
}
export async function anstellen(titel: string): Promise<Eintrag> {
const eintrag: Eintrag = {
ref: kennungBauen(),
titel,
erfasstAm: Date.now(),
versuche: 0,
}
await mitLaden('readwrite', (laden) => laden.put(eintrag))
return eintrag
}
export async function alle(): Promise<Eintrag[]> {
const eintraege = await mitLaden<Eintrag[]>('readonly', (laden) => laden.getAll())
return eintraege.sort((a, b) => a.erfasstAm - b.erfasstAm)
}
export async function abhaken(ref: string): Promise<void> {
await mitLaden('readwrite', (laden) => laden.delete(ref))
}
export async function versuchZaehlen(eintrag: Eintrag): Promise<void> {
await mitLaden('readwrite', (laden) =>
laden.put({ ...eintrag, versuche: eintrag.versuche + 1 }),
)
}
/**
* Eine Kennung, die auch ohne `crypto.randomUUID` zustande kommt.
*
* Ältere Browser kennen die Funktion nur über HTTPS; die Warteschlange soll
* aber auch dann noch funktionieren, wenn nichts anderes mehr geht.
*/
function kennungBauen(): string {
const quelle: Crypto = crypto
if (typeof quelle.randomUUID === 'function') return quelle.randomUUID()
const zufall = new Uint8Array(16)
quelle.getRandomValues(zufall)
return [...zufall].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Schickt alles hinaus, was noch aussteht.
*
* Gibt zurück, wie viele Einträge angekommen sind. Was scheitert, bleibt
* liegen — beim nächsten Versuch kommt es erneut dran.
*/
export async function abarbeiten(): Promise<{ gesendet: number; offen: number }> {
const warteschlange = await alle()
let gesendet = 0
for (const eintrag of warteschlange) {
try {
const antwort = await fetch('/api/erfassen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel: eintrag.titel, ref: eintrag.ref }),
})
if (antwort.ok) {
await abhaken(eintrag.ref)
gesendet++
continue
}
// 400 heißt: Das wird auch beim zehnten Mal nichts. Liegen lassen wäre
// eine Warteschlange, die nie leer wird.
if (antwort.status === 400) {
await abhaken(eintrag.ref)
continue
}
// 401 und alles Übrige: später noch einmal.
await versuchZaehlen(eintrag)
} catch {
// Kein Netz. Genau dafür gibt es die Warteschlange.
await versuchZaehlen(eintrag)
}
}
return { gesendet, offen: (await alle()).length }
}
+103
View File
@@ -0,0 +1,103 @@
'use server'
import { revalidatePath } from 'next/cache'
import { tokenEingabe } from '@/lib/api'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { tokenAnlegen, tokenWiderrufen } from '@/server/api/token'
export type Rueckmeldung =
| { ok: true; meldung: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
async function akteurOderFehler() {
try {
// Tokens sind Zugänge zur ganzen Anwendung. Sie anzulegen ist
// Verwaltungssache, nicht Alltag.
return { akteur: await pruefeRolle('ADMIN') }
} catch (fehler) {
if (fehler instanceof NichtErlaubt) {
return { antwort: { ok: false as const, fehler: fehler.message } }
}
throw fehler
}
}
export type TokenAntwort =
| { ok: true; klartext: string; name: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
/**
* Legt einen Token an und gibt ihn **einmal** zurück.
*
* Danach steht der Wert nirgends mehr — auch nicht in der Datenbank, dort
* liegt nur sein Hash. Wer ihn verliert, legt einen neuen an.
*/
export async function tokenErzeugen(daten: FormData): Promise<TokenAntwort> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const gelesen = tokenEingabe.safeParse({
name: daten.get('name'),
rechte: daten.getAll('rechte'),
gueltigTage: daten.get('gueltigTage') || null,
})
if (!gelesen.success) {
const felder: Record<string, string> = {}
for (const problem of gelesen.error.issues) {
const feld = problem.path.join('.') || 'rechte'
felder[feld] ??= problem.message
}
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
}
const neu = await tokenAnlegen(
akteur.id,
gelesen.data.name,
gelesen.data.rechte,
gelesen.data.gueltigTage,
)
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'api.token-angelegt',
objektTyp: 'ApiToken',
objektId: neu.id,
// Name, Rechte und Präfix – nie der Token selbst.
details: {
name: gelesen.data.name,
rechte: gelesen.data.rechte.join(','),
praefix: neu.praefix,
},
})
revalidatePath('/verwaltung/api')
return { ok: true, klartext: neu.klartext, name: gelesen.data.name }
}
/** Zieht einen Token zurück. */
export async function tokenZuruecknehmen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const id = daten.get('id')
if (typeof id !== 'string' || id.length === 0) {
return { ok: false, fehler: 'Ungültige Anfrage.' }
}
const geklappt = await tokenWiderrufen(id, akteur.id)
if (!geklappt) return { ok: false, fehler: 'Diesen Token gibt es nicht mehr.' }
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'api.token-widerrufen',
objektTyp: 'ApiToken',
objektId: id,
})
revalidatePath('/verwaltung/api')
return { ok: true, meldung: 'Der Token gilt nicht mehr.' }
}
+26 -95
View File
@@ -2,12 +2,7 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { z } from 'zod' import { z } from 'zod'
import { import { zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen } from '@/lib/zeitblock'
zeitblockSchema,
zeitraumHash,
LAGE_FEHLER_TEXT,
lagePruefen,
} from '@/lib/zeitblock'
import { auditSchreiben } from '@/server/audit' import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
@@ -18,6 +13,7 @@ import {
terminAnlegen, terminAnlegen,
terminEntfernen, terminEntfernen,
} from '@/server/kalender/schreiben' } from '@/server/kalender/schreiben'
import { betreffFuer, zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
export type Rueckmeldung = export type Rueckmeldung =
@@ -56,11 +52,6 @@ async function eigenerZeitblock(id: string, benutzerId: string) {
}) })
} }
/** Was im Kalender als Betreff steht. */
function betreffFuer(titel: string): string {
return titel
}
// --- Anlegen --------------------------------------------------------------- // --- Anlegen ---------------------------------------------------------------
/** /**
@@ -76,99 +67,39 @@ export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
if ('antwort' in geprueft) return geprueft.antwort if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft const { akteur } = geprueft
const gelesen = zeitblockSchema.safeParse({ const ergebnis = await zeitblockAnlegenFuer(
aufgabeId: daten.get('aufgabeId'), { id: akteur.id, email: akteur.email },
kalenderkontoId: daten.get('kalenderkontoId'), {
beginn: daten.get('beginn'), aufgabeId: feld(daten, 'aufgabeId'),
ende: daten.get('ende'), kalenderkontoId: feld(daten, 'kalenderkontoId'),
}) beginn: new Date(feld(daten, 'beginn')),
if (!gelesen.success) { ende: new Date(feld(daten, 'ende')),
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 (!ergebnis.ok) return ergebnis
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('/woche')
revalidatePath('/aufgaben') revalidatePath('/aufgaben')
revalidatePath('/') revalidatePath('/')
return schreibfehler return ergebnis.geschrieben
? { ? { ok: true, id: ergebnis.id, meldung: `„${ergebnis.titel}" eingeplant.` }
: {
ok: false, ok: false,
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`, fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
} }
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` } }
/**
* Ein Textfeld aus dem Formular.
*
* FormData kann auch Dateien liefern; die haetten hier nichts zu suchen und
* wuerden beim Stringify zu "[object Object]".
*/
function feld(daten: FormData, name: string): string {
const wert = daten.get(name)
return typeof wert === 'string' ? wert : ''
} }
// --- Verschieben ----------------------------------------------------------- // --- Verschieben -----------------------------------------------------------
+147
View File
@@ -0,0 +1,147 @@
import 'server-only'
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import {
PRAEFIX_LAENGE,
TOKEN_MARKE,
rechteLesen,
tokenZerlegen,
type Recht,
} from '@/lib/api'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Tokens für die öffentliche Schnittstelle.
*
* Gespeichert wird nur der Hash. Ein Token ist ein Passwort — es gibt keinen
* Grund, es zurücklesen zu können, und jeden Grund, es nicht zu können.
*
* SHA-256 ohne Salz und ohne Streckung ist hier richtig, anders als bei
* Passwörtern: Der Wert hat 32 Byte Zufall aus einer sicheren Quelle. Gegen
* so etwas hilft kein Wörterbuch, und die Prüfung muss bei jeder Anfrage
* schnell sein.
*/
/** Ziffern und Kleinbuchstaben — das Präfix wird auch mal abgetippt. */
const PRAEFIX_ZEICHEN = 'abcdefghijkmnopqrstuvwxyz23456789'
function praefixBauen(): string {
const bytes = randomBytes(PRAEFIX_LAENGE)
return [...bytes].map((b) => PRAEFIX_ZEICHEN[b % PRAEFIX_ZEICHEN.length]).join('')
}
export function tokenHashen(wert: string): string {
return createHash('sha256').update(wert, 'utf8').digest('hex')
}
export type NeuerToken = {
id: string
/** Nur hier, nur jetzt. Danach steht der Wert nirgends mehr. */
klartext: string
praefix: string
}
/** Legt einen Token an und gibt ihn ein einziges Mal heraus. */
export async function tokenAnlegen(
benutzerId: string,
name: string,
rechte: readonly Recht[],
gueltigTage: number | null,
): Promise<NeuerToken> {
const praefix = praefixBauen()
const geheimnis = randomBytes(32).toString('base64url')
const klartext = `${TOKEN_MARKE}_${praefix}_${geheimnis}`
const zeile = await db.apiToken.create({
data: {
benutzerId,
name,
praefix,
hash: tokenHashen(klartext),
rechte: rechte.join(','),
gueltigBis:
gueltigTage === null
? null
: new Date(jetzt().getTime() + gueltigTage * 86_400_000),
},
select: { id: true },
})
return { id: zeile.id, klartext, praefix }
}
export type Aufrufer = {
tokenId: string
benutzerId: string
email: string
name: string
rechte: Recht[]
}
/**
* Prüft einen Token und gibt den Aufrufer zurück.
*
* Alle Fehlschläge sehen von außen gleich aus: unbekannt, abgelaufen,
* widerrufen oder zu einem gesperrten Konto gehörend. Wer probiert, soll
* nicht erfahren, welcher Fall vorliegt.
*/
export async function tokenPruefen(wert: string | null): Promise<Aufrufer | null> {
if (!wert) return null
const teile = tokenZerlegen(wert)
if (!teile) return null
const zeile = await db.apiToken.findUnique({
where: { praefix: teile.praefix },
select: {
id: true,
hash: true,
rechte: true,
gueltigBis: true,
widerrufenAm: true,
benutzer: { select: { id: true, email: true, name: true, aktiv: true } },
},
})
if (!zeile) return null
// Auch wenn das Präfix stimmt: Der Rest muss ebenfalls passen, und der
// Vergleich läuft in gleichbleibender Zeit.
const erwartet = Buffer.from(zeile.hash, 'utf8')
const bekommen = Buffer.from(tokenHashen(wert.trim()), 'utf8')
if (erwartet.length !== bekommen.length) return null
if (!timingSafeEqual(erwartet, bekommen)) return null
const zeitpunkt = jetzt()
if (zeile.widerrufenAm) return null
if (zeile.gueltigBis && zeile.gueltigBis.getTime() < zeitpunkt.getTime()) return null
if (!zeile.benutzer.aktiv) return null
// Nicht auf das Schreiben warten: Die Anfrage soll deswegen nicht langsamer
// werden, und ein verlorener Zeitstempel ist zu verschmerzen.
void db.apiToken
.update({ where: { id: zeile.id }, data: { letzteNutzung: zeitpunkt } })
.catch(() => undefined)
return {
tokenId: zeile.id,
benutzerId: zeile.benutzer.id,
email: zeile.benutzer.email,
name: zeile.benutzer.name ?? zeile.benutzer.email,
rechte: rechteLesen(zeile.rechte),
}
}
/** Zieht einen Token zurück. Die Zeile bleibt — für das Protokoll. */
export async function tokenWiderrufen(id: string, benutzerId: string): Promise<boolean> {
const zeile = await db.apiToken.findFirst({
where: { id, benutzerId, widerrufenAm: null },
select: { id: true },
})
if (!zeile) return false
await db.apiToken.update({
where: { id: zeile.id },
data: { widerrufenAm: jetzt() },
})
return true
}
+102
View File
@@ -0,0 +1,102 @@
import 'server-only'
import { NextResponse } from 'next/server'
import { FEHLER, darf, tokenAusKopfzeilen, type Recht } from '@/lib/api'
import { auditSchreiben } from '@/server/audit'
import { tokenPruefen, type Aufrufer } from '@/server/api/token'
/**
* Die Tür zur öffentlichen Schnittstelle.
*
* Jede Route geht hier durch, und jede Route protokolliert. Eine
* Schnittstelle, bei der sich hinterher nicht sagen lässt, wer was geändert
* hat, ist in einer Anwendung mit fremden Zugängen nicht zu verantworten.
*/
export type Wachergebnis =
{ ok: true; aufrufer: Aufrufer } | { ok: false; antwort: NextResponse }
function fehler(art: keyof typeof FEHLER): NextResponse {
const { status, fehler: text } = FEHLER[art]
return NextResponse.json(
{ fehler: text },
{
status,
headers: {
'Cache-Control': 'no-store',
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
...(status === 401 ? { 'WWW-Authenticate': 'Bearer' } : {}),
},
},
)
}
/** Prüft Token und Recht in einem Zug. */
export async function wache(anfrage: Request, gebraucht: Recht): Promise<Wachergebnis> {
const wert = tokenAusKopfzeilen(
anfrage.headers.get('Authorization'),
anfrage.headers.get('X-Planer-Token'),
)
const aufrufer = await tokenPruefen(wert)
if (!aufrufer) return { ok: false, antwort: fehler('nichtAngemeldet') }
if (!darf(aufrufer.rechte, gebraucht)) {
// Das Fehlen eines Rechts wird protokolliert: Ein Dienst, der immer
// wieder an dieselbe Wand läuft, ist entweder falsch eingerichtet oder
// versucht etwas.
await auditSchreiben({
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
aktion: 'api.abgewiesen',
objektTyp: 'ApiToken',
objektId: aufrufer.tokenId,
details: { gebraucht, hat: aufrufer.rechte.join(',') },
})
return { ok: false, antwort: fehler('nichtErlaubt') }
}
return { ok: true, aufrufer }
}
/** Antwort mit Daten. Nie zwischenspeichern — die Liste ändert sich ständig. */
export function daten(inhalt: unknown, status = 200): NextResponse {
return NextResponse.json(inhalt, {
status,
headers: { 'Cache-Control': 'no-store' },
})
}
export function abgelehnt(art: keyof typeof FEHLER): NextResponse {
return fehler(art)
}
/**
* Liest den Rumpf als JSON, ohne bei Unsinn zu stolpern.
*
* Gibt `undefined` zurueck, wenn sich nichts lesen liess — das ist von
* einem gelesenen `null` zu unterscheiden.
*/
export async function rumpfLesen(anfrage: Request): Promise<unknown> {
try {
return (await anfrage.json()) as unknown
} catch {
return undefined
}
}
/** Schreibt einen Protokolleintrag für eine Nutzung der Schnittstelle. */
export async function apiProtokoll(
aufrufer: Aufrufer,
aktion: string,
objektId: string | null,
details?: Record<string, unknown>,
): Promise<void> {
await auditSchreiben({
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
aktion,
objektTyp: 'Aufgabe',
objektId,
// Der Name des Tokens gehört dazu: Sonst steht im Protokoll nur, dass
// "der Benutzer" es war — und das war er ja gerade nicht selbst.
details: { ueber: 'api', token: aufrufer.tokenId, ...details },
})
}
+306
View File
@@ -0,0 +1,306 @@
import 'server-only'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { darf } from '@/lib/api'
import { alsDatum, alsDauer, alsUhrzeitAnzeige, wochenBeginn } from '@/lib/zeit'
import { auditSchreiben } from '@/server/audit'
import type { Aufrufer } from '@/server/api/token'
import { db } from '@/server/db'
import { zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
import { jetzt } from '@/server/zeitgeber'
/**
* Der MCP-Server des Planers.
*
* Er hängt an denselben Tokens und denselben Rechten wie die Schnittstelle
* aus M7 — ein Sprachmodell bekommt keinen eigenen, weicheren Zugang. Was
* ein Token nicht darf, kann auch ein Werkzeug nicht.
*
* Die Werkzeuge sind bewusst wenige und grob geschnitten. Ein Modell, das
* zwischen zwanzig fast gleichen Werkzeugen wählen muss, wählt falsch; und
* jedes zusätzliche Werkzeug ist eine weitere Möglichkeit, versehentlich
* etwas zu ändern.
*/
/** Antwort in der Form, die MCP erwartet. */
function text(inhalt: string) {
return { content: [{ type: 'text' as const, text: inhalt }] }
}
function fehler(inhalt: string) {
return { content: [{ type: 'text' as const, text: inhalt }], isError: true }
}
const STATUS_OFFEN = ['OFFEN', 'GEPLANT', 'WARTET'] as const
/** Wie viele Datensätze ein Werkzeug höchstens zurückgibt. */
const GRENZE = 50
export function serverBauen(aufrufer: Aufrufer): McpServer {
const server = new McpServer(
{ name: 'planer', version: '1.0.0' },
{
instructions: [
'Der Planer verwaltet Aufgaben, Ideen, Projekte und Zeitblöcke einer',
'einzelnen Person. Aufgaben, die über diese Werkzeuge entstehen,',
'landen in der Inbox und werden von Hand einsortiert — setze also',
'keinen Bereich und kein Projekt, das geht hier ohnehin nicht.',
'Zeiten sind Europe/Berlin.',
].join(' '),
},
)
// --- Lesen ---------------------------------------------------------------
if (darf(aufrufer.rechte, 'lesen')) {
server.registerTool(
'aufgaben_suchen',
{
title: 'Aufgaben suchen',
description:
'Listet Aufgaben. Ohne Angaben die offenen, nach Fälligkeit sortiert.',
inputSchema: {
suche: z.string().max(100).optional().describe('Text im Titel'),
nurOffene: z.boolean().default(true),
nurUeberfaellige: z.boolean().default(false),
},
},
async ({ suche, nurOffene, nurUeberfaellige }) => {
const zeitpunkt = jetzt()
const aufgaben = await db.aufgabe.findMany({
where: {
typ: { not: 'IDEE' },
...(nurOffene ? { status: { in: [...STATUS_OFFEN] } } : {}),
...(nurUeberfaellige ? { faelligAm: { lt: zeitpunkt } } : {}),
...(suche ? { titel: { contains: suche, mode: 'insensitive' } } : {}),
},
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { prioritaet: 'asc' }],
take: GRENZE,
select: {
id: true,
titel: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
bereich: { select: { name: true } },
},
})
if (aufgaben.length === 0) return text('Keine Aufgabe passt dazu.')
const zeilen = aufgaben.map((a) => {
const teile = [
a.titel,
`(${a.id})`,
a.status,
`Prio ${a.prioritaet}`,
a.aufwandMinuten ? alsDauer(a.aufwandMinuten) : 'ohne Schätzung',
a.faelligAm ? `fällig ${alsDatum(a.faelligAm)}` : 'ohne Termin',
a.bereich?.name ?? 'Inbox',
]
return `- ${teile.join(' · ')}`
})
return text(zeilen.join('\n'))
},
)
server.registerTool(
'woche_ansehen',
{
title: 'Woche ansehen',
description:
'Zeigt die eingeplanten Zeitblöcke einer Woche samt freier Kapazität.',
inputSchema: {
versatz: z
.number()
.int()
.min(-52)
.max(52)
.default(0)
.describe('0 = diese Woche, 1 = nächste'),
},
},
async ({ versatz }) => {
const zeitpunkt = new Date(jetzt().getTime() + versatz * 7 * 86_400_000)
const von = wochenBeginn(zeitpunkt)
const bis = new Date(von.getTime() + 7 * 86_400_000)
const bloecke = await db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId: aufrufer.benutzerId },
beginn: { gte: von, lt: bis },
},
orderBy: { beginn: 'asc' },
select: {
beginn: true,
ende: true,
aufgabe: { select: { titel: true } },
},
})
if (bloecke.length === 0) {
return text(`Ab ${alsDatum(von)} ist noch nichts eingeplant.`)
}
const zeilen = bloecke.map(
(b) =>
`- ${alsDatum(b.beginn)} ${alsUhrzeitAnzeige(b.beginn)}–${alsUhrzeitAnzeige(b.ende)} ${b.aufgabe.titel}`,
)
const summe = bloecke.reduce(
(s, b) => s + (b.ende.getTime() - b.beginn.getTime()) / 60_000,
0,
)
return text(`${zeilen.join('\n')}\n\nZusammen ${alsDauer(summe)}.`)
},
)
server.registerTool(
'kalender_auflisten',
{
title: 'Kalender auflisten',
description:
'Die verbundenen Kalenderkonten. Ihre Kennung braucht das Einplanen.',
inputSchema: {},
},
async () => {
const konten = await db.kalenderkonto.findMany({
where: { benutzerId: aufrufer.benutzerId, aktiv: true },
orderBy: { erstelltAm: 'asc' },
select: { id: true, anzeigename: true, anbieter: true },
})
if (konten.length === 0) return text('Kein Kalender verbunden.')
return text(konten.map((k) => `- ${k.anzeigename} (${k.id})`).join('\n'))
},
)
}
// --- Erfassen ------------------------------------------------------------
if (darf(aufrufer.rechte, 'erfassen')) {
server.registerTool(
'aufgabe_anlegen',
{
title: 'Aufgabe anlegen',
description:
'Legt eine Aufgabe in der Inbox an. Bereich und Projekt lassen sich nicht setzen.',
inputSchema: {
titel: z.string().trim().min(1).max(200),
notiz: z.string().trim().max(4000).optional(),
faelligAm: z.iso.date().optional().describe('JJJJ-MM-TT'),
aufwandMinuten: z.number().int().min(1).max(1440).optional(),
prioritaet: z
.number()
.int()
.min(1)
.max(5)
.optional()
.describe('1 dringend … 5 irgendwann'),
},
},
async ({ titel, notiz, faelligAm, aufwandMinuten, prioritaet }) => {
const aufgabe = await db.aufgabe.create({
data: {
titel,
notiz: notiz ?? null,
quelle: 'MCP',
faelligAm: faelligAm ? new Date(faelligAm) : null,
...(aufwandMinuten ? { aufwandMinuten } : {}),
...(prioritaet ? { prioritaet } : {}),
},
select: { id: true, titel: true },
})
await protokoll(aufrufer, 'mcp.erfasst', aufgabe.id, { titel: aufgabe.titel })
return text(`Angelegt: „${aufgabe.titel}" (${aufgabe.id}). Liegt in der Inbox.`)
},
)
}
// --- Schreiben -----------------------------------------------------------
if (darf(aufrufer.rechte, 'schreiben')) {
server.registerTool(
'aufgabe_abhaken',
{
title: 'Aufgabe abhaken',
description: 'Setzt eine Aufgabe auf erledigt.',
inputSchema: { id: z.string().min(1) },
},
async ({ id }) => {
const aufgabe = await db.aufgabe.findUnique({
where: { id },
select: { id: true, titel: true, status: true },
})
if (!aufgabe) return fehler('Diese Aufgabe gibt es nicht.')
if (aufgabe.status === 'ERLEDIGT') return text('War schon erledigt.')
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
})
await protokoll(aufrufer, 'mcp.abgehakt', aufgabe.id, { titel: aufgabe.titel })
return text(`Abgehakt: „${aufgabe.titel}".`)
},
)
server.registerTool(
'zeitblock_anlegen',
{
title: 'Aufgabe einplanen',
description:
'Legt einen Zeitblock für eine Aufgabe an und schreibt ihn in den Kalender.',
inputSchema: {
aufgabeId: z.string().min(1),
kalenderkontoId: z.string().min(1).describe('Kennung aus kalender_auflisten'),
beginn: z.iso.datetime().describe('ISO 8601 mit Zeitzone'),
ende: z.iso.datetime(),
},
},
async ({ aufgabeId, kalenderkontoId, beginn, ende }) => {
// Derselbe Weg wie beim Ziehen in der Wochenansicht: Rasterprüfung,
// Kalenderschreibweg, Schleifenschutz, Protokoll.
const ergebnis = await zeitblockAnlegenFuer(
{ id: aufrufer.benutzerId, email: aufrufer.email },
{
aufgabeId,
kalenderkontoId,
beginn: new Date(beginn),
ende: new Date(ende),
},
)
if (!ergebnis.ok) return fehler(ergebnis.fehler)
return text(
ergebnis.geschrieben
? `Eingeplant: „${ergebnis.titel}".`
: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
)
},
)
}
return server
}
/**
* Hält fest, was über MCP passiert ist.
*
* Ein Sprachmodell, das Aufgaben anlegt, muss genauso nachvollziehbar sein
* wie ein Mensch — eher noch mehr.
*/
async function protokoll(
aufrufer: Aufrufer,
aktion: string,
objektId: string,
details: Record<string, unknown>,
): Promise<void> {
await auditSchreiben({
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
aktion,
objektTyp: 'Aufgabe',
objektId,
details: { ueber: 'mcp', token: aufrufer.tokenId, ...details },
})
}
+132
View File
@@ -0,0 +1,132 @@
import 'server-only'
import { zeitblockSchema, zeitraumHash } from '@/lib/zeitblock'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
import { terminAnlegen } from '@/server/kalender/schreiben'
import { jetzt } from '@/server/zeitgeber'
/**
* Das Einplanen selbst — ohne Sitzung, ohne Formular.
*
* Die Serveraktion aus der Oberfläche und der MCP-Server benutzen dieselbe
* Stelle. Zwei Umsetzungen desselben Vorgangs wären zwei Gelegenheiten, die
* Regeln unterschiedlich hinzuschreiben — und der Kalenderschreibweg samt
* Schleifenschutz ist nichts, was man zweimal richtig hinbekommt.
*
* Bewusst **nicht** in einer Datei mit `'use server'`: Dort wird jede
* exportierte Funktion zu einem Endpunkt, den der Browser aufrufen kann. Eine
* Funktion, die den Akteur als Parameter nimmt, wäre dann ein offenes Tor.
*/
export type Akteur = { id: string; email: string }
export type Einplanung = {
aufgabeId: string
kalenderkontoId: string
beginn: Date
ende: Date
}
export type Einplanungsergebnis =
| { ok: true; id: string; titel: string; geschrieben: boolean; fehler: string | null }
| { ok: false; fehler: string }
/** Legt einen Zeitblock an und schreibt ihn in den Kalender. */
export async function zeitblockAnlegenFuer(
akteur: Akteur,
eingabe: Einplanung,
): Promise<Einplanungsergebnis> {
const gelesen = zeitblockSchema.safeParse(eingabe)
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) {
// Die Planung überlebt einen Netzhänger; der Zeitblock bleibt mit
// vermerktem Fehler stehen.
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,
aktion: 'zeitblock.angelegt',
objektTyp: 'Zeitblock',
objektId: zeitblock.id,
details: {
aufgabe: aufgabe.titel,
kalender: konto.anzeigename,
geschrieben: schreibfehler === null,
},
})
return {
ok: true,
id: zeitblock.id,
titel: aufgabe.titel,
geschrieben: schreibfehler === null,
fehler: schreibfehler,
}
}
/** Der Betreff, unter dem ein Zeitblock im Kalender steht. */
export function betreffFuer(titel: string): string {
return titel
}