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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
412 lines
13 KiB
TypeScript
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 },
|
|
})
|
|
})
|
|
}
|