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
+15 -95
View File
@@ -2,12 +2,7 @@
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import {
zeitblockSchema,
zeitraumHash,
LAGE_FEHLER_TEXT,
lagePruefen,
} from '@/lib/zeitblock'
import { zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen } from '@/lib/zeitblock'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
@@ -18,6 +13,7 @@ import {
terminAnlegen,
terminEntfernen,
} from '@/server/kalender/schreiben'
import { betreffFuer, zeitblockAnlegenFuer } from '@/server/zeitblock/dienst'
import { jetzt } from '@/server/zeitgeber'
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 ---------------------------------------------------------------
/**
@@ -76,99 +67,28 @@ export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
if ('antwort' in geprueft) return geprueft.antwort
const { akteur } = geprueft
const gelesen = zeitblockSchema.safeParse({
aufgabeId: daten.get('aufgabeId'),
kalenderkontoId: daten.get('kalenderkontoId'),
beginn: daten.get('beginn'),
ende: daten.get('ende'),
})
if (!gelesen.success) {
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
}
const [aufgabe, konto] = await Promise.all([
db.aufgabe.findUnique({
where: { id: gelesen.data.aufgabeId },
select: { id: true, titel: true, notiz: true, status: true },
}),
db.kalenderkonto.findFirst({
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
}),
])
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
const zeitblock = await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: konto.id,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
const ergebnis = await zeitblockAnlegenFuer(
{ id: akteur.id, email: akteur.email },
{
aufgabeId: String(daten.get('aufgabeId') ?? ''),
kalenderkontoId: String(daten.get('kalenderkontoId') ?? ''),
beginn: new Date(String(daten.get('beginn') ?? '')),
ende: new Date(String(daten.get('ende') ?? '')),
},
select: { id: true },
})
)
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
if (aufgabe.status === 'OFFEN') {
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
}
let schreibfehler: string | null = null
try {
const token = await gueltigesTokenFuer(konto.id, jetzt())
const ergebnis = await terminAnlegen({
anbieter: konto.anbieter,
kalenderId: konto.kalenderId,
token,
betreff: betreffFuer(aufgabe.titel),
beschreibung: aufgabe.notiz,
beginn: gelesen.data.beginn,
ende: gelesen.data.ende,
})
await db.zeitblock.update({
where: { id: zeitblock.id },
data: {
extId: ergebnis.extId,
etag: ergebnis.etag,
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
letzterFehler: null,
},
})
} catch (fehler) {
schreibfehler =
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
await db.zeitblock.update({
where: { id: zeitblock.id },
data: { letzterFehler: schreibfehler },
})
}
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'zeitblock.angelegt',
objektTyp: 'Zeitblock',
objektId: zeitblock.id,
details: {
aufgabe: aufgabe.titel,
kalender: konto.anzeigename,
geschrieben: schreibfehler === null,
},
})
if (!ergebnis.ok) return ergebnis
revalidatePath('/woche')
revalidatePath('/aufgaben')
revalidatePath('/')
return schreibfehler
? {
return ergebnis.geschrieben
? { ok: true, id: ergebnis.id, meldung: `„${ergebnis.titel}" eingeplant.` }
: {
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 -----------------------------------------------------------
+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
}