import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { AccessError } from '@/lib/errors' import { visibleDepartmentWhere } from '@/lib/access/departmentFilter' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { canWritePersonnel } from '@/lib/access/documentWrite' import type { EffectivePermissions } from '@/lib/auth/permissions' import type { Prisma } from '@afis/db' /** * Abteilungen — die Ebene über der Personalakte. * * Der Aufbau ist derselbe wie bei der Personalakte, nur eine Stufe höher: was * zu einer Abteilung aktenkundig ist, **wird abgeleitet und nicht * eingetragen**. Eine Fallakte trägt keine Abteilung; sie gehört zu einer, * wenn eine beteiligte Person dieser Abteilung angehört. * * Das ist die wichtigste Festlegung hier. Eine eingetragene Abteilung an der * Akte wäre schnell veraltet — Leute wechseln, Beteiligte kommen dazu — und * niemand würde sie nachpflegen. Abgeleitet stimmt sie immer. * * Jede Abfrage läuft durch dieselben Sichtbarkeitsfragmente wie überall sonst. * Eine sichtbare Abteilung ist kein Zugang zu ihrem Inhalt. */ export type DepartmentSummary = { id: string key: string name: string description: string | null minClearance: number isArchived: boolean /** `null`, wenn der Fragende Personalakten gar nicht sehen darf. Nicht `0` — eine Null wäre die Auskunft „hier ist niemand", und die steht ihm nicht zu. */ memberCount: number | null } /** Alle Abteilungen, die dieser Nutzer sehen darf. */ export async function listDepartments( p: EffectivePermissions, options: { includeArchived?: boolean } = {}, ): Promise { const abteilungen = await db.department.findMany({ where: { AND: [ visibleDepartmentWhere(p), ...(options.includeArchived ? [] : [{ isArchived: false }]), ], }, orderBy: { name: 'asc' }, select: { id: true, key: true, name: true, description: true, minClearance: true, isArchived: true, }, }) if (abteilungen.length === 0) return [] // Die Mitgliederzahl zählt nur, was der Nutzer auch sehen dürfte — sonst // verriete die Zahl, wie viele Personalakten über seiner Stufe liegen. if (!darfPersonal(p)) { return abteilungen.map((abteilung) => ({ ...abteilung, memberCount: null })) } const zaehler = await Promise.all( abteilungen.map((abteilung) => db.personnelFile.count({ where: { AND: [visiblePersonnelWhere(p), { departmentId: abteilung.id }] }, }), ), ) return abteilungen.map((abteilung, i) => ({ ...abteilung, memberCount: zaehler[i]! })) } export async function loadVisibleDepartment(p: EffectivePermissions, key: string) { return db.department.findFirst({ where: { AND: [visibleDepartmentWhere(p), { key }] }, select: { id: true, key: true, name: true, description: true, minClearance: true, isArchived: true, }, }) } function darfPersonal(p: EffectivePermissions): boolean { return p.isAdmin || p.permissions.has('personnel.read') } /** Die Mitglieder einer Abteilung, gefiltert wie jede Personalaktenliste. */ export async function listMembers(p: EffectivePermissions, departmentId: string) { if (!darfPersonal(p)) return [] return db.personnelFile.findMany({ where: { AND: [visiblePersonnelWhere(p), { departmentId }] }, orderBy: { displayName: 'asc' }, select: { id: true, displayName: true, rank: true, statusTags: true, classification: true, }, }) } /** * Das Where-Fragment für „Fälle dieser Abteilung". * * Über die Beteiligten und deren Personalakten. Beide Richtungen zählen: ob * jemand Ermittler oder Betroffener ist, ändert nichts daran, dass der Fall * die Abteilung betrifft — für die Zugehörigkeit ist die Rolle unerheblich, * für den Zugriff nicht (siehe lib/cases/terms.ts). */ function caseOfDepartment(departmentId: string): Prisma.CaseWhereInput { return { participants: { some: { personnelFile: { is: { departmentId } } } }, } } export async function listDepartmentCases( p: EffectivePermissions, departmentId: string, take = 25, ) { return db.case.findMany({ where: { AND: [visibleCaseWhere(p), caseOfDepartment(departmentId)] }, orderBy: { updatedAt: 'desc' }, take, select: { id: true, caseNumber: true, title: true, status: true, templateKey: true, classification: true, updatedAt: true, }, }) } /** * Dokumente der Abteilung — abgeleitet wie alles hier. * * Zwei Wege dorthin: das Dokument hängt an der Personalakte eines Mitglieds, * oder an einer Fallakte, die diese Abteilung betrifft. Ein Dokument * unmittelbar an der Abteilung gibt es nicht: die Vorlagen kennen über * `TemplateUse` nur Fall- und Personalakte, und ein Besitzer, für den es keine * Vorlage gibt, wäre ein Feld, das nichts füllen kann. */ export async function listDepartmentDocuments( p: EffectivePermissions, departmentId: string, take = 25, ) { const gehoertDazu: Prisma.DocumentWhereInput = { OR: [ { case: { is: caseOfDepartment(departmentId) } }, ...(darfPersonal(p) ? [{ personnelFile: { is: { departmentId } } } as Prisma.DocumentWhereInput] : []), ], } return db.document.findMany({ where: { AND: [visibleDocumentWhere(p), gehoertDazu] }, orderBy: { updatedAt: 'desc' }, take, select: { id: true, documentNumber: true, title: true, status: true, classification: true, updatedAt: true, }, }) } export type Slice = { label: string; value: number } /** * Womit diese Abteilung aktenkundig ist, nach Fallart. * * Gezählt wird über **dieselbe** Bedingung wie die Liste. Ein Diagramm, das * mehr Fälle zeigt als die Liste hergibt, wäre selbst eine Auskunft — genau * wie ein Facettenzähler. Siehe harte Regel 6. */ export async function caseTypesOfDepartment( p: EffectivePermissions, departmentId: string, ): Promise { const gruppen = await db.case.groupBy({ by: ['templateKey'], where: { AND: [visibleCaseWhere(p), caseOfDepartment(departmentId)] }, _count: { _all: true }, }) return gruppen .map((gruppe) => ({ label: gruppe.templateKey, value: gruppe._count._all })) .sort((a, b) => b.value - a.value) } /** * Die Gegenrichtung: welche Abteilung wie viele Fälle hat. * * Das ist die Frage, mit der die Übersicht der Abteilungen anfängt — wo häuft * sich etwas. Auch hier zählt nur, was der Fragende sehen darf. */ export async function casesPerDepartment(p: EffectivePermissions): Promise { const abteilungen = await db.department.findMany({ where: { AND: [visibleDepartmentWhere(p), { isArchived: false }] }, select: { id: true, name: true }, }) const zahlen = await Promise.all( abteilungen.map((abteilung) => db.case.count({ where: { AND: [visibleCaseWhere(p), caseOfDepartment(abteilung.id)] }, }), ), ) return abteilungen .map((abteilung, i) => ({ label: abteilung.name, value: zahlen[i]! })) .filter((eintrag) => eintrag.value > 0) .sort((a, b) => b.value - a.value) } // --------------------------------------------------------------------------- // Verwaltung // --------------------------------------------------------------------------- function darfVerwalten(p: EffectivePermissions): boolean { return p.isAdmin || p.permissions.has('roles.manage') } export async function createDepartment( p: EffectivePermissions, input: { key: string; name: string; description?: string | null; minClearance: number }, ): Promise<{ key: string }> { if (!darfVerwalten(p)) throw new AccessError('no-permission') // Eine Abteilung anzulegen, die man selbst nicht sieht, ergibt keinen Sinn — // dieselbe Regel wie bei Akten und Dokumenten. if (!p.isAdmin && input.minClearance > p.clearanceLevel) { throw new AccessError('clearance-too-low') } const key = input.key.trim().toUpperCase() if (!/^[A-Z0-9][A-Z0-9-]{0,20}$/.test(key)) throw new AccessError('department-key-invalid') const name = input.name.trim() if (name.length === 0) throw new AccessError('department-name-missing') const vorhanden = await db.department.findUnique({ where: { key }, select: { id: true } }) if (vorhanden) throw new AccessError('department-key-taken') const abteilung = await db.$transaction(async (tx) => { const angelegt = await tx.department.create({ data: { key, name, description: input.description?.trim() || null, minClearance: input.minClearance, createdById: p.userId, }, select: { id: true, key: true }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.create', targetType: 'Department', targetId: angelegt.id, clearanceAtTime: p.clearanceLevel, meta: { key, name }, }) return angelegt }) return { key: abteilung.key } } export async function updateDepartment( p: EffectivePermissions, input: { id: string name: string description?: string | null minClearance: number isArchived: boolean }, ): Promise { if (!darfVerwalten(p)) throw new AccessError('no-permission') if (!p.isAdmin && input.minClearance > p.clearanceLevel) { throw new AccessError('clearance-too-low') } const abteilung = await db.department.findFirst({ where: { AND: [visibleDepartmentWhere(p), { id: input.id }] }, select: { id: true }, }) if (!abteilung) throw new AccessError('not-found') const name = input.name.trim() if (name.length === 0) throw new AccessError('department-name-missing') await db.$transaction(async (tx) => { await tx.department.update({ where: { id: input.id }, data: { name, description: input.description?.trim() || null, minClearance: input.minClearance, isArchived: input.isArchived, }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.update', targetType: 'Department', targetId: input.id, clearanceAtTime: p.clearanceLevel, meta: { name, minClearance: input.minClearance, isArchived: input.isArchived }, }) }) } /** * Eine Person einer Abteilung zuordnen — oder die Zuordnung aufheben. * * Setzt dasselbe Recht voraus wie das Ändern der Personalakte selbst: die * Zugehörigkeit steht in der Akte, und wer sie nicht schreiben darf, darf auch * das nicht. */ export async function assignToDepartment( p: EffectivePermissions, input: { personnelFileId: string; departmentId: string | null }, ): Promise { const akte = await db.personnelFile.findFirst({ where: { AND: [visiblePersonnelWhere(p), { id: input.personnelFileId }] }, select: { id: true, classification: true, departmentId: true }, }) if (!akte || !darfPersonal(p)) throw new AccessError('not-found') const erlaubt = canWritePersonnel(p, akte.classification) if (!erlaubt.ok) throw new AccessError(erlaubt.reason) if (input.departmentId) { const ziel = await db.department.findFirst({ where: { AND: [visibleDepartmentWhere(p), { id: input.departmentId, isArchived: false }] }, select: { id: true }, }) if (!ziel) throw new AccessError('not-found') } await db.$transaction(async (tx) => { await tx.personnelFile.update({ where: { id: akte.id }, data: { departmentId: input.departmentId }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.assign', targetType: 'PersonnelFile', targetId: akte.id, clearanceAtTime: p.clearanceLevel, meta: { from: akte.departmentId, to: input.departmentId }, }) }) }