import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { canWritePersonnel } from '@/lib/access/documentWrite' import { AccessError, must } from '@/lib/errors' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { Prisma } from '@afis/db' /** * Personalakten. Eine Akte zu einer Person, auch zu einer **ohne * Discord-Konto**: Zeugen, ehemalige Mitarbeiter und Personen, über die * lediglich Aktenlage besteht, brauchen einen Platz. * * Die Rückverweise sind der heikelste Teil. Siehe `loadBacklinks`. */ const KOPFDATEN = { id: true, userId: true, displayName: true, serviceNumber: true, rank: true, department: true, status: true, note: true, classification: true, createdById: true, createdAt: true, updatedAt: true, } satisfies Prisma.PersonnelFileSelect export async function loadVisiblePersonnelFile(p: EffectivePermissions, id: string) { return db.personnelFile.findFirst({ where: { AND: [visiblePersonnelWhere(p), { id }] }, select: KOPFDATEN, }) } /** * Liste der Personalakten. Suchbegriff und Zählung laufen durch dieselbe * Abfrage wie die Sichtbarkeit, damit sich aus einer Trefferzahl nichts über * verborgene Akten ableiten lässt. */ export async function listPersonnelFiles( p: EffectivePermissions, options: { query?: string; take?: number } = {}, ) { const begriff = options.query?.trim() const where: Prisma.PersonnelFileWhereInput = { AND: [ visiblePersonnelWhere(p), ...(begriff ? [ { OR: [ { displayName: { contains: begriff, mode: 'insensitive' as const } }, { serviceNumber: { contains: begriff, mode: 'insensitive' as const } }, { rank: { contains: begriff, mode: 'insensitive' as const } }, ], }, ] : []), ], } const [files, total] = await Promise.all([ db.personnelFile.findMany({ where, orderBy: [{ displayName: 'asc' }], take: options.take ?? 50, select: KOPFDATEN, }), db.personnelFile.count({ where }), ]) return { files, total } } export async function createPersonnelFile( p: EffectivePermissions, input: { displayName: string userId?: string | null serviceNumber?: string | null rank?: string | null department?: string | null status?: string | null note?: string | null classification: number }, ): Promise<{ id: string }> { must(canWritePersonnel(p, input.classification)) const name = input.displayName.trim() if (name.length < 2) throw new AccessError('name-required') return db.$transaction(async (tx) => { const created = await tx.personnelFile.create({ data: { displayName: name, userId: input.userId || null, serviceNumber: input.serviceNumber?.trim() || null, rank: input.rank?.trim() || null, department: input.department?.trim() || null, status: input.status?.trim() || null, note: input.note?.trim() || null, classification: input.classification, createdById: p.userId, }, select: { id: true }, }) await writeAudit(tx, { actorId: p.userId, action: 'personnel.create', targetType: 'PersonnelFile', targetId: created.id, clearanceAtTime: p.clearanceLevel, meta: { displayName: name, classification: input.classification }, }) return created }) } /** Kopfdaten ändern. Die Klassifizierung läuft über `reclassifyPersonnelFile`. */ export async function updatePersonnelFile( p: EffectivePermissions, id: string, input: { displayName?: string serviceNumber?: string | null rank?: string | null department?: string | null status?: string | null note?: string | null }, ): Promise { const target = await loadVisiblePersonnelFile(p, id) if (!target) throw new AccessError('not-found') must(canWritePersonnel(p, target.classification)) const name = input.displayName?.trim() if (name !== undefined && name.length < 2) throw new AccessError('name-required') await db.$transaction(async (tx) => { await tx.personnelFile.update({ where: { id }, data: { ...(name !== undefined ? { displayName: name } : {}), ...(input.serviceNumber !== undefined ? { serviceNumber: input.serviceNumber?.trim() || null } : {}), ...(input.rank !== undefined ? { rank: input.rank?.trim() || null } : {}), ...(input.department !== undefined ? { department: input.department?.trim() || null } : {}), ...(input.status !== undefined ? { status: input.status?.trim() || null } : {}), ...(input.note !== undefined ? { note: input.note?.trim() || null } : {}), }, }) await writeAudit(tx, { actorId: p.userId, action: 'personnel.update', targetType: 'PersonnelFile', targetId: id, clearanceAtTime: p.clearanceLevel, meta: { fields: Object.keys(input) }, }) }) } /** * Klassifizierung ändern. Wie bei der Fallakte ist die Herabstufung der * heiklere Fall: sie macht die Akte für Leute sichtbar, die sie vorher nicht * sehen durften. Sie braucht eine Begründung und steht im Protokoll. */ export async function reclassifyPersonnelFile( p: EffectivePermissions, id: string, to: number, reason?: string, ): Promise { const target = await loadVisiblePersonnelFile(p, id) if (!target) throw new AccessError('not-found') if (target.classification === to) return if (to < 0 || to > 5) throw new AccessError('clearance-too-low') must(canWritePersonnel(p, Math.max(to, target.classification))) const lowering = to < target.classification const begruendung = reason?.trim() ?? '' if (lowering && begruendung.length < 10) throw new AccessError('reason-required') await db.$transaction(async (tx) => { await tx.personnelFile.update({ where: { id }, data: { classification: to } }) await writeAudit(tx, { actorId: p.userId, action: 'personnel.reclassify', targetType: 'PersonnelFile', targetId: id, clearanceAtTime: p.clearanceLevel, meta: { from: target.classification, to, lowering, reason: begruendung || undefined, }, }) }) } /** * Rückverweise: die Fälle, in denen diese Person vorkommt, nach Rolle getrennt. * * **Diese Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.** * Das ist der häufigste Ort, an dem ein Aktensystem leckt: eine Personalakte * der Stufe 1, auf der „3 Fälle" steht, verrät die Existenz von Ermittlungen, * die der Leser nicht sehen darf. Deshalb steckt der Filter in der Abfrage und * die Zählung entsteht aus dem gefilterten Ergebnis — nie aus einem eigenen * `count` ohne Filter. Siehe docs/05-akten-und-vorlagen.md, Rückverweise. */ export async function loadBacklinks( p: EffectivePermissions, file: { id: string; userId: string | null }, ) { const wenDieAkteMeint: Prisma.CaseParticipantWhereInput[] = [{ personnelFileId: file.id }] // Dieselbe Person kann über ihre Personalakte oder über ihr Discord-Konto // eingetragen sein. Beides zählt, sonst fehlen Fälle. if (file.userId) wenDieAkteMeint.push({ userId: file.userId }) const rows = await db.caseParticipant.findMany({ where: { AND: [{ OR: wenDieAkteMeint }, { case: { is: visibleCaseWhere(p) } }], }, select: { role: true, case: { select: { id: true, caseNumber: true, title: true, status: true, classification: true, updatedAt: true, }, }, }, orderBy: { createdAt: 'desc' }, take: 200, }) // Eine Person kann in einem Fall mehrere Rollen haben; jede Rolle bekommt // ihren Eintrag, der Fall steht dann in beiden Gruppen. const nachRolle = new Map() for (const row of rows) { const gruppe = nachRolle.get(row.role) ?? [] gruppe.push(row) nachRolle.set(row.role, gruppe) } return { total: rows.length, byRole: [...nachRolle.entries()].map(([role, eintraege]) => ({ role, eintraege })), } }