Files
AfiS-Aktensystem/web/lib/departments/service.ts
T
Harlekin d7fdbb54f4 Export ohne Vier-Augen, Wiedervorlage setzbar, Druckansicht mit Weg zurück
**Export.** Das Vier-Augen-Verfahren ab Stufe 3 ist weg. Wer exportiert,
entscheidet jetzt allein die Freigabestufe: mindestens die des Ziels. Das ist
strenger als das Lesen — wer als Beteiligter ausnahmsweise in eine höher
eingestufte Akte darf, darf sie deshalb noch nicht ausser Haus geben.

Dabei fiel eine Lücke auf: hängte man sich an einen laufenden Auftrag,
entstand kein Protokolleintrag. „Jeder Export wird protokolliert" heisst jeder
Abruf, nicht jeder Rendervorgang — wer dieselbe Datei bekommt, muss genauso
darin stehen.

**Fristen.** dueAt wurde überall gelesen — Übersicht, Suchfilter, Ausdruck —
und nirgends gesetzt. Es gab keine Stelle, an der man ein Datum einträgt,
deshalb stand unter „Fristen" immer „Nichts steht an". Jetzt im Kopf der Akte,
mit Protokolleintrag und ohne Verlaufseintrag: eine Wiedervorlage ist eine
Merkhilfe, keine Aussage über den Fall.

**Statuswechsel.** Aus einem Entwurf führt laut Lebenszyklus nur „Offen"
heraus, deshalb standen dort nie mehr als zwei Ziele. Das sah aus wie ein
Fehler und war keiner — jetzt steht die Erklärung daneben und an jeder Option.

**Druckansicht.** Sie hatte kein eigenes Layout: das weisse Blatt lag auf dem
dunklen Thema und es gab keinen Weg zurück. Jetzt heller Grund, eine Leiste
mit Zurück und Drucken, beides beim Drucken ausgeblendet. Das Ziel wird aus
dem Pfad abgeleitet, damit es auch beim direkten Aufruf stimmt.

**Abteilungsstatistik.** Eine Abteilung ohne Freigabe wird als „Ohne Freigabe"
geführt statt weggelassen — sonst summierten sich die Anteile nicht auf die
Zahl daneben. Mehrere ergeben ein Stück, sonst stünde dort, wie viele es sind.

Dazu: Dienstgrad in eckigen Klammern vor dem Namen und in dessen Grösse, in
einer eigenen Spalte; Knopfform für „Personalakte anlegen"; die abgeschnittene
Anzahl im Kopf der Personalaktenliste; „Zuletzt geöffnet" ohne die zwei
Hinweistexte, leer als Zeichen statt als Satz.
2026-09-04 00:14:07 +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 },
})
})
}