Files
Harlekin 6b2b4c4c8c
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
Personalakten und Abteilungen als vollstaendiges Dossier speichern
Bisher liess sich eine einzelne Fallakte und ein einzelnes Dokument als PDF
sichern. Wer den Stand zu einer Person brauchte, rief beides einzeln ab,
legte es von Hand zusammen und wusste danach nicht, ob er alles hatte.

Ein Dossier ist die Personalakte, dahinter alle ihre Dokumente, dahinter
alle Fallakten, in denen die Person vorkommt - jeweils in derselben
Druckdarstellung wie einzeln. Fuer eine Abteilung dasselbe, je Mitglied
getrennt: eine Abteilung hat keine eigenen Fallakten, sie hat Mitglieder,
die in Fallakten vorkommen. Alles in einen Topf geschuettet staende dieselbe
Akte mehrfach da, ohne dass man saehe, ueber wen sie dort steht.

Der Abrufer waehlt die hoechste Stufe, die aufgenommen wird. Das ist keine
Zugriffspruefung, sondern eine Entscheidung ueber den Umfang - und beides
darf nie durcheinandergeraten:

  - Was ueber der Freigabestufe des Abrufers liegt, existiert fuer ihn
    nicht. Es steht nicht im Abzug, wird nicht gezaehlt und nicht erwaehnt.
  - Was er selbst ausgeschlossen hat, steht als Vermerk im PDF, nach Stufe
    aufgeschluesselt. Eine gekuerzte Mappe darf nicht wie eine
    vollstaendige aussehen, und der Deckel steht zusaetzlich auf jeder
    Seite im Wasserzeichen.

Beide Grenzen laufen in der Datenbankabfrage, nicht danach in JavaScript.

Alles wird als eine Druckseite gerendert statt als mehrere
zusammengeheftete PDFs. So laufen Seitenzahlen, Kopfzeile und Wasserzeichen
durch, und es gibt keinen zweiten Weg, der irgendwann anders aussieht als
der erste. Dafuer sind die vorhandenen Druckansichten zu Bausteinen
geworden - CaseSheet und DocumentSheet stehen jetzt in components/print und
werden von den alten Seiten wie vom Dossier benutzt.

Der Zwischenspeicherschluessel traegt den Deckel, sonst bekaeme ein Abruf
bis Stufe 1 die Datei eines Abrufs bis Stufe 4. Und der Stand ist der
spaeteste ueber alle enthaltenen Akten, nicht der der Kopfdaten allein -
sonst gaebe der Zwischenspeicher einen Abzug heraus, in dem ein
inzwischen geaendertes Dokument im alten Wortlaut steht.

Jeder Abruf steht mit seinem Deckel im Protokoll. Ohne den sagt der
Eintrag, dass jemand etwas ausser Haus gegeben hat, aber nicht wie viel.
2026-09-04 15:34:32 +02:00

406 lines
14 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import {
collectPersonnelDossier,
departmentMembers,
effectiveCap,
personnelDossierStand,
} from '@/lib/export/dossier'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { OmittedNote } from '@/components/print/DossierBody'
import { exportCacheKey } from '@/lib/export/cacheKey'
import { requestExport } from '@/lib/export/service'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Das Dossier: eine Personalakte mit allem, was daran hängt.
*
* Zwei Grenzen wirken darin, und der ganze Wert dieser Prüfungen liegt darin,
* sie auseinanderzuhalten:
*
* - Die **Freigabestufe des Abrufers** ist der Schutz. Was darüber liegt,
* darf nicht im Abzug stehen, nicht gezählt werden und nicht als
* weggelassen erwähnt werden - harte Regel 6.
* - Die **gewählte Höchststufe** ist eine Entscheidung über den Umfang. Was
* sie ausschliesst, muss im Abzug vermerkt sein, sonst hält der Empfänger
* eine gekürzte Mappe für eine vollständige.
*
* Ein Deckel, der nach oben wirkt, wäre die schlimmste denkbare Verwechslung.
* Dafür steht die erste Gruppe.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `dossier-${Date.now()}`
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
describe('Der Deckel wirkt nur nach unten', () => {
it('lässt niemanden über die eigene Stufe hinaus', () => {
// Der Kern. Wäre das je anders, wäre der Deckel ein Aufzug.
expect(effectiveCap(perms('u', 2), 5)).toBe(2)
expect(effectiveCap(perms('u', 0), 4)).toBe(0)
})
it('nimmt eine niedrigere Wahl an', () => {
expect(effectiveCap(perms('u', 4), 1)).toBe(1)
})
it('fällt bei Unsinn auf die eigene Stufe zurück, nicht nach oben', () => {
expect(effectiveCap(perms('u', 3), Number.NaN)).toBe(3)
expect(effectiveCap(perms('u', 3), -7)).toBe(0)
})
it('trennt zwei Abzüge mit verschiedenem Deckel im Zwischenspeicher', () => {
// Sonst bekäme ein Abruf bis Stufe 1 die Datei eines Abrufs bis Stufe 4.
const basis = {
kind: 'PERSONNEL',
targetId: 'abc',
updatedAt: new Date('2026-03-14T10:00:00Z'),
clearanceLevel: 4,
templateVersion: 0,
}
expect(exportCacheKey({ ...basis, maxLevel: 1 })).not.toBe(
exportCacheKey({ ...basis, maxLevel: 4 }),
)
expect(exportCacheKey({ ...basis, maxLevel: 2 })).toBe(
exportCacheKey({ ...basis, maxLevel: 2 }),
)
})
})
describe('Der Vermerk über das Weggelassene', () => {
/**
* Wirklich gerendert und nicht nur gezählt: der Vermerk ist das einzige, was
* dem Empfänger sagt, dass er eine gekürzte Mappe hält. Fehlte er im Markup,
* wäre die Zählung dahinter wertlos.
*/
const markup = (inhalt: Parameters<typeof OmittedNote>[0]['inhalt'], stufe = 4) =>
renderToStaticMarkup(createElement(OmittedNote, { inhalt, eigeneStufe: stufe }))
it('nennt Stufe und Anzahl', () => {
const html = markup({
omitted: [{ level: 3, documents: 2, cases: 1 }],
omittedTotal: 3,
truncated: false,
})
expect(html).toContain('Stufe 3')
expect(html).toContain('2 Dokumente')
expect(html).toContain('1 Fallakten')
})
it('schweigt, wenn nichts weggelassen wurde', () => {
// Ein Kasten "nichts weggelassen" auf jedem Abzug wäre Lärm; fehlt der
// Kasten, ist der Abzug vollständig.
expect(markup({ omitted: [], omittedTotal: 0, truncated: false })).toBe('')
})
it('sagt es, wenn der Abzug abgeschnitten wurde', () => {
const html = markup({ omitted: [], omittedTotal: 0, truncated: true })
expect(html).toContain('abgeschnitten')
})
})
suite('Was ins Dossier kommt', () => {
const abrufer = `${marke}-abrufer`
const fremd = `${marke}-fremd`
let personId = ''
let abteilungId = ''
const stufen = [1, 3, 5] as const
beforeAll(async () => {
await db.user.createMany({
data: [abrufer, fremd].map((id) => ({ id, username: id, displayName: id })),
})
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
const abteilung = await db.department.create({
data: {
// Kürzel: Grossbuchstaben, Ziffern, Bindestrich - und je Lauf anders,
// weil die Zeile stehen bleibt.
key: `PRUEF-${Date.now().toString(36).toUpperCase()}`.slice(0, 21),
name: 'Prüfabteilung',
minClearance: 0,
},
})
abteilungId = abteilung.id
const person = await db.personnelFile.create({
data: { displayName: `${marke}-person`, classification: 1, departmentId: abteilung.id },
})
personId = person.id
// Je eine Personalakte, ein Dokument und eine Fallakte auf Stufe 1, 3, 5.
for (const stufe of stufen) {
await db.document.create({
data: {
documentNumber: `${marke}-D${stufe}`,
title: `Dokument Stufe ${stufe}`,
templateId: vorlage.id,
templateKey: vorlage.key,
templateVersion: 1,
classification: stufe,
personnelFileId: person.id,
createdById: abrufer,
},
})
const akte = await db.case.create({
data: {
caseNumber: `${marke}-F${stufe}`,
title: `Fall Stufe ${stufe}`,
templateId: vorlage.id,
templateKey: vorlage.key,
classification: stufe,
status: 'OPEN',
createdById: abrufer,
},
})
await db.caseParticipant.create({
data: { caseId: akte.id, personnelFileId: person.id, role: 'SUBJECT' },
})
}
// Eine Fallakte ohne diese Person - sie darf nie auftauchen.
await db.case.create({
data: {
caseNumber: `${marke}-Fremd`,
title: 'Ohne die Person',
templateId: vorlage.id,
templateKey: vorlage.key,
classification: 1,
status: 'OPEN',
createdById: abrufer,
},
})
// Mitglieder der Abteilung auf verschiedenen Stufen.
await db.personnelFile.createMany({
data: stufen.map((stufe) => ({
displayName: `${marke}-mitglied-${stufe}`,
classification: stufe,
departmentId: abteilung.id,
})),
})
})
// Kein Aufräumen: Akten, Dokumente und Protokolleinträge lassen sich in
// diesem System nicht löschen - Regeln in der Datenbank verbieten es (harte
// Regel 2). Jeder Lauf arbeitet deshalb unter einer eigenen Marke.
afterAll(async () => {
await db.$disconnect()
})
it('nimmt alles bis zur gewählten Stufe auf', async () => {
const inhalt = await collectPersonnelDossier(
perms(abrufer, 5),
{ id: personId, userId: null },
3,
)
expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3])
expect(inhalt.cases.map((c) => c.classification).sort()).toEqual([1, 3])
})
it('vermerkt, was der Abrufer selbst weggelassen hat', async () => {
const inhalt = await collectPersonnelDossier(
perms(abrufer, 5),
{ id: personId, userId: null },
1,
)
// Stufe 3 und 5 dürfte er sehen, hat sie aber ausgeschlossen: beides muss
// im Abzug stehen, sonst wirkt die Mappe vollständig.
expect(inhalt.omitted).toEqual([
{ level: 3, documents: 1, cases: 1 },
{ level: 5, documents: 1, cases: 1 },
])
expect(inhalt.omittedTotal).toBe(4)
})
it('zählt nicht mit, was über der eigenen Stufe liegt', async () => {
// Der wichtigste negative Fall: Stufe 3 wählt Deckel 3. Was auf Stufe 5
// liegt, darf weder im Abzug stehen noch als weggelassen gezählt werden -
// die Zahl allein verriete, dass es etwas gibt.
const inhalt = await collectPersonnelDossier(
perms(abrufer, 3),
{ id: personId, userId: null },
3,
)
expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3])
expect(inhalt.omitted).toEqual([])
expect(inhalt.omittedTotal).toBe(0)
})
it('zählt auch dann nichts Höheres, wenn der Deckel niedriger liegt', async () => {
const inhalt = await collectPersonnelDossier(
perms(abrufer, 3),
{ id: personId, userId: null },
1,
)
// Nur Stufe 3 fehlt sichtbar. Stufe 5 bleibt unerwähnt.
expect(inhalt.omitted).toEqual([{ level: 3, documents: 1, cases: 1 }])
})
it('nimmt keine Fallakte auf, in der die Person nicht vorkommt', async () => {
const inhalt = await collectPersonnelDossier(
perms(abrufer, 5),
{ id: personId, userId: null },
5,
)
expect(inhalt.cases.map((c) => c.caseNumber)).not.toContain(`${marke}-Fremd`)
expect(inhalt.cases).toHaveLength(3)
})
it('gibt einem Abrufer ohne Stufe gar nichts', async () => {
const inhalt = await collectPersonnelDossier(
perms(fremd, 0),
{ id: personId, userId: null },
5,
)
expect(inhalt.documents).toEqual([])
expect(inhalt.cases).toEqual([])
expect(inhalt.omitted).toEqual([])
})
it('führt den Stand über das ganze Dossier, nicht nur über die Kopfdaten', async () => {
const akte = await db.personnelFile.findUniqueOrThrow({ where: { id: personId } })
const vorher = await personnelDossierStand(
perms(abrufer, 5),
{ id: personId, userId: null, updatedAt: akte.updatedAt },
5,
)
// Ein Dokument ändern, die Personalakte nicht anfassen.
await db.document.update({
where: { documentNumber: `${marke}-D1` },
data: { title: 'Geändert' },
})
const nachher = await personnelDossierStand(
perms(abrufer, 5),
{ id: personId, userId: null, updatedAt: akte.updatedAt },
5,
)
// Ohne das gäbe der Zwischenspeicher den alten Abzug heraus.
expect(nachher.getTime()).toBeGreaterThan(vorher.getTime())
})
it('lässt Mitglieder über der eigenen Stufe aus der Abteilung verschwinden', async () => {
const { members, omittedMembers } = await departmentMembers(perms(abrufer, 3), abteilungId, 3)
const stufenDerMitglieder = members.map((m) => m.classification).sort()
expect(stufenDerMitglieder).toEqual([1, 1, 3])
// Das Mitglied auf Stufe 5 wird auch nicht gezählt.
expect(omittedMembers).toBe(0)
})
it('zählt Mitglieder, die der Abrufer weggelassen hat', async () => {
const { members, omittedMembers } = await departmentMembers(perms(abrufer, 5), abteilungId, 1)
expect(members.map((m) => m.classification).sort()).toEqual([1, 1])
expect(omittedMembers).toBe(2)
})
it('lädt die vollständigen Kopfdaten der Mitglieder', async () => {
// Sonst stünde im Abteilungsabzug bei jedem "Kein Status gesetzt", auch bei
// jemandem, der einen trägt.
const { members } = await departmentMembers(perms(abrufer, 5), abteilungId, 5)
for (const mitglied of members) {
expect(mitglied).toHaveProperty('statusTags')
expect(mitglied).toHaveProperty('note')
expect(mitglied.createdAt).toBeInstanceOf(Date)
}
})
})
suite('Der Auftrag selbst', () => {
const abrufer = `${marke}-auftrag`
let personId = ''
let hochId = ''
beforeAll(async () => {
await db.user.create({ data: { id: abrufer, username: abrufer, displayName: abrufer } })
const person = await db.personnelFile.create({
data: { displayName: `${marke}-auftrag-person`, classification: 1 },
})
personId = person.id
const hoch = await db.personnelFile.create({
data: { displayName: `${marke}-auftrag-hoch`, classification: 4 },
})
hochId = hoch.id
})
afterAll(async () => {
await db.$disconnect()
})
it('legt einen Auftrag mit gedeckelter Höchststufe an', async () => {
// Angefragt wird 5, erlaubt ist 2. Im Auftrag muss 2 stehen - und nicht 5,
// auch nicht "erstmal 5 und später gedeckelt".
const auftrag = await requestExport(perms(abrufer, 2), {
kind: 'PERSONNEL',
personnelFileId: personId,
maxLevel: 5,
})
const zeile = await db.exportJob.findUniqueOrThrow({ where: { id: auftrag.jobId } })
expect(zeile.kind).toBe('PERSONNEL')
expect(zeile.maxLevel).toBe(2)
expect(zeile.clearanceAtTime).toBe(2)
})
it('protokolliert den Abzug mit seinem Deckel', async () => {
await requestExport(perms(abrufer, 3), {
kind: 'PERSONNEL',
personnelFileId: personId,
maxLevel: 1,
})
const eintrag = await db.auditLog.findFirst({
where: { actorId: abrufer, action: 'export.pdf', targetType: 'PersonnelFile' },
orderBy: { at: 'desc' },
})
// Ohne den Deckel im Protokoll steht dort nicht, wie viel das Haus
// verlassen hat.
expect(eintrag?.meta).toMatchObject({ kind: 'PERSONNEL', maxLevel: 1 })
})
it('weist ab, wer die Stufe der Personalakte nicht hat', async () => {
// Lesen darf man eine Akte unter Umständen auch als Zugeordneter. Ausser
// Haus geben nicht.
await expect(
requestExport(perms(abrufer, 2), {
kind: 'PERSONNEL',
personnelFileId: hochId,
maxLevel: 2,
}),
).rejects.toBeInstanceOf(AccessError)
})
it('gibt es nicht, wenn der Abrufer die Akte gar nicht sehen darf', async () => {
// 404 statt 403: dass es die Akte gibt, geht ihn nichts an.
await expect(
requestExport(perms(abrufer, 0), {
kind: 'PERSONNEL',
personnelFileId: hochId,
maxLevel: 0,
}),
).rejects.toMatchObject({ code: 'not-found' })
})
})