feat(aufgaben): Server Actions und Abfrageschicht

Anlegen, ändern, Status setzen, einsortieren, aus einer Idee eine Aufgabe
machen und löschen — jede Aktion prüft die Rolle selbst und hinterlässt
einen Audit-Eintrag.

Statuswechsel gehen nur die Wege, die uebergangErlaubt() zulässt:
Abgeschlossenes wird erst wieder geöffnet, bevor es weitergeht. Wer per
Formular etwas anderes einreicht, bekommt eine Meldung statt eines stillen
Zustandssprungs.

Sortiert wird in der Anwendung, nicht in SQL. Die Regel ist mehrstufig
und steht als geprüfte reine Funktion in src/lib — zwei Fassungen
derselben Regel wären eine Fehlerquelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 16:17:43 +02:00
co-authored by Claude Opus 5
parent 63c3c15a3c
commit b3ca1c9ce7
3 changed files with 739 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { z } from 'zod'
import { projektSchema } from '@/lib/aufgabe'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
export type Rueckmeldung =
| { ok: true; meldung: string; id?: string }
| { ok: false; fehler: string; felder?: Record<string, string> }
function feldFehler(fehler: z.ZodError): Record<string, string> {
const felder: Record<string, string> = {}
for (const problem of fehler.issues) {
const feld = problem.path.join('.')
felder[feld] ??= problem.message
}
return felder
}
async function akteur() {
return pruefeRolle('BENUTZER')
}
export async function projektAnlegen(daten: FormData): Promise<Rueckmeldung> {
let ich
try {
ich = await akteur()
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries()))
if (!gelesen.success) {
return {
ok: false,
fehler: 'Bitte die markierten Felder prüfen.',
felder: feldFehler(gelesen.error),
}
}
const vorhanden = await db.projekt.findUnique({
where: { name: gelesen.data.name },
select: { id: true },
})
if (vorhanden) {
return {
ok: false,
fehler: 'Diesen Namen gibt es schon.',
felder: { name: 'Diesen Namen gibt es schon.' },
}
}
const projekt = await db.projekt.create({
data: gelesen.data,
select: { id: true, name: true },
})
await auditSchreiben({
akteur: { id: ich.id, email: ich.email },
aktion: 'projekt.angelegt',
objektTyp: 'Projekt',
objektId: projekt.id,
details: { name: projekt.name },
})
revalidatePath('/projekte')
return { ok: true, id: projekt.id, meldung: `Projekt „${projekt.name}" angelegt.` }
}
export async function projektAendern(id: string, daten: FormData): Promise<Rueckmeldung> {
let ich
try {
ich = await akteur()
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries()))
if (!gelesen.success) {
return {
ok: false,
fehler: 'Bitte die markierten Felder prüfen.',
felder: feldFehler(gelesen.error),
}
}
const vorher = await db.projekt.findUnique({
where: { id },
select: { id: true, name: true, status: true },
})
if (!vorher) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' }
const dublette = await db.projekt.findFirst({
where: { name: gelesen.data.name, id: { not: id } },
select: { id: true },
})
if (dublette) {
return {
ok: false,
fehler: 'Diesen Namen gibt es schon.',
felder: { name: 'Diesen Namen gibt es schon.' },
}
}
await db.projekt.update({ where: { id }, data: gelesen.data })
await auditSchreiben({
akteur: { id: ich.id, email: ich.email },
aktion: 'projekt.geaendert',
objektTyp: 'Projekt',
objektId: id,
details: {
vorher: vorher.name,
nachher: gelesen.data.name,
statusVorher: vorher.status,
statusNachher: gelesen.data.status,
},
})
revalidatePath('/projekte')
revalidatePath(`/projekte/${id}`)
return { ok: true, id, meldung: `Projekt „${gelesen.data.name}" gespeichert.` }
}
/**
* Löscht ein Projekt. Die enthaltenen Aufgaben bleiben bestehen und verlieren
* nur ihre Zuordnung — sonst verschwände mit dem Projekt auch die Arbeit.
*/
export async function projektLoeschen(daten: FormData): Promise<Rueckmeldung> {
let ich
try {
ich = await akteur()
} catch (fehler) {
if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message }
throw fehler
}
const id = daten.get('id')
if (typeof id !== 'string' || id.length === 0) {
return { ok: false, fehler: 'Ungültige Anfrage.' }
}
const projekt = await db.projekt.findUnique({
where: { id },
select: { id: true, name: true, _count: { select: { aufgaben: true } } },
})
if (!projekt) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' }
await db.projekt.delete({ where: { id: projekt.id } })
await auditSchreiben({
akteur: { id: ich.id, email: ich.email },
aktion: 'projekt.geloescht',
objektTyp: 'Projekt',
objektId: projekt.id,
details: { name: projekt.name, aufgabenGeloest: projekt._count.aufgaben },
})
revalidatePath('/projekte')
revalidatePath('/aufgaben')
return {
ok: true,
meldung:
projekt._count.aufgaben > 0
? `Projekt „${projekt.name}" gelöscht. ${projekt._count.aufgaben} Aufgabe(n) bleiben erhalten.`
: `Projekt „${projekt.name}" gelöscht.`,
}
}