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>
This commit is contained in:
Florian Wilde
2026-09-13 14:31:17 +02:00
co-authored by Claude Opus 5
parent e2642e51a7
commit 5f45dff1a7
4 changed files with 932 additions and 124 deletions
+784 -29
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+15 -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,28 @@ 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: String(daten.get('aufgabeId') ?? ''),
ende: daten.get('ende'), kalenderkontoId: String(daten.get('kalenderkontoId') ?? ''),
}) beginn: new Date(String(daten.get('beginn') ?? '')),
if (!gelesen.success) { ende: new Date(String(daten.get('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.` }
} }
// --- Verschieben ----------------------------------------------------------- // --- Verschieben -----------------------------------------------------------
+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
}