Files
AfiS-Aktensystem/web/lib/personnel/service.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

396 lines
12 KiB
TypeScript

import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit/write'
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 { AssignmentRoleName } from '@/lib/personnel/terms'
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,
rank: true,
departmentId: true,
department: { select: { id: true, key: true, name: true } },
statusTags: 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; statusTag?: string; take?: number } = {},
) {
const begriff = options.query?.trim()
const where: Prisma.PersonnelFileWhereInput = {
AND: [
visiblePersonnelWhere(p),
...(options.statusTag ? [{ statusTags: { has: options.statusTag } }] : []),
...(begriff
? [
{
OR: [
{ displayName: { contains: begriff, mode: 'insensitive' as const } },
{ rank: { contains: begriff, mode: 'insensitive' as const } },
{ department: { is: { name: { contains: begriff, mode: 'insensitive' as const } } } },
{ department: { is: { key: { 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
rank?: string | null
departmentId?: string | null
statusTags?: string[]
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,
rank: input.rank?.trim() || null,
departmentId: input.departmentId || null,
statusTags: await gueltigeStatusEtiketten(input.statusTags),
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
rank?: string | null
departmentId?: string | null
statusTags?: string[]
note?: string | null
},
): Promise<void> {
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.rank !== undefined ? { rank: input.rank?.trim() || null } : {}),
...(input.departmentId !== undefined
? { departmentId: input.departmentId || null }
: {}),
...(input.statusTags !== undefined
? { statusTags: await gueltigeStatusEtiketten(input.statusTags) }
: {}),
...(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<void> {
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<string, typeof rows>()
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 })),
}
}
/**
* Filtert auf die Etiketten, die es wirklich gibt.
*
* Sonst könnte ein manipuliertes Formular einen erfundenen Status setzen, und
* die Liste im Adminbereich wäre nur noch eine Empfehlung. Die Reihenfolge
* kommt aus der Konfiguration, nicht aus der Eingabe: so steht auf jeder Akte
* dieselbe Abfolge.
*/
async function gueltigeStatusEtiketten(gewuenscht: string[] | undefined): Promise<string[]> {
if (!gewuenscht?.length) return []
const bekannt = await db.personnelStatusTag.findMany({
where: { key: { in: gewuenscht }, isActive: true },
orderBy: { sortOrder: 'asc' },
select: { key: true },
})
return bekannt.map((tag) => tag.key)
}
/** Die Etiketten zur Auswahl, für Formulare und Filter. */
export async function listStatusTags() {
return db.personnelStatusTag.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
select: { key: true, name: true, colorToken: true },
})
}
/**
* Die AfiS-Mitglieder, die an dieser Personalakte hängen.
*
* Wer hier steht, liest die Akte unabhängig von seiner Freigabestufe - das ist
* derselbe ausnahmsweise Zugang wie `CaseParticipant` bei der Fallakte.
*/
export async function listAssignments(p: EffectivePermissions, personnelFileId: string) {
return db.personnelAssignment.findMany({
where: {
AND: [{ personnelFileId }, { personnelFile: { is: visiblePersonnelWhere(p) } }],
},
orderBy: { createdAt: 'asc' },
select: {
id: true,
role: true,
createdAt: true,
user: { select: { id: true, displayName: true } },
},
})
}
export async function addAssignment(
p: EffectivePermissions,
input: { personnelFileId: string; userId: string; role: AssignmentRoleName },
): Promise<{ id: string }> {
const target = await loadVisiblePersonnelFile(p, input.personnelFileId)
if (!target) throw new AccessError('not-found')
must(canWritePersonnel(p, target.classification))
const mitglied = await db.user.findUnique({
where: { id: input.userId },
select: { id: true },
})
if (!mitglied) throw new AccessError('not-found')
const vorhanden = await db.personnelAssignment.findFirst({
where: { personnelFileId: target.id, userId: input.userId, role: input.role },
select: { id: true },
})
if (vorhanden) return vorhanden
return db.$transaction(async (tx) => {
const created = await tx.personnelAssignment.create({
data: {
personnelFileId: target.id,
userId: input.userId,
role: input.role,
addedById: p.userId,
},
select: { id: true },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'personnel.assign',
targetType: 'PersonnelAssignment',
targetId: created.id,
clearanceAtTime: p.clearanceLevel,
meta: {
personnelFileId: target.id,
displayName: target.displayName,
userId: input.userId,
role: input.role,
},
})
return created
})
}
export async function removeAssignment(
p: EffectivePermissions,
input: { personnelFileId: string; assignmentId: string },
): Promise<void> {
const target = await loadVisiblePersonnelFile(p, input.personnelFileId)
if (!target) throw new AccessError('not-found')
must(canWritePersonnel(p, target.classification))
const zuordnung = await db.personnelAssignment.findFirst({
where: { id: input.assignmentId, personnelFileId: target.id },
select: { id: true, userId: true, role: true },
})
if (!zuordnung) throw new AccessError('not-found')
await db.$transaction(async (tx) => {
await tx.personnelAssignment.delete({ where: { id: zuordnung.id } })
await writeAudit(tx, {
actorId: p.userId,
action: 'personnel.unassign',
targetType: 'PersonnelAssignment',
targetId: zuordnung.id,
clearanceAtTime: p.clearanceLevel,
meta: {
personnelFileId: target.id,
userId: zuordnung.userId,
role: zuordnung.role,
},
})
})
}