Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

335 lines
10 KiB
TypeScript

'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import {
AccessError,
addEntry,
changeStatus,
createCase,
reclassify,
setDueDate,
} from '@/lib/cases/service'
import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
import { addParticipant, removeParticipant } from '@/lib/cases/participants'
import { linkCases, unlinkCases } from '@/lib/cases/links'
import { toggleWatch } from '@/lib/cases/watch'
import { LINK_KINDS, PARTICIPANT_ROLES, parseParticipantDrafts } from '@/lib/cases/terms'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
function toMessage(error: unknown): string {
if (error instanceof AccessError) {
return de.actionErrors[error.code] ?? de.actionErrors.default!
}
console.error('[faelle] unerwartet', error)
return de.actionErrors.default!
}
const createSchema = z.object({
title: z.string().trim().min(3).max(200),
classification: z.coerce.number().int().min(0).max(5),
templateKey: z.string().min(1),
/** Betroffene Person, gleich beim Anlegen verknüpft. Optional. */
personnelFileId: z.string().max(64).optional(),
})
export type CreateState = { error?: string }
/**
* Wird über `useActionState` aufgerufen, damit Fehler an der Stelle stehen, an
* der sie entstanden sind, und der Knopf einen Wartezustand bekommt.
* Bei Erfolg leitet die Aktion auf die neue Akte weiter.
*/
export async function createCaseAction(
_previous: CreateState,
formData: FormData,
): Promise<CreateState> {
const user = await requireUser()
const parsed = createSchema.safeParse({
title: formData.get('title'),
classification: formData.get('classification'),
templateKey: formData.get('templateKey'),
})
if (!parsed.success) return { error: de.actionErrors.default! }
// Beliebig viele Beteiligte. Was nicht sauber ankommt, fällt weg: die Akte
// soll nicht an einer krummen Zeile scheitern. Siehe lib/cases/terms.ts.
const beteiligte = parseParticipantDrafts(formData.getAll('participants'))
let caseNumber: string
try {
const created = await createCase(user.permissions, parsed.data)
caseNumber = created.caseNumber
// Die Beteiligten gleich mitverknüpfen, damit sie nicht in einem zweiten
// Schritt vergessen werden. Scheitert einer, steht die Akte trotzdem - sie
// ist das Wichtigere, und nachtragen kann man auf der Aktenseite.
for (const eintrag of beteiligte) {
await addParticipant(user.permissions, {
caseNumber,
role: eintrag.role,
...(eintrag.kind === 'user'
? { userId: eintrag.id }
: { personnelFileId: eintrag.id }),
})
}
} catch (error) {
return { error: toMessage(error) }
}
revalidatePath('/faelle')
// redirect wirft, deshalb steht danach nichts mehr.
redirect(`/faelle/${caseNumber}`)
}
const entrySchema = z.object({
id: z.uuid(),
caseNumber: z.string().min(1),
body: z.string().trim().min(1).max(20_000),
minClearance: z.coerce.number().int().min(0).max(5).default(0),
})
export async function addEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = entrySchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await addEntry(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidateTag(`case:${parsed.data.caseNumber}`)
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
}
const statusSchema = z.object({
caseNumber: z.string().min(1),
to: z.enum(['DRAFT', 'OPEN', 'DORMANT', 'REVIEW', 'CLOSED', 'ARCHIVED', 'SEALED']),
reason: z.string().trim().optional(),
})
export async function changeStatusAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = statusSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await changeStatus(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
revalidatePath('/faelle')
return { ok: true }
}
const reclassifySchema = z.object({
caseNumber: z.string().min(1),
to: z.coerce.number().int().min(0).max(5),
reason: z.string().trim().optional(),
})
export async function reclassifyAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = reclassifySchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await reclassify(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
revalidatePath('/faelle')
return { ok: true }
}
const editSchema = z.object({
entryId: z.string().min(1),
caseNumber: z.string().min(1),
body: z.string().trim().min(1).max(20_000),
minClearance: z.coerce.number().int().min(0).max(5).optional(),
changeReason: z.string().trim().optional(),
})
export async function editEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = editSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await editEntry(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
}
const retractSchema = z.object({
entryId: z.string().min(1),
caseNumber: z.string().min(1),
reason: z.string().trim().min(10),
})
export async function retractEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = retractSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.entry.reasonTooShort }
try {
await retractEntry(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
}
export async function restoreEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = retractSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.entry.reasonTooShort }
try {
await restoreEntry(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
}
// ---------------------------------------------------------------------------
// Beteiligte und Verknüpfungen
// ---------------------------------------------------------------------------
const participantSchema = z.object({
caseNumber: z.string().min(1),
role: z.enum(PARTICIPANT_ROLES),
userId: z.string().max(64).optional(),
personnelFileId: z.string().max(64).optional(),
})
export async function addParticipantAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = participantSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors['participant-target']! }
try {
await addParticipant(user.permissions, parsed.data)
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const removeParticipantSchema = z.object({
caseNumber: z.string().min(1),
participantId: z.string().min(1),
})
export async function removeParticipantAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = removeParticipantSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await removeParticipant(user.permissions, parsed.data)
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const linkSchema = z.object({
fromCaseNumber: z.string().min(1),
toCaseNumber: z.string().min(1),
kind: z.enum(LINK_KINDS),
})
export async function linkCasesAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = linkSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await linkCases(user.permissions, parsed.data)
revalidatePath(`/faelle/${parsed.data.fromCaseNumber}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
export async function unlinkCasesAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = linkSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await unlinkCases(user.permissions, parsed.data)
revalidatePath(`/faelle/${parsed.data.fromCaseNumber}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const watchSchema = z.object({ caseNumber: z.string().min(1) })
export type WatchResult = { ok: true; watching: boolean } | { ok: false; error: string }
/** Beobachten verschafft keinen Zugriff - nur was sichtbar ist, lässt sich merken. */
export async function toggleWatchAction(input: unknown): Promise<WatchResult> {
const user = await requireUser()
const parsed = watchSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
const { watching } = await toggleWatch(user.permissions, parsed.data.caseNumber)
revalidatePath('/uebersicht')
return { ok: true, watching }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const dueSchema = z.object({
caseNumber: z.string().min(1),
/** Leer heisst: Wiedervorlage abräumen. */
dueAt: z.string().max(10).optional(),
})
export async function setDueDateAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = dueSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await setDueDate(user.permissions, {
caseNumber: parsed.data.caseNumber,
dueAt: parsed.data.dueAt ? new Date(parsed.data.dueAt) : null,
})
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
revalidatePath('/uebersicht')
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}