Files
AfiS-Aktensystem/web/lib/departments/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

412 lines
13 KiB
TypeScript

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<DepartmentSummary[]> {
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<Slice[]> {
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,
redactedLabel: string,
): Promise<Slice[]> {
// **Alle** Abteilungen, auch die über der eigenen Stufe. Die Zahlen zählen
// weiterhin nur Fälle, die der Fragende sehen darf - was er hier erfährt,
// ist also nicht, was in einer verborgenen Abteilung steht, sondern dass
// einige der ihm bekannten Fälle zu einer gehören, deren Namen er nicht
// kennt. Ein Diagramm, das sie stillschweigend weglässt, wäre die
// unangenehmere Auskunft: die Anteile summierten sich nicht auf die
// Gesamtzahl, und niemand wüsste, warum. Siehe docs/13-entscheidungen.md.
const abteilungen = await db.department.findMany({
where: { isArchived: false },
select: { id: true, name: true, minClearance: true },
})
const zahlen = await Promise.all(
abteilungen.map((abteilung) =>
db.case.count({
where: { AND: [visibleCaseWhere(p), caseOfDepartment(abteilung.id)] },
}),
),
)
const sichtbar = (abteilung: { minClearance: number }): boolean =>
p.isAdmin || abteilung.minClearance <= p.clearanceLevel
const stuecke = abteilungen
.map((abteilung, i) => ({
label: sichtbar(abteilung) ? abteilung.name : redactedLabel,
value: zahlen[i]!,
verborgen: !sichtbar(abteilung),
}))
.filter((eintrag) => eintrag.value > 0)
// Mehrere verborgene Abteilungen ergeben **ein** geschwärztes Stück. Sonst
// stünde dort, wie viele es sind.
const offen = stuecke.filter((eintrag) => !eintrag.verborgen)
const verborgen = stuecke.filter((eintrag) => eintrag.verborgen)
const zusammen =
verborgen.length > 0
? [
{
label: redactedLabel,
value: verborgen.reduce((summe, eintrag) => summe + eintrag.value, 0),
},
]
: []
return [...offen.map(({ label, value }) => ({ label, value })), ...zusammen].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<void> {
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<void> {
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 },
})
})
}