Files
AfiS-Aktensystem/web/app/(app)/personalakten/actions.ts
T
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

250 lines
7.6 KiB
TypeScript

'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import {
addAssignment,
createPersonnelFile,
reclassifyPersonnelFile,
removeAssignment,
updatePersonnelFile,
} from '@/lib/personnel/service'
import { createDocument, saveDocumentSection } from '@/lib/documents/service'
import { addParticipant } from '@/lib/cases/participants'
import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { db } from '@/lib/db'
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('[personalakten] unerwartet', error)
return de.actionErrors.default!
}
const kopfdaten = {
displayName: z.string().min(2).max(120),
rank: z.string().max(60).optional(),
departmentId: z.string().max(64).optional(),
note: z.string().max(2000).optional(),
}
/**
* Mehrfachauswahl aus einem Formular: der Browser schickt denselben Namen
* mehrfach, `Object.fromEntries` behält davon nur den letzten. Deshalb werden
* die Etiketten getrennt eingesammelt.
*/
function statusAusFormular(formData: FormData): string[] {
return formData
.getAll('statusTags')
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.slice(0, 20)
}
const createSchema = z.object({
...kopfdaten,
userId: z.string().max(64).optional(),
classification: z.coerce.number().int().min(0).max(5),
})
export async function createPersonnelAction(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const user = await requireUser()
const parsed = createSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: de.actionErrors['name-required']! }
let id: string
try {
const created = await createPersonnelFile(user.permissions, {
...parsed.data,
statusTags: statusAusFormular(formData),
})
id = created.id
// Das Aktenblatt ist der erste Eintrag zu einer Person und entsteht
// deshalb gleich mit - als Entwurf, mit dem Namen schon eingetragen.
// Fehlt das Recht dafür, bleibt es bei der Akte allein.
await erstesAktenblatt(user.permissions, id, parsed.data.displayName)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
// Nach dem Anlegen direkt in die Akte, nicht zurück in die Liste.
redirect(`/personalakten/${id}`)
}
const updateSchema = z.object({ id: z.string().min(1), ...kopfdaten })
export async function updatePersonnelAction(
_prev: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
const user = await requireUser()
const parsed = updateSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, error: de.actionErrors['name-required']! }
try {
const { id, ...rest } = parsed.data
await updatePersonnelFile(user.permissions, id, {
...rest,
statusTags: statusAusFormular(formData),
})
revalidatePath(`/personalakten/${id}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const reclassifySchema = z.object({
id: z.string().min(1),
to: z.coerce.number().int().min(0).max(5),
reason: z.string().max(2000).optional(),
})
export async function reclassifyPersonnelAction(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 reclassifyPersonnelFile(
user.permissions,
parsed.data.id,
parsed.data.to,
parsed.data.reason,
)
revalidatePath(`/personalakten/${parsed.data.id}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
/**
* Legt das Aktenblatt zu einer frisch angelegten Person an und trägt den Namen
* ein, soweit er sich zerlegen lässt.
*
* Schlägt es fehl, weil das Recht `document.create` fehlt, bleibt die
* Personalakte trotzdem stehen: das Blatt lässt sich später nachholen, die
* Akte wäre sonst verloren.
*/
async function erstesAktenblatt(
permissions: Awaited<ReturnType<typeof requireUser>>['permissions'],
personnelFileId: string,
displayName: string,
): Promise<void> {
const vorlage = await db.documentTemplate.findFirst({
where: { key: 'personalakte', isActive: true, uses: { has: 'PERSON_DOCUMENT' } },
select: { key: true },
})
if (!vorlage) return
const teile = displayName.trim().split(/\s+/)
const nachname = teile.length > 1 ? teile[teile.length - 1]! : displayName.trim()
const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
try {
const doc = await createDocument(permissions, {
templateKey: vorlage.key,
title: displayName,
classification: 1,
owner: { kind: 'personnel', personnelFileId },
})
for (const [fieldKey, value] of [
['name', nachname],
['vorname', vorname],
] as const) {
if (!value) continue
await saveDocumentSection(permissions, {
documentNumber: doc.documentNumber,
fieldKey,
value,
baseVersion: 0,
})
}
} catch (error) {
if (error instanceof AccessError) return
throw error
}
}
const assignmentSchema = z.object({
personnelFileId: z.string().min(1),
userId: z.string().min(1),
role: z.enum(ASSIGNMENT_ROLES),
})
export async function addAssignmentAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = assignmentSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await addAssignment(user.permissions, parsed.data)
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const removeAssignmentSchema = z.object({
personnelFileId: z.string().min(1),
assignmentId: z.string().min(1),
})
export async function removeAssignmentAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = removeAssignmentSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await removeAssignment(user.permissions, parsed.data)
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const linkCaseSchema = z.object({
personnelFileId: z.string().min(1),
caseNumber: z.string().min(1).max(60),
role: z.enum(PARTICIPANT_ROLES),
})
/**
* Einen Fall von der Personalakte aus verknüpfen.
*
* Es ist derselbe Vorgang wie „Beteiligte eintragen" auf der Fallakte, nur von
* der anderen Seite her gedacht: dort sucht man die Person, hier den Fall.
*/
export async function linkCaseToPersonAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = linkCaseSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await addParticipant(user.permissions, {
caseNumber: parsed.data.caseNumber.trim(),
role: parsed.data.role,
personnelFileId: parsed.data.personnelFileId,
})
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}