7 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 3370d94b59 Merge: Meilenstein 8 – MCP (0.8.0)
Ein Sprachmodell kann jetzt Aufgaben suchen, anlegen, abhaken und
einplanen — ueber dieselben Tokens und dieselben Rechte wie die
Schnittstelle aus M7. Welche Werkzeuge es sieht, haengt am Token: Was
nicht erlaubt ist, taucht gar nicht erst in der Liste auf.

Das Einplanen liegt jetzt an einer Stelle, die Oberflaeche und MCP-Server
gemeinsam benutzen, bewusst ausserhalb einer 'use server'-Datei. Ein ueber
MCP angelegter Zeitblock geht damit durch denselben Kalenderschreibweg und
Schleifenschutz wie ein von Hand gezogener.

Geprueft vor dem Merge: Format, Lint, Typen und 331 Unit-Tests auf dem
Endstand. Die sechs MCP-E2E-Tests liefen gruen auf bdc4260; danach kamen
Doku, Versionssprung und eine kleine Lint-Korrektur beim Lesen der
Formularfelder. E2E und ShellCheck liefen auf dem Endstand nicht, weil
Docker nicht lief — kein Shellskript wurde geaendert.

Damit ist der Fahrplan abgearbeitet.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:08:41 +02:00
14 changed files with 1694 additions and 144 deletions
+25
View File
@@ -6,6 +6,31 @@ 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 ## [0.7.0] – 2026-09-13
### Hinzugefügt ### Hinzugefügt
+5 -2
View File
@@ -43,6 +43,8 @@ wird.
Planer lässt sich als App auf dem Startbildschirm ablegen Planer lässt sich als App auf dem Startbildschirm ablegen
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und - **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md)) 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
@@ -51,9 +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
Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). Der [Fahrplan](ROADMAP.md) ist abgearbeitet. Was danach denkbar wäre, steht
dort unter „Ideen für später" — und was bewusst nicht kommt, gleich darunter.
## Bildergalerie ## Bildergalerie
+14 -4
View File
@@ -12,7 +12,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [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 | erledigt | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | erledigt |
| [M8](#m8--mcp) | MCP | geplant | | [M8](#m8--mcp) | MCP | erledigt |
--- ---
@@ -156,9 +156,19 @@ 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.
--- ---
+74 -1
View File
@@ -14,7 +14,80 @@ die Übersicht aus `scripts/zeit.sh summe`.
| M5 | claude | 420 | 7,00 | | M5 | claude | 420 | 7,00 |
| M6 | claude | 733 | 12,22 | | M6 | claude | 733 | 12,22 |
| M7 | claude | 886 | 14,77 | | M7 | claude | 886 | 14,77 |
| **Gesamt** | | **2452** | **40,87** | | 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
--- ---
+1
View File
@@ -6,3 +6,4 @@ datum;start;ende;minuten;meilenstein;art;notiz
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-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
2026-09-13;14:10;15:19;69;M8;claude;M8 MCP fertig
1 datum start ende minuten meilenstein art notiz
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
7 2026-09-11 20:47 09:00 733 M6 claude M6 Integrationen fertig
8 2026-09-12 10:15 01:01 886 M7 claude M7 Schnellerfassung und API fertig
9 2026-09-13 14:10 15:19 69 M8 claude M8 MCP fertig
+83
View File
@@ -0,0 +1,83 @@
# MCP-Server
Der Planer spricht das Model Context Protocol. Damit kann ein Sprachmodell
Aufgaben suchen, anlegen, abhaken und einplanen — mit denselben Tokens und
denselben Rechten wie die [Schnittstelle](API.md).
Adresse: `<APP_URL>/api/mcp`
## Einrichten
1. In der Anwendung unter **API** einen Token anlegen. Gib ihm nur, was das
Modell wirklich braucht.
2. Den Client auf die Adresse zeigen lassen, mit dem Token als Bearer.
Für Claude Code:
```bash
claude mcp add planer --transport http https://planer.example.de/api/mcp \
--header "Authorization: Bearer planer_…"
```
Für Clients, die nur stdio können, dazwischen `mcp-remote` setzen:
```json
{
"mcpServers": {
"planer": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://planer.example.de/api/mcp",
"--header",
"Authorization: Bearer planer_…"
]
}
}
}
```
## Werkzeuge
Welche ein Modell zu sehen bekommt, hängt am Token. Was nicht erlaubt ist,
taucht gar nicht erst in der Liste auf — ein Modell soll nicht gegen eine Tür
laufen, die für es verschlossen ist.
| Werkzeug | Recht | Was es tut |
| -------------------- | ----------- | ----------------------------------------------- |
| `aufgaben_suchen` | `lesen` | Aufgaben auflisten, optional gefiltert |
| `woche_ansehen` | `lesen` | Zeitblöcke einer Woche mit Summe |
| `kalender_auflisten` | `lesen` | Verbundene Kalender samt Kennung |
| `aufgabe_anlegen` | `erfassen` | Neue Aufgabe in der Inbox |
| `aufgabe_abhaken` | `schreiben` | Aufgabe auf erledigt setzen |
| `zeitblock_anlegen` | `schreiben` | Aufgabe einplanen und in den Kalender schreiben |
Zum Einplanen braucht es die Kennung eines Kalenderkontos — daher erst
`kalender_auflisten`, dann `zeitblock_anlegen`.
`zeitblock_anlegen` geht durch dieselbe Stelle wie das Ziehen in der
Wochenansicht: Rasterprüfung, Kalenderschreibweg, Schleifenschutz, Protokoll.
Ein über MCP angelegter Zeitblock verhält sich also genau wie ein von Hand
gezogener.
## Was der Server bewusst **nicht** kann
- **Löschen.** Weder Aufgaben noch Zeitblöcke. Abhaken ja, wegräumen nein.
- **Bereich oder Projekt setzen.** Was hier entsteht, landet in der Inbox.
- **Bereiche, Projekte oder Kalenderkonten anlegen und ändern.**
- **Einstellungen oder Tokens anfassen.**
Ein Modell soll den Alltag erleichtern, nicht die Ordnung umbauen.
## Protokoll
Jede schreibende Nutzung landet im Protokoll — mit dem Vermerk, dass sie über
MCP kam, und mit dem Token. Ein Modell, das Aufgaben anlegt, muss genauso
nachvollziehbar sein wie ein Mensch. Eher noch mehr.
## Ohne Sitzungen
Der Server ist zustandslos: Jede Anfrage steht für sich. Der Planer hat keine
langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den mehrere
Container nicht teilen könnten.
+1 -10
View File
@@ -31,18 +31,9 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
11. **[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 |
| ----- | ----------- |
| MCP | M8 |
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
--- ---
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md). 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).
+173
View File
@@ -0,0 +1,173 @@
import { createHash, randomBytes } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type APIRequestContext } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { EMAIL, VERBINDUNG } from './hilfe'
/**
* Der MCP-Endpunkt.
*
* Er spricht JSON-RPC, also wird hier auch JSON-RPC gesprochen — kein
* Nachbau der Bibliothek, sondern echte Aufrufe gegen den laufenden Server.
*
* Der wichtigste Punkt: Die Werkzeugliste hängt an den Rechten des Tokens.
* Was ein Token nicht darf, soll ein Modell gar nicht erst sehen.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-MCP-${Date.now()}`
async function tokenBauen(benutzerId: string, rechte: string): Promise<string> {
const praefix = randomBytes(4).toString('hex')
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
await db.apiToken.create({
data: {
benutzerId,
name: `${PRAEFIX} ${rechte}`,
praefix,
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
rechte,
},
})
return klartext
}
type Antwort = {
result?: { tools?: { name: string }[]; content?: { text: string }[]; isError?: boolean }
error?: { message: string }
}
/** Ein JSON-RPC-Aufruf gegen den Endpunkt. */
async function rufen(
request: APIRequestContext,
token: string | null,
methode: string,
parameter: Record<string, unknown> = {},
) {
const antwort = await request.post('/api/mcp', {
headers: {
'Content-Type': 'application/json',
// Der Streamable-HTTP-Transport besteht auf beidem.
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
data: { jsonrpc: '2.0', id: 1, method: methode, params: parameter },
})
return antwort
}
/** Der Handschlag, den jede MCP-Verbindung zuerst führt. */
const HANDSCHLAG = {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'e2e', version: '1.0.0' },
}
test.describe('MCP', () => {
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
let benutzerId = ''
test.beforeAll(async () => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
benutzerId = benutzer.id
})
test.afterAll(async () => {
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'mcp.' } } })
await db.$disconnect()
})
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
const antwort = await rufen(request, null, 'initialize', HANDSCHLAG)
expect(antwort.status()).toBe(401)
})
test('führt den Handschlag mit gültigem Token', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
const antwort = await rufen(request, token, 'initialize', HANDSCHLAG)
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as {
result?: { serverInfo?: { name: string } }
}
expect(inhalt.result?.serverInfo?.name).toBe('planer')
})
test('zeigt nur die Werkzeuge, die der Token erlaubt', async ({ request }) => {
const nurLesen = await tokenBauen(benutzerId, 'lesen')
await rufen(request, nurLesen, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, nurLesen, 'tools/list')
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
(w) => w.name,
)
expect(namen).toContain('aufgaben_suchen')
expect(namen).toContain('woche_ansehen')
// Erfassen und Schreiben fehlen dem Token – und damit der Liste.
expect(namen).not.toContain('aufgabe_anlegen')
expect(namen).not.toContain('aufgabe_abhaken')
expect(namen).not.toContain('zeitblock_anlegen')
})
test('zeigt einem Erfassen-Token nur das Anlegen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, token, 'tools/list')
const namen = (((await antwort.json()) as Antwort).result?.tools ?? []).map(
(w) => w.name,
)
expect(namen).toEqual(['aufgabe_anlegen'])
})
test('legt eine Aufgabe an und protokolliert das', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'erfassen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const titel = `${PRAEFIX} Angebot schreiben`
const antwort = await rufen(request, token, 'tools/call', {
name: 'aufgabe_anlegen',
arguments: { titel, aufwandMinuten: 45 },
})
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as Antwort
expect(inhalt.result?.content?.[0]?.text).toContain('Angelegt')
const aufgabe = await db.aufgabe.findFirstOrThrow({
where: { titel },
select: { quelle: true, bereichId: true, status: true, aufwandMinuten: true },
})
expect(aufgabe.quelle).toBe('MCP')
expect(aufgabe.aufwandMinuten).toBe(45)
// Wie über die Schnittstelle: offen und in der Inbox.
expect(aufgabe.status).toBe('OFFEN')
expect(aufgabe.bereichId).toBeNull()
const eintrag = await db.auditEintrag.findFirst({
where: { aktion: 'mcp.erfasst' },
select: { details: true },
})
expect(JSON.stringify(eintrag?.details)).toContain('mcp')
})
test('lässt ein Werkzeug ohne Recht gar nicht erst aufrufen', async ({ request }) => {
const token = await tokenBauen(benutzerId, 'lesen')
await rufen(request, token, 'initialize', HANDSCHLAG)
const antwort = await rufen(request, token, 'tools/call', {
name: 'aufgabe_anlegen',
arguments: { titel: `${PRAEFIX} darf nicht` },
})
const inhalt = (await antwort.json()) as Antwort
// Unbekanntes Werkzeug: Es ist für diesen Token nicht registriert.
expect(inhalt.error ?? inhalt.result?.isError).toBeTruthy()
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
})
})
+786 -31
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.7.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",
+66
View File
@@ -0,0 +1,66 @@
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
import { tokenAusKopfzeilen } from '@/lib/api'
import { tokenPruefen } from '@/server/api/token'
import { serverBauen } from '@/server/mcp/werkzeuge'
export const dynamic = 'force-dynamic'
/**
* Der MCP-Endpunkt.
*
* Ausgewiesen wird sich mit demselben Token wie bei `/api/v1` — ein
* Sprachmodell bekommt keinen eigenen, weicheren Zugang. Welche Werkzeuge es
* überhaupt zu sehen bekommt, hängt an den Rechten des Tokens: Was nicht
* erlaubt ist, taucht gar nicht erst in der Liste auf.
*
* Ohne Sitzungen: Jede Anfrage steht für sich. Der Planer hat keine
* langlaufenden Vorgänge, und eine Sitzungsverwaltung wäre Zustand, den
* mehrere Container nicht teilen könnten.
*/
async function bedienen(anfrage: Request): Promise<Response> {
const aufrufer = await tokenPruefen(
tokenAusKopfzeilen(
anfrage.headers.get('Authorization'),
anfrage.headers.get('X-Planer-Token'),
),
)
if (!aufrufer) {
return Response.json(
{
jsonrpc: '2.0',
error: { code: -32001, message: 'Kein gültiger Token.' },
id: null,
},
{ status: 401, headers: { 'WWW-Authenticate': 'Bearer' } },
)
}
const server = serverBauen(aufrufer)
const transport = new WebStandardStreamableHTTPServerTransport({
// Zustandslos: kein sessionIdGenerator.
enableJsonResponse: true,
})
await server.connect(transport)
try {
return await transport.handleRequest(anfrage)
} finally {
// Jede Anfrage bekommt einen frischen Server; sonst blieben Verbindungen
// offen, die niemand mehr schliesst.
await transport.close()
await server.close()
}
}
export async function POST(anfrage: Request) {
return bedienen(anfrage)
}
export async function GET(anfrage: Request) {
return bedienen(anfrage)
}
export async function DELETE(anfrage: Request) {
return bedienen(anfrage)
}
+26 -95
View File
@@ -2,12 +2,7 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { z } from 'zod' import { z } from 'zod'
import { import { zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen } from '@/lib/zeitblock'
zeitblockSchema,
zeitraumHash,
LAGE_FEHLER_TEXT,
lagePruefen,
} from '@/lib/zeitblock'
import { auditSchreiben } from '@/server/audit' import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
@@ -18,6 +13,7 @@ import {
terminAnlegen, terminAnlegen,
terminEntfernen, terminEntfernen,
} from '@/server/kalender/schreiben' } from '@/server/kalender/schreiben'
import { betreffFuer, zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
export type Rueckmeldung = export type Rueckmeldung =
@@ -56,11 +52,6 @@ async function eigenerZeitblock(id: string, benutzerId: string) {
}) })
} }
/** Was im Kalender als Betreff steht. */
function betreffFuer(titel: string): string {
return titel
}
// --- Anlegen --------------------------------------------------------------- // --- Anlegen ---------------------------------------------------------------
/** /**
@@ -76,99 +67,39 @@ export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
if ('antwort' in geprueft) return geprueft.antwort if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft const { akteur } = geprueft
const gelesen = zeitblockSchema.safeParse({ const ergebnis = await zeitblockAnlegenFuer(
aufgabeId: daten.get('aufgabeId'), { id: akteur.id, email: akteur.email },
kalenderkontoId: daten.get('kalenderkontoId'), {
beginn: daten.get('beginn'), aufgabeId: feld(daten, 'aufgabeId'),
ende: daten.get('ende'), kalenderkontoId: feld(daten, 'kalenderkontoId'),
}) beginn: new Date(feld(daten, 'beginn')),
if (!gelesen.success) { ende: new Date(feld(daten, 'ende')),
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
}
const [aufgabe, konto] = await Promise.all([
db.aufgabe.findUnique({
where: { id: gelesen.data.aufgabeId },
select: { id: true, titel: true, notiz: true, status: true },
}),
db.kalenderkonto.findFirst({
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
}),
])
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
const zeitblock = await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: konto.id,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
}, },
select: { id: true }, )
})
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen. if (!ergebnis.ok) return ergebnis
if (aufgabe.status === 'OFFEN') {
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
}
let schreibfehler: string | null = null
try {
const token = await gueltigesTokenFuer(konto.id, jetzt())
const ergebnis = await terminAnlegen({
anbieter: konto.anbieter,
kalenderId: konto.kalenderId,
token,
betreff: betreffFuer(aufgabe.titel),
beschreibung: aufgabe.notiz,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
})
await db.zeitblock.update({
where: { id: zeitblock.id },
data: {
extId: ergebnis.extId,
etag: ergebnis.etag,
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
letzterFehler: null,
},
})
} catch (fehler) {
schreibfehler =
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
await db.zeitblock.update({
where: { id: zeitblock.id },
data: { letzterFehler: schreibfehler },
})
}
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'zeitblock.angelegt',
objektTyp: 'Zeitblock',
objektId: zeitblock.id,
details: {
aufgabe: aufgabe.titel,
kalender: konto.anzeigename,
geschrieben: schreibfehler === null,
},
})
revalidatePath('/woche') revalidatePath('/woche')
revalidatePath('/aufgaben') revalidatePath('/aufgaben')
revalidatePath('/') revalidatePath('/')
return schreibfehler return ergebnis.geschrieben
? { ? { ok: true, id: ergebnis.id, meldung: `„${ergebnis.titel}" eingeplant.` }
: {
ok: false, ok: false,
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`, fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
} }
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` } }
/**
* Ein Textfeld aus dem Formular.
*
* FormData kann auch Dateien liefern; die haetten hier nichts zu suchen und
* wuerden beim Stringify zu "[object Object]".
*/
function feld(daten: FormData, name: string): string {
const wert = daten.get(name)
return typeof wert === 'string' ? wert : ''
} }
// --- Verschieben ----------------------------------------------------------- // --- Verschieben -----------------------------------------------------------
+306
View File
@@ -0,0 +1,306 @@
import 'server-only'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { darf } from '@/lib/api'
import { alsDatum, alsDauer, alsUhrzeitAnzeige, wochenBeginn } from '@/lib/zeit'
import { auditSchreiben } from '@/server/audit'
import type { Aufrufer } from '@/server/api/token'
import { db } from '@/server/db'
import { zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
import { jetzt } from '@/server/zeitgeber'
/**
* Der MCP-Server des Planers.
*
* Er hängt an denselben Tokens und denselben Rechten wie die Schnittstelle
* aus M7 — ein Sprachmodell bekommt keinen eigenen, weicheren Zugang. Was
* ein Token nicht darf, kann auch ein Werkzeug nicht.
*
* Die Werkzeuge sind bewusst wenige und grob geschnitten. Ein Modell, das
* zwischen zwanzig fast gleichen Werkzeugen wählen muss, wählt falsch; und
* jedes zusätzliche Werkzeug ist eine weitere Möglichkeit, versehentlich
* etwas zu ändern.
*/
/** Antwort in der Form, die MCP erwartet. */
function text(inhalt: string) {
return { content: [{ type: 'text' as const, text: inhalt }] }
}
function fehler(inhalt: string) {
return { content: [{ type: 'text' as const, text: inhalt }], isError: true }
}
const STATUS_OFFEN = ['OFFEN', 'GEPLANT', 'WARTET'] as const
/** Wie viele Datensätze ein Werkzeug höchstens zurückgibt. */
const GRENZE = 50
export function serverBauen(aufrufer: Aufrufer): McpServer {
const server = new McpServer(
{ name: 'planer', version: '1.0.0' },
{
instructions: [
'Der Planer verwaltet Aufgaben, Ideen, Projekte und Zeitblöcke einer',
'einzelnen Person. Aufgaben, die über diese Werkzeuge entstehen,',
'landen in der Inbox und werden von Hand einsortiert — setze also',
'keinen Bereich und kein Projekt, das geht hier ohnehin nicht.',
'Zeiten sind Europe/Berlin.',
].join(' '),
},
)
// --- Lesen ---------------------------------------------------------------
if (darf(aufrufer.rechte, 'lesen')) {
server.registerTool(
'aufgaben_suchen',
{
title: 'Aufgaben suchen',
description:
'Listet Aufgaben. Ohne Angaben die offenen, nach Fälligkeit sortiert.',
inputSchema: {
suche: z.string().max(100).optional().describe('Text im Titel'),
nurOffene: z.boolean().default(true),
nurUeberfaellige: z.boolean().default(false),
},
},
async ({ suche, nurOffene, nurUeberfaellige }) => {
const zeitpunkt = jetzt()
const aufgaben = await db.aufgabe.findMany({
where: {
typ: { not: 'IDEE' },
...(nurOffene ? { status: { in: [...STATUS_OFFEN] } } : {}),
...(nurUeberfaellige ? { faelligAm: { lt: zeitpunkt } } : {}),
...(suche ? { titel: { contains: suche, mode: 'insensitive' } } : {}),
},
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { prioritaet: 'asc' }],
take: GRENZE,
select: {
id: true,
titel: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
bereich: { select: { name: true } },
},
})
if (aufgaben.length === 0) return text('Keine Aufgabe passt dazu.')
const zeilen = aufgaben.map((a) => {
const teile = [
a.titel,
`(${a.id})`,
a.status,
`Prio ${a.prioritaet}`,
a.aufwandMinuten ? alsDauer(a.aufwandMinuten) : 'ohne Schätzung',
a.faelligAm ? `fällig ${alsDatum(a.faelligAm)}` : 'ohne Termin',
a.bereich?.name ?? 'Inbox',
]
return `- ${teile.join(' · ')}`
})
return text(zeilen.join('\n'))
},
)
server.registerTool(
'woche_ansehen',
{
title: 'Woche ansehen',
description:
'Zeigt die eingeplanten Zeitblöcke einer Woche samt freier Kapazität.',
inputSchema: {
versatz: z
.number()
.int()
.min(-52)
.max(52)
.default(0)
.describe('0 = diese Woche, 1 = nächste'),
},
},
async ({ versatz }) => {
const zeitpunkt = new Date(jetzt().getTime() + versatz * 7 * 86_400_000)
const von = wochenBeginn(zeitpunkt)
const bis = new Date(von.getTime() + 7 * 86_400_000)
const bloecke = await db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId: aufrufer.benutzerId },
beginn: { gte: von, lt: bis },
},
orderBy: { beginn: 'asc' },
select: {
beginn: true,
ende: true,
aufgabe: { select: { titel: true } },
},
})
if (bloecke.length === 0) {
return text(`Ab ${alsDatum(von)} ist noch nichts eingeplant.`)
}
const zeilen = bloecke.map(
(b) =>
`- ${alsDatum(b.beginn)} ${alsUhrzeitAnzeige(b.beginn)}–${alsUhrzeitAnzeige(b.ende)} ${b.aufgabe.titel}`,
)
const summe = bloecke.reduce(
(s, b) => s + (b.ende.getTime() - b.beginn.getTime()) / 60_000,
0,
)
return text(`${zeilen.join('\n')}\n\nZusammen ${alsDauer(summe)}.`)
},
)
server.registerTool(
'kalender_auflisten',
{
title: 'Kalender auflisten',
description:
'Die verbundenen Kalenderkonten. Ihre Kennung braucht das Einplanen.',
inputSchema: {},
},
async () => {
const konten = await db.kalenderkonto.findMany({
where: { benutzerId: aufrufer.benutzerId, aktiv: true },
orderBy: { erstelltAm: 'asc' },
select: { id: true, anzeigename: true, anbieter: true },
})
if (konten.length === 0) return text('Kein Kalender verbunden.')
return text(konten.map((k) => `- ${k.anzeigename} (${k.id})`).join('\n'))
},
)
}
// --- Erfassen ------------------------------------------------------------
if (darf(aufrufer.rechte, 'erfassen')) {
server.registerTool(
'aufgabe_anlegen',
{
title: 'Aufgabe anlegen',
description:
'Legt eine Aufgabe in der Inbox an. Bereich und Projekt lassen sich nicht setzen.',
inputSchema: {
titel: z.string().trim().min(1).max(200),
notiz: z.string().trim().max(4000).optional(),
faelligAm: z.iso.date().optional().describe('JJJJ-MM-TT'),
aufwandMinuten: z.number().int().min(1).max(1440).optional(),
prioritaet: z
.number()
.int()
.min(1)
.max(5)
.optional()
.describe('1 dringend … 5 irgendwann'),
},
},
async ({ titel, notiz, faelligAm, aufwandMinuten, prioritaet }) => {
const aufgabe = await db.aufgabe.create({
data: {
titel,
notiz: notiz ?? null,
quelle: 'MCP',
faelligAm: faelligAm ? new Date(faelligAm) : null,
...(aufwandMinuten ? { aufwandMinuten } : {}),
...(prioritaet ? { prioritaet } : {}),
},
select: { id: true, titel: true },
})
await protokoll(aufrufer, 'mcp.erfasst', aufgabe.id, { titel: aufgabe.titel })
return text(`Angelegt: „${aufgabe.titel}" (${aufgabe.id}). Liegt in der Inbox.`)
},
)
}
// --- Schreiben -----------------------------------------------------------
if (darf(aufrufer.rechte, 'schreiben')) {
server.registerTool(
'aufgabe_abhaken',
{
title: 'Aufgabe abhaken',
description: 'Setzt eine Aufgabe auf erledigt.',
inputSchema: { id: z.string().min(1) },
},
async ({ id }) => {
const aufgabe = await db.aufgabe.findUnique({
where: { id },
select: { id: true, titel: true, status: true },
})
if (!aufgabe) return fehler('Diese Aufgabe gibt es nicht.')
if (aufgabe.status === 'ERLEDIGT') return text('War schon erledigt.')
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
})
await protokoll(aufrufer, 'mcp.abgehakt', aufgabe.id, { titel: aufgabe.titel })
return text(`Abgehakt: „${aufgabe.titel}".`)
},
)
server.registerTool(
'zeitblock_anlegen',
{
title: 'Aufgabe einplanen',
description:
'Legt einen Zeitblock für eine Aufgabe an und schreibt ihn in den Kalender.',
inputSchema: {
aufgabeId: z.string().min(1),
kalenderkontoId: z.string().min(1).describe('Kennung aus kalender_auflisten'),
beginn: z.iso.datetime().describe('ISO 8601 mit Zeitzone'),
ende: z.iso.datetime(),
},
},
async ({ aufgabeId, kalenderkontoId, beginn, ende }) => {
// Derselbe Weg wie beim Ziehen in der Wochenansicht: Rasterprüfung,
// Kalenderschreibweg, Schleifenschutz, Protokoll.
const ergebnis = await zeitblockAnlegenFuer(
{ id: aufrufer.benutzerId, email: aufrufer.email },
{
aufgabeId,
kalenderkontoId,
beginn: new Date(beginn),
ende: new Date(ende),
},
)
if (!ergebnis.ok) return fehler(ergebnis.fehler)
return text(
ergebnis.geschrieben
? `Eingeplant: „${ergebnis.titel}".`
: `Eingeplant, aber nicht in den Kalender geschrieben: ${ergebnis.fehler ?? 'unbekannter Fehler'}`,
)
},
)
}
return server
}
/**
* Hält fest, was über MCP passiert ist.
*
* Ein Sprachmodell, das Aufgaben anlegt, muss genauso nachvollziehbar sein
* wie ein Mensch — eher noch mehr.
*/
async function protokoll(
aufrufer: Aufrufer,
aktion: string,
objektId: string,
details: Record<string, unknown>,
): Promise<void> {
await auditSchreiben({
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
aktion,
objektTyp: 'Aufgabe',
objektId,
details: { ueber: 'mcp', token: aufrufer.tokenId, ...details },
})
}
+132
View File
@@ -0,0 +1,132 @@
import 'server-only'
import { zeitblockSchema, zeitraumHash } from '@/lib/zeitblock'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
import { terminAnlegen } from '@/server/kalender/schreiben'
import { jetzt } from '@/server/zeitgeber'
/**
* Das Einplanen selbst — ohne Sitzung, ohne Formular.
*
* Die Serveraktion aus der Oberfläche und der MCP-Server benutzen dieselbe
* Stelle. Zwei Umsetzungen desselben Vorgangs wären zwei Gelegenheiten, die
* Regeln unterschiedlich hinzuschreiben — und der Kalenderschreibweg samt
* Schleifenschutz ist nichts, was man zweimal richtig hinbekommt.
*
* Bewusst **nicht** in einer Datei mit `'use server'`: Dort wird jede
* exportierte Funktion zu einem Endpunkt, den der Browser aufrufen kann. Eine
* Funktion, die den Akteur als Parameter nimmt, wäre dann ein offenes Tor.
*/
export type Akteur = { id: string; email: string }
export type Einplanung = {
aufgabeId: string
kalenderkontoId: string
beginn: Date
ende: Date
}
export type Einplanungsergebnis =
| { ok: true; id: string; titel: string; geschrieben: boolean; fehler: string | null }
| { ok: false; fehler: string }
/** Legt einen Zeitblock an und schreibt ihn in den Kalender. */
export async function zeitblockAnlegenFuer(
akteur: Akteur,
eingabe: Einplanung,
): Promise<Einplanungsergebnis> {
const gelesen = zeitblockSchema.safeParse(eingabe)
if (!gelesen.success) {
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
}
const [aufgabe, konto] = await Promise.all([
db.aufgabe.findUnique({
where: { id: gelesen.data.aufgabeId },
select: { id: true, titel: true, notiz: true, status: true },
}),
db.kalenderkonto.findFirst({
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
}),
])
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
const zeitblock = await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: konto.id,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
},
select: { id: true },
})
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
if (aufgabe.status === 'OFFEN') {
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
}
let schreibfehler: string | null = null
try {
const token = await gueltigesTokenFuer(konto.id, jetzt())
const ergebnis = await terminAnlegen({
anbieter: konto.anbieter,
kalenderId: konto.kalenderId,
token,
betreff: betreffFuer(aufgabe.titel),
beschreibung: aufgabe.notiz,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
})
await db.zeitblock.update({
where: { id: zeitblock.id },
data: {
extId: ergebnis.extId,
etag: ergebnis.etag,
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
letzterFehler: null,
},
})
} catch (fehler) {
// Die Planung überlebt einen Netzhänger; der Zeitblock bleibt mit
// vermerktem Fehler stehen.
schreibfehler =
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
await db.zeitblock.update({
where: { id: zeitblock.id },
data: { letzterFehler: schreibfehler },
})
}
await auditSchreiben({
akteur,
aktion: 'zeitblock.angelegt',
objektTyp: 'Zeitblock',
objektId: zeitblock.id,
details: {
aufgabe: aufgabe.titel,
kalender: konto.anzeigename,
geschrieben: schreibfehler === null,
},
})
return {
ok: true,
id: zeitblock.id,
titel: aufgabe.titel,
geschrieben: schreibfehler === null,
fehler: schreibfehler,
}
}
/** Der Betreff, unter dem ein Zeitblock im Kalender steht. */
export function betreffFuer(titel: string): string {
return titel
}