Compare commits
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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.
|
||||||
@@ -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.
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 350 KiB |
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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).
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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": "/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}“ 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 "Authorization: Bearer …" {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')),
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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.' }
|
||||||
|
}
|
||||||
@@ -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 -----------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||