diff --git a/packages/db/prisma/migrations/20260904160000_dossier/migration.sql b/packages/db/prisma/migrations/20260904160000_dossier/migration.sql
new file mode 100644
index 0000000..1e98af3
--- /dev/null
+++ b/packages/db/prisma/migrations/20260904160000_dossier/migration.sql
@@ -0,0 +1,27 @@
+-- Dossier: Personalakte und Abteilung als vollständiger Abzug.
+--
+-- Bisher liess sich eine einzelne Fallakte und ein einzelnes Dokument als PDF
+-- speichern. Wer den Stand zu einer Person brauchte, musste beides einzeln
+-- abrufen und von Hand zusammenlegen - und wusste danach nicht, ob er alles
+-- hatte.
+--
+-- Der Abzug ist gedeckelt: der Abrufer wählt die höchste Stufe, die
+-- aufgenommen wird. Das ist keine Zugriffsprüfung, sondern eine Entscheidung
+-- über den Umfang - die Prüfung bleibt die Freigabestufe des Abrufers, und
+-- `maxLevel` liegt nie darüber. Was dadurch wegfällt, steht als Vermerk im
+-- PDF: eine gekürzte Mappe darf nicht wie eine vollständige aussehen.
+--
+-- `maxLevel` gehört in den Zwischenspeicherschlüssel, sonst bekäme ein Abruf
+-- bis Stufe 1 die Datei eines Abrufs bis Stufe 4.
+
+ALTER TYPE "ExportKind" ADD VALUE IF NOT EXISTS 'PERSONNEL';
+ALTER TYPE "ExportKind" ADD VALUE IF NOT EXISTS 'DEPARTMENT';
+
+ALTER TABLE "ExportJob" ADD COLUMN "maxLevel" INTEGER;
+
+-- Nie mehr aufnehmen, als der Abrufer selbst sehen darf. Die Anwendung deckelt
+-- ohnehin an drei Stellen; hier steht es als Bedingung, die nicht umgangen
+-- werden kann.
+ALTER TABLE "ExportJob"
+ ADD CONSTRAINT "ExportJob_maxLevel_nicht_ueber_stufe"
+ CHECK ("maxLevel" IS NULL OR "maxLevel" <= "clearanceAtTime");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index a81c061..365451a 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -1006,6 +1006,10 @@ model ExportJob {
/// Die Stufe des Abrufers zum Zeitpunkt der Anfrage. Sie steckt im
/// Schluessel und im Wasserzeichen.
clearanceAtTime Int
+ /// Hoechste Stufe, die in den Abzug aufgenommen wird. Beim Dossier waehlbar,
+ /// nie hoeher als clearanceAtTime. Steht im Schluessel, damit zwei Abzuege
+ /// mit verschiedenen Deckeln nicht dieselbe Datei bekommen.
+ maxLevel Int?
status ExportStatus @default(QUEUED)
cacheKey String
/// Pfad unterhalb von FILES_DIR, sobald fertig.
@@ -1033,6 +1037,11 @@ enum ExportKind {
CASE
/// ein einzelnes Dokument, ohne Verlauf
DOCUMENT
+ /// eine Personalakte samt ihren Dokumenten und den Fallakten, in denen die
+ /// Person vorkommt
+ PERSONNEL
+ /// eine Abteilung, je Mitglied ein vollstaendiges Dossier
+ DEPARTMENT
/// mehrere Akten als ZIP, nur mit Recht 'admin'
BUNDLE
/// vollstaendiger JSON-Abzug, nur mit Recht 'admin'
diff --git a/web/app/(app)/abteilungen/[key]/export/loading.tsx b/web/app/(app)/abteilungen/[key]/export/loading.tsx
new file mode 100644
index 0000000..3b1b469
--- /dev/null
+++ b/web/app/(app)/abteilungen/[key]/export/loading.tsx
@@ -0,0 +1,15 @@
+/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */
+export default function Loading() {
+ return (
+
+ )
+}
diff --git a/web/app/(app)/abteilungen/[key]/export/page.tsx b/web/app/(app)/abteilungen/[key]/export/page.tsx
new file mode 100644
index 0000000..b0fe52d
--- /dev/null
+++ b/web/app/(app)/abteilungen/[key]/export/page.tsx
@@ -0,0 +1,57 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requireUser } from '@/lib/auth/current'
+import { loadVisibleDepartment } from '@/lib/departments/service'
+import { de } from '@/lib/i18n/de'
+import { ExportPanel } from '@/components/export/ExportPanel'
+
+export const metadata: Metadata = { title: de.export.dossierHeading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Abteilungsabzug als PDF: je Mitglied ein vollständiges Dossier.
+ *
+ * Der Hinweis auf den Umfang steht bewusst da. Wer bei fünfzig Mitgliedern
+ * ohne Deckel abruft, bekommt ein Bündel, das niemand mehr liest.
+ */
+export default async function DepartmentExportPage({
+ params,
+}: {
+ params: Promise<{ key: string }>
+}) {
+ const user = await requireUser()
+ const { key } = await params
+
+ const abteilung = await loadVisibleDepartment(user.permissions, key)
+ if (!abteilung) notFound()
+
+ const stufen = await db.clearanceLevelConfig.findMany({
+ where: user.permissions.isAdmin ? {} : { level: { lte: user.permissions.clearanceLevel } },
+ orderBy: { level: 'asc' },
+ select: { level: true, name: true },
+ })
+
+ return (
+
+
{de.export.dossierHeading}
+
{de.export.departmentDossierIntro}
+
+
+
+
+
+
+
+ {de.export.print}
+
+
+
+ )
+}
diff --git a/web/app/(app)/abteilungen/[key]/page.tsx b/web/app/(app)/abteilungen/[key]/page.tsx
index 6398c7e..e79745a 100644
--- a/web/app/(app)/abteilungen/[key]/page.tsx
+++ b/web/app/(app)/abteilungen/[key]/page.tsx
@@ -68,7 +68,12 @@ export default async function DepartmentPage({ params }: { params: Promise<{ key
) : null}
- {abteilung.name}
+
+
{abteilung.name}
+
+ {de.export.dossier}
+
+
{abteilung.description ? (
{abteilung.description}
diff --git a/web/app/(app)/export/actions.ts b/web/app/(app)/export/actions.ts
index 6a82d29..d287cea 100644
--- a/web/app/(app)/export/actions.ts
+++ b/web/app/(app)/export/actions.ts
@@ -23,6 +23,18 @@ function toMessage(error: unknown): string {
const requestSchema = z.union([
z.object({ kind: z.literal('CASE'), caseNumber: z.string().min(1) }),
z.object({ kind: z.literal('DOCUMENT'), documentNumber: z.string().min(1) }),
+ z.object({
+ kind: z.literal('PERSONNEL'),
+ personnelFileId: z.string().min(1),
+ // Gedeckelt wird im Dienst auf die Stufe des Abrufers. Hier steht nur die
+ // Form: eine Zahl im gültigen Bereich, nicht irgendein Text.
+ maxLevel: z.coerce.number().int().min(0).max(5),
+ }),
+ z.object({
+ kind: z.literal('DEPARTMENT'),
+ departmentKey: z.string().min(1),
+ maxLevel: z.coerce.number().int().min(0).max(5),
+ }),
])
/**
diff --git a/web/app/(app)/personalakten/[id]/export/loading.tsx b/web/app/(app)/personalakten/[id]/export/loading.tsx
new file mode 100644
index 0000000..3b1b469
--- /dev/null
+++ b/web/app/(app)/personalakten/[id]/export/loading.tsx
@@ -0,0 +1,15 @@
+/** Skelett, keine Blockade. Siehe docs/09-performance-und-ux.md. */
+export default function Loading() {
+ return (
+
+ )
+}
diff --git a/web/app/(app)/personalakten/[id]/export/page.tsx b/web/app/(app)/personalakten/[id]/export/page.tsx
new file mode 100644
index 0000000..f555504
--- /dev/null
+++ b/web/app/(app)/personalakten/[id]/export/page.tsx
@@ -0,0 +1,61 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requirePermission } from '@/lib/auth/current'
+import { loadVisiblePersonnelFile } from '@/lib/personnel/service'
+import { de } from '@/lib/i18n/de'
+import { ExportPanel } from '@/components/export/ExportPanel'
+
+export const metadata: Metadata = { title: de.export.dossierHeading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Dossier einer Person als PDF.
+ *
+ * Eigene Seite wie beim Fallexport: die Personalakte ist voll, und die Wahl
+ * der Höchststufe braucht Platz und eine Erklärung. Siehe
+ * docs/13-entscheidungen.md.
+ */
+export default async function PersonnelExportPage({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}) {
+ const user = await requirePermission('personnel.read')
+ const { id } = await params
+
+ const akte = await loadVisiblePersonnelFile(user.permissions, id)
+ if (!akte) notFound()
+
+ // Nur Stufen anbieten, die der Abrufer selbst hat. Eine Auswahl, die er
+ // ohnehin nicht bekommt, wäre eine Falle - und der Dienst deckelt sie später
+ // stillschweigend weg.
+ const stufen = await db.clearanceLevelConfig.findMany({
+ where: user.permissions.isAdmin ? {} : { level: { lte: user.permissions.clearanceLevel } },
+ orderBy: { level: 'asc' },
+ select: { level: true, name: true },
+ })
+
+ return (
+
+
{de.export.dossierHeading}
+
{de.export.dossierIntro}
+
+
+
+
+
+
+
+ {de.export.print}
+
+
+
+ )
+}
diff --git a/web/app/(app)/personalakten/[id]/page.tsx b/web/app/(app)/personalakten/[id]/page.tsx
index c2220c5..2b33506 100644
--- a/web/app/(app)/personalakten/[id]/page.tsx
+++ b/web/app/(app)/personalakten/[id]/page.tsx
@@ -234,7 +234,14 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
{de.personnel.one}
- {akte.displayName}
+
+
{akte.displayName}
+ {/* Der Abzug ist kein Nebenweg der Akte, sondern eine eigene Seite:
+ die Wahl der Höchststufe will erklärt werden. */}
+
+ {de.export.dossier}
+
+
diff --git a/web/app/(print)/abteilungen/[key]/druck/page.tsx b/web/app/(print)/abteilungen/[key]/druck/page.tsx
new file mode 100644
index 0000000..67f6cee
--- /dev/null
+++ b/web/app/(print)/abteilungen/[key]/druck/page.tsx
@@ -0,0 +1,171 @@
+import type { Metadata } from 'next'
+import { db } from '@/lib/db'
+import { visibleDepartmentEntryWhere } from '@/lib/access/departmentFilter'
+import { permissionsForPrint } from '@/lib/export/printAccess'
+import {
+ departmentMembers,
+ effectiveCap,
+ loadVisibleDepartmentForPrint,
+} from '@/lib/export/dossier'
+import { DossierBody } from '@/components/print/DossierBody'
+import { de } from '@/lib/i18n/de'
+import { NotAvailable } from '../../../NotAvailable'
+
+export const dynamic = 'force-dynamic'
+
+export const metadata: Metadata = {
+ title: de.export.dossier,
+ robots: { index: false, follow: false },
+}
+
+/**
+ * Druckansicht einer Abteilung: die Abteilung selbst, dahinter je Mitglied ein
+ * vollständiges Dossier.
+ *
+ * Getrennt nach Mitgliedern und nicht als ein grosser Topf: eine Abteilung hat
+ * keine eigenen Fallakten, sie hat Mitglieder, die in Fallakten vorkommen.
+ * Alles ineinandergeschüttet stünde dieselbe Akte mehrfach da, ohne dass man
+ * sähe, über wen sie hier steht.
+ *
+ * Das wird umfangreich. Genau deshalb ist die Höchststufe hier keine Zierde.
+ */
+export default async function PrintDepartmentDossierPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ key: string }>
+ searchParams: Promise<{ token?: string; stufe?: string }>
+}) {
+ const { key } = await params
+ const { token, stufe } = await searchParams
+
+ const zugang = await permissionsForPrint(token)
+ if (!zugang) return
+ const { permissions, displayName } = zugang
+
+ const abteilung = await loadVisibleDepartmentForPrint(permissions, key)
+ if (!abteilung) return
+
+ const cap = effectiveCap(permissions, Number(stufe ?? permissions.clearanceLevel))
+
+ const [{ members, omittedMembers }, vermerke, levels, autoren] = await Promise.all([
+ departmentMembers(permissions, abteilung.id, cap),
+ db.departmentEntry.findMany({
+ where: {
+ AND: [visibleDepartmentEntryWhere(permissions), { departmentId: abteilung.id }],
+ },
+ orderBy: [{ createdAt: 'asc' }],
+ select: {
+ id: true,
+ body: true,
+ minClearance: true,
+ authorId: true,
+ createdAt: true,
+ version: true,
+ editedAt: true,
+ retractedAt: true,
+ retractionReason: true,
+ },
+ }),
+ db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
+ db.user.findMany({ select: { id: true, displayName: true } }),
+ ])
+
+ const levelName = new Map(levels.map((l) => [l.level, l.name]))
+ const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
+
+ return (
+
+
+
+ {abteilung.minClearance} ·{' '}
+ {levelName.get(abteilung.minClearance) ?? de.common.unknown}
+
+ {abteilung.name}
+ {abteilung.key}
+
+
+
+
+ {de.department.members}
+ {de.department.memberCount(members.length)}
+
+
+ {de.export.retrievedBy}
+ {displayName}
+
+
+ {de.export.asOf}
+
+ {new Date().toLocaleString('de-DE')} ·{' '}
+ {de.export.cappedAt(cap, levelName.get(cap) ?? '')}
+
+
+
+
+
+ {abteilung.description ? (
+ {abteilung.description}
+ ) : null}
+
+
+ {de.export.coverNote}
+
+
+
+ {omittedMembers > 0 ? (
+
+ {de.export.omittedHeading}
+
+ {de.export.omittedMembers(omittedMembers)}
+
+
+ ) : null}
+
+ {de.department.entries}
+ {vermerke.length === 0 ? (
+ {de.department.entriesEmpty}
+ ) : (
+ vermerke.map((vermerk) => (
+
+
+ {nameVon.get(vermerk.authorId) ?? vermerk.authorId} ·{' '}
+ {vermerk.createdAt.toLocaleString('de-DE')}
+ {vermerk.editedAt
+ ? ` · ${de.export.editedAt(vermerk.version, vermerk.editedAt.toLocaleDateString('de-DE'))}`
+ : ''}
+
+ {vermerk.retractedAt ? (
+
+ {de.entry.retracted(
+ vermerk.retractedAt.toLocaleDateString('de-DE'),
+ nameVon.get(vermerk.authorId) ?? vermerk.authorId,
+ vermerk.retractionReason ?? '',
+ )}
+
+ ) : (
+
{vermerk.body}
+ )}
+
+ ))
+ )}
+
+ {de.export.membersHeading}
+ {members.length === 0 ? (
+ {de.export.noMembers}
+ ) : (
+ members.map((mitglied) => (
+
+ ))
+ )}
+
+ )
+}
diff --git a/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx
index de52415..e957c9d 100644
--- a/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx
+++ b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx
@@ -1,13 +1,8 @@
import type { Metadata } from 'next'
-import { db } from '@/lib/db'
-import { loadDocumentSections } from '@/lib/documents/service'
import { loadVisibleDocument } from '@/lib/documents/service'
-import { resolvePhotoUrls } from '@/lib/files/service'
import { permissionsForPrint } from '@/lib/export/printAccess'
-import { Sheet } from '@/components/documents/Sheet'
-import { de } from '@/lib/i18n/de'
+import { DocumentSheet } from '@/components/print/DocumentSheet'
import { NotAvailable } from '../../../NotAvailable'
-import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
export const dynamic = 'force-dynamic'
@@ -21,11 +16,8 @@ export async function generateMetadata({
}
/**
- * Druckansicht eines einzelnen Dokuments.
- *
- * Kein Verlauf, keine Kopfdatentabelle einer Akte - das Dokument selbst, so
- * wie seine Vorlage es vorsieht. Ein Aktenblatt kommt als Aktenblatt heraus,
- * ein Vertrag als Vertrag. Siehe docs/08-suche-und-export.md.
+ * Druckansicht eines einzelnen Dokuments. Der Bogen selbst steht in
+ * `components/print/DocumentSheet`, weil ihn auch das Dossier braucht.
*/
export default async function PrintDocumentPage({
params,
@@ -39,142 +31,17 @@ export default async function PrintDocumentPage({
const zugang = await permissionsForPrint(token)
if (!zugang) return
- const { permissions, displayName } = zugang
- const doc = await loadVisibleDocument(permissions, documentNumber)
+ const doc = await loadVisibleDocument(zugang.permissions, documentNumber)
if (!doc) return
- const [sections, template, foundationLevels, levels] = await Promise.all([
- loadDocumentSections(permissions, doc),
- db.documentTemplate.findUnique({
- where: { id: doc.templateId },
- select: { sheetTitle: true, sheetSubtitle: true, name: true },
- }),
- db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
- db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
- ])
-
- const photoUrls = await resolvePhotoUrls(
- permissions,
- sections.map((section) => ({
- fieldKey: section.fieldKey,
- visible: section.visible,
- value: section.visible ? section.value : null,
- type: section.field.type,
- })),
- )
-
- const levelName = new Map(levels.map((l) => [l.level, l.name]))
- const werte = Object.fromEntries(
- sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
- )
-
- // Das Aktenblatt bringt sein eigenes Aussehen mit und braucht keinen
- // Berichtsrahmen darum.
- if (doc.layout === 'DOSSIER') {
- return (
-
- )
- }
-
return (
-
-
- {doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
-
- {doc.title}
-
- {doc.documentNumber}
-
-
-
-
-
- {de.case.status}
- {de.document.statusNames[doc.status] ?? doc.status}
-
-
- {de.document.kind}
- {doc.templateKey}
-
-
- {de.export.retrievedBy}
- {displayName}
-
-
- {de.export.asOf}
-
- {new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '}
- {permissions.clearanceLevel}
-
-
-
-
-
-
- {doc.retractedAt ? (
-
- {de.document.statusNames.RETRACTED}
-
- ) : null}
-
- {sections.map((section) => {
- const field = section.field
-
- if (!isInputField(field)) {
- if (field.type === 'pagebreak') {
- return
- }
- const text = fillPlaceholders(field.text ?? '', werte)
- return field.type === 'heading' ? (
- {text || field.label}
- ) : (
-
- {text}
-
- )
- }
-
- if (field.type === 'signature') {
- return (
-
-
-
{fillPlaceholders(field.label, werte)}
-
- )
- }
-
- return (
-
-
{field.label}
- {!section.visible ? (
-
- {' '}
-
- {de.export.redactedAt(
- section.value.requiredLevel,
- levelName.get(section.value.requiredLevel) ?? '',
- )}
-
-
- ) : (
-
- {String(section.value ?? '-')}
-
- )}
-
- )
- })}
+
)
}
diff --git a/web/app/(print)/faelle/[caseNumber]/druck/page.tsx b/web/app/(print)/faelle/[caseNumber]/druck/page.tsx
index 9dc190d..980ba72 100644
--- a/web/app/(print)/faelle/[caseNumber]/druck/page.tsx
+++ b/web/app/(print)/faelle/[caseNumber]/druck/page.tsx
@@ -1,14 +1,8 @@
import type { Metadata } from 'next'
-import { db } from '@/lib/db'
-import { visibleEntryWhere } from '@/lib/access/caseFilter'
-import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { loadVisibleCase } from '@/lib/cases/service'
-import { loadSections } from '@/lib/cases/sectionService'
import { permissionsForPrint } from '@/lib/export/printAccess'
-import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
-import { de } from '@/lib/i18n/de'
+import { CaseSheet } from '@/components/print/CaseSheet'
import { NotAvailable } from '../../../NotAvailable'
-import type { RenderedSection } from '@/lib/sections/render'
export const dynamic = 'force-dynamic'
@@ -26,11 +20,8 @@ export async function generateMetadata({
*
* Sie ist die einzige Vorlage für den PDF-Export: kein zweites Layout von Hand
* in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite auf.
- *
- * **Ein Export darf nie sauberer aussehen als die Akte ist.** Deshalb:
- * gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen,
- * geänderte Einträge sind als geändert gekennzeichnet, zurückgezogene stehen
- * als Grabstein mit Begründung. Siehe docs/08-suche-und-export.md.
+ * Der Bogen selbst steht in `components/print/CaseSheet`, weil ihn auch das
+ * Dossier einer Person und der Bogen einer Abteilung brauchen.
*/
export default async function PrintCasePage({
params,
@@ -44,224 +35,17 @@ export default async function PrintCasePage({
const zugang = await permissionsForPrint(token)
if (!zugang) return
- const { permissions, displayName } = zugang
- const akte = await loadVisibleCase(permissions, caseNumber)
+ const akte = await loadVisibleCase(zugang.permissions, caseNumber)
if (!akte) return
- const [sections, entries, anhaenge, levels, autoren] = await Promise.all([
- loadSections(permissions, akte.id, akte.templateId),
- db.caseEntry.findMany({
- where: { AND: [visibleEntryWhere(permissions), { caseId: akte.id }] },
- orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
- select: {
- id: true,
- type: true,
- body: true,
- authorId: true,
- createdAt: true,
- version: true,
- editedAt: true,
- editVisible: true,
- retractedAt: true,
- retractionReason: true,
- },
- }),
- db.attachment.findMany({
- where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
- orderBy: { createdAt: 'asc' },
- select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
- }),
- db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
- db.user.findMany({ select: { id: true, displayName: true } }),
- ])
-
- const levelName = new Map(levels.map((l) => [l.level, l.name]))
- const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
- const stand = new Date()
-
return (
-
-
- {akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
-
- {akte.title}
- {akte.caseNumber}
-
-
-
-
- {de.export.coverNote}
-
-
-
- {de.section.heading}
-
-
- {de.case.timeline}
- {entries.length === 0 ? (
- {de.entry.empty}
- ) : (
- entries.map((eintrag) => (
-
-
- {de.entry.types[eintrag.type] ?? eintrag.type} ·{' '}
- {nameVon.get(eintrag.authorId) ?? eintrag.authorId} ·{' '}
- {eintrag.createdAt.toLocaleString('de-DE')}
- {eintrag.editVisible && eintrag.editedAt
- ? ` · ${de.export.editedAt(eintrag.version, eintrag.editedAt.toLocaleDateString('de-DE'))}`
- : ''}
-
- {eintrag.retractedAt ? (
-
- {de.entry.retracted(
- eintrag.retractedAt.toLocaleDateString('de-DE'),
- nameVon.get(eintrag.authorId) ?? eintrag.authorId,
- eintrag.retractionReason ?? '',
- )}
-
- ) : (
-
{eintrag.body}
- )}
-
- ))
- )}
-
- {de.attachment.heading}
- {anhaenge.length === 0 ? (
- {de.attachment.empty}
- ) : (
-
-
-
- {de.export.fileName}
- {de.export.fileSize}
- {de.export.fileHash}
-
-
-
- {anhaenge.map((anhang) => (
-
- {anhang.originalName}
- {anhang.sizeBytes}
- {/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
- gehört - deshalb steht er im Verzeichnis. */}
-
- {anhang.sha256}
-
-
- ))}
-
-
- )}
+
)
}
-
-function Zeile({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- )
-}
-
-/**
- * Die Abschnitte im Druck. Ein gesperrtes Feld wird als Balken ausgegeben, mit
- * dem Vermerk, ab welcher Stufe es lesbar wäre - sonst merkt der Empfänger
- * nicht, dass er eine gekürzte Fassung in der Hand hält.
- */
-function PrintSections({
- sections,
- levelName,
-}: {
- sections: RenderedSection[]
- levelName: Map
-}) {
- const werte = Object.fromEntries(
- sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
- )
-
- return (
-
- {sections.map((section) => {
- const field = section.field
-
- if (!isInputField(field)) {
- if (field.type === 'pagebreak') {
- return
- }
- const text = fillPlaceholders(field.text ?? '', werte)
- return field.type === 'heading' ? (
-
{text || field.label}
- ) : (
-
- {text}
-
- )
- }
-
- return (
-
-
{field.label}
- {!section.visible ? (
-
- {' '}
-
- {de.export.redactedAt(
- section.value.requiredLevel,
- levelName.get(section.value.requiredLevel) ?? '',
- )}
-
-
- ) : section.segments ? (
-
- {section.segments.map((segment, index) =>
- segment.kind === 'text' ? (
- {segment.text}
- ) : (
- // Balken statt Auslassung, mit der Stufe daneben: der
- // Empfaenger soll sehen, dass hier etwas fehlt.
-
-
-
-
- [{segment.requiredLevel}]
-
- ),
- )}
-
- ) : (
-
- {String(section.value ?? '-')}
-
- )}
-
- )
- })}
-
- )
-}
diff --git a/web/app/(print)/personalakten/[id]/druck/page.tsx b/web/app/(print)/personalakten/[id]/druck/page.tsx
new file mode 100644
index 0000000..d072a88
--- /dev/null
+++ b/web/app/(print)/personalakten/[id]/druck/page.tsx
@@ -0,0 +1,71 @@
+import type { Metadata } from 'next'
+import { db } from '@/lib/db'
+import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
+import { permissionsForPrint } from '@/lib/export/printAccess'
+import { effectiveCap } from '@/lib/export/dossier'
+import { DossierBody } from '@/components/print/DossierBody'
+import { de } from '@/lib/i18n/de'
+import { NotAvailable } from '../../../NotAvailable'
+
+export const dynamic = 'force-dynamic'
+
+export const metadata: Metadata = {
+ title: de.export.dossier,
+ robots: { index: false, follow: false },
+}
+
+/**
+ * Druckansicht einer Personalakte als vollständiges Dossier.
+ *
+ * Alles in **einer** Seite und nicht als mehrere zusammengeheftete PDFs: die
+ * Druckansicht ist ohnehin die einzige Vorlage, und so laufen Seitenzahlen,
+ * Wasserzeichen und Kopfzeile durch. Ein PDF-Zusammenfüger wäre ein zweiter
+ * Weg, der irgendwann anders aussieht als der erste.
+ *
+ * `stufe` deckelt, was aufgenommen wird. Der Wert wird hier **erneut** auf die
+ * Stufe des Abrufers gedeckelt und nicht bloss übernommen: er steht in der
+ * Adresse, und eine Adresse ist eine Eingabe.
+ */
+export default async function PrintPersonnelDossierPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string }>
+ searchParams: Promise<{ token?: string; stufe?: string }>
+}) {
+ const { id } = await params
+ const { token, stufe } = await searchParams
+
+ const zugang = await permissionsForPrint(token)
+ if (!zugang) return
+ const { permissions, displayName } = zugang
+
+ const akte = await db.personnelFile.findFirst({
+ where: { AND: [visiblePersonnelWhere(permissions), { id }] },
+ select: {
+ id: true,
+ userId: true,
+ displayName: true,
+ rank: true,
+ classification: true,
+ statusTags: true,
+ note: true,
+ createdAt: true,
+ department: { select: { key: true, name: true } },
+ },
+ })
+ if (!akte) return
+
+ const cap = effectiveCap(permissions, Number(stufe ?? permissions.clearanceLevel))
+
+ return (
+
+
+
+ )
+}
diff --git a/web/app/globals.css b/web/app/globals.css
index 401eaa2..4579f7a 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -929,6 +929,25 @@
color: #444;
}
+ /* Der Kopf einer Akte **innerhalb** eines Dossiers. Kein voller Deckel: der
+ steht vorn, einer je Abzug. Hier genügt eine Marke, die sagt, was jetzt
+ anfängt. */
+ .print-runin {
+ border-top: 2pt solid #000;
+ border-bottom: 0.75pt solid #000;
+ padding: 3mm 0;
+ margin: 0 0 4mm;
+ break-after: avoid;
+ }
+
+ /* Was der Abrufer bewusst weggelassen hat. Auffällig, weil eine gekürzte
+ Mappe nicht wie eine vollständige aussehen darf - siehe docs/08. */
+ .print-omitted {
+ border: 1.5pt dashed #000;
+ padding: 3mm 4mm;
+ margin: 4mm 0;
+ }
+
/* Die Schwärzung im Druck: ein voller Balken, kein Weglassen. */
.print-redacted {
display: inline-block;
diff --git a/web/components/export/ExportPanel.tsx b/web/components/export/ExportPanel.tsx
index aa5065f..6a68f5e 100644
--- a/web/components/export/ExportPanel.tsx
+++ b/web/components/export/ExportPanel.tsx
@@ -13,13 +13,27 @@ import { requestExportAction, type ExportState } from '@/app/(app)/export/action
* der Knopf zum Herunterladen bereit - automatisch losladen wäre unhöflich,
* wenn jemand die Seite inzwischen wieder verlassen hat und zurückkommt.
*/
+export type ExportPanelTarget =
+ | { kind: 'CASE'; caseNumber: string }
+ | { kind: 'DOCUMENT'; documentNumber: string }
+ | { kind: 'PERSONNEL'; personnelFileId: string }
+ | { kind: 'DEPARTMENT'; departmentKey: string }
+
export function ExportPanel({
target,
topic,
+ levels,
+ defaultLevel,
}: {
- target: { kind: 'CASE'; caseNumber: string } | { kind: 'DOCUMENT'; documentNumber: string }
+ target: ExportPanelTarget
/** Eigenes Nutzerthema: der Export geht nur den Auftraggeber an. */
topic: string
+ /**
+ * Wählbare Höchststufen. Nur beim Dossier gesetzt - eine einzelne Akte hat
+ * genau eine Stufe, da gäbe es nichts zu wählen.
+ */
+ levels?: { level: number; name: string }[]
+ defaultLevel?: number
}) {
const [state, action, pending] = useActionState(
requestExportAction,
@@ -51,10 +65,35 @@ export function ExportPanel({
{target.kind === 'CASE' ? (
- ) : (
+ ) : target.kind === 'DOCUMENT' ? (
+ ) : target.kind === 'PERSONNEL' ? (
+
+ ) : (
+
)}
+ {levels ? (
+
+
+ {exportText.maxLevel}
+
+
+ {levels.map((stufe) => (
+
+ {stufe.level} · {stufe.name}
+
+ ))}
+
+
{exportText.maxLevelHint}
+
+ ) : null}
+
+}) {
+ const [sections, entries, anhaenge, levels, autoren] = await Promise.all([
+ loadSections(permissions, akte.id, akte.templateId),
+ db.caseEntry.findMany({
+ where: { AND: [visibleEntryWhere(permissions), { caseId: akte.id }] },
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ select: {
+ id: true,
+ type: true,
+ body: true,
+ authorId: true,
+ createdAt: true,
+ version: true,
+ editedAt: true,
+ editVisible: true,
+ retractedAt: true,
+ retractionReason: true,
+ },
+ }),
+ db.attachment.findMany({
+ where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
+ orderBy: { createdAt: 'asc' },
+ select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
+ }),
+ db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
+ authors ? [] : db.user.findMany({ select: { id: true, displayName: true } }),
+ ])
+
+ const levelName = new Map(levels.map((l) => [l.level, l.name]))
+ const nameVon = authors ?? new Map(autoren.map((a) => [a.id, a.displayName]))
+ const stand = new Date()
+
+ return (
+ <>
+ {withCover ? (
+
+
+ {akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
+
+ {akte.title}
+ {akte.caseNumber}
+
+
+
+
+ {de.export.coverNote}
+
+
+ ) : (
+
+ )}
+
+ {de.section.heading}
+
+
+ {de.case.timeline}
+ {entries.length === 0 ? (
+ {de.entry.empty}
+ ) : (
+ entries.map((eintrag) => (
+
+
+ {de.entry.types[eintrag.type] ?? eintrag.type} ·{' '}
+ {nameVon.get(eintrag.authorId) ?? eintrag.authorId} ·{' '}
+ {eintrag.createdAt.toLocaleString('de-DE')}
+ {eintrag.editVisible && eintrag.editedAt
+ ? ` · ${de.export.editedAt(eintrag.version, eintrag.editedAt.toLocaleDateString('de-DE'))}`
+ : ''}
+
+ {eintrag.retractedAt ? (
+
+ {de.entry.retracted(
+ eintrag.retractedAt.toLocaleDateString('de-DE'),
+ nameVon.get(eintrag.authorId) ?? eintrag.authorId,
+ eintrag.retractionReason ?? '',
+ )}
+
+ ) : (
+
{eintrag.body}
+ )}
+
+ ))
+ )}
+
+ {de.attachment.heading}
+ {anhaenge.length === 0 ? (
+ {de.attachment.empty}
+ ) : (
+
+
+
+ {de.export.fileName}
+ {de.export.fileSize}
+ {de.export.fileHash}
+
+
+
+ {anhaenge.map((anhang) => (
+
+ {anhang.originalName}
+ {anhang.sizeBytes}
+ {/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
+ gehört - deshalb steht er im Verzeichnis. */}
+
+ {anhang.sha256}
+
+
+ ))}
+
+
+ )}
+ >
+ )
+}
+
+function Zeile({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+/**
+ * Die Abschnitte im Druck. Ein gesperrtes Feld wird als Balken ausgegeben, mit
+ * dem Vermerk, ab welcher Stufe es lesbar wäre - sonst merkt der Empfänger
+ * nicht, dass er eine gekürzte Fassung in der Hand hält.
+ */
+function PrintSections({
+ sections,
+ levelName,
+}: {
+ sections: RenderedSection[]
+ levelName: Map
+}) {
+ const werte = Object.fromEntries(
+ sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
+ )
+
+ return (
+
+ {sections.map((section) => {
+ const field = section.field
+
+ if (!isInputField(field)) {
+ if (field.type === 'pagebreak') {
+ return
+ }
+ const text = fillPlaceholders(field.text ?? '', werte)
+ return field.type === 'heading' ? (
+
{text || field.label}
+ ) : (
+
+ {text}
+
+ )
+ }
+
+ return (
+
+
{field.label}
+ {!section.visible ? (
+
+ {' '}
+
+ {de.export.redactedAt(
+ section.value.requiredLevel,
+ levelName.get(section.value.requiredLevel) ?? '',
+ )}
+
+
+ ) : section.segments ? (
+
+ {section.segments.map((segment, index) =>
+ segment.kind === 'text' ? (
+ {segment.text}
+ ) : (
+ // Balken statt Auslassung, mit der Stufe daneben: der
+ // Empfaenger soll sehen, dass hier etwas fehlt.
+
+
+
+
+ [{segment.requiredLevel}]
+
+ ),
+ )}
+
+ ) : (
+
+ {String(section.value ?? '-')}
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/web/components/print/DocumentSheet.tsx b/web/components/print/DocumentSheet.tsx
new file mode 100644
index 0000000..4e519e3
--- /dev/null
+++ b/web/components/print/DocumentSheet.tsx
@@ -0,0 +1,189 @@
+import { db } from '@/lib/db'
+import { loadDocumentSections } from '@/lib/documents/service'
+import { resolvePhotoUrls } from '@/lib/files/service'
+import { Sheet } from '@/components/documents/Sheet'
+import { de } from '@/lib/i18n/de'
+import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Der Druckbogen eines Dokuments, ohne die Seite drumherum.
+ *
+ * Kein Verlauf, keine Kopfdatentabelle einer Akte - das Dokument selbst, so
+ * wie seine Vorlage es vorsieht. Ein Aktenblatt kommt als Aktenblatt heraus,
+ * ein Vertrag als Vertrag. Siehe docs/08-suche-und-export.md.
+ *
+ * Ausgelagert aus derselben Not wie CaseSheet: das Dossier einer Person hängt
+ * alle Dokumente hintereinander, und zwar in genau dieser Darstellung.
+ */
+export type DocumentForPrint = {
+ id: string
+ documentNumber: string
+ title: string
+ templateId: string
+ templateKey: string
+ layout: string
+ status: string
+ classification: number
+ createdAt: Date
+ retractedAt: Date | null
+}
+
+export async function DocumentSheet({
+ permissions,
+ displayName,
+ doc,
+ /** Im Dossier steht der Deckel schon vorn; dann nur der Inhalt. */
+ withCover = true,
+}: {
+ permissions: EffectivePermissions
+ displayName: string
+ doc: DocumentForPrint
+ withCover?: boolean
+}) {
+ const [sections, template, foundationLevels, levels] = await Promise.all([
+ loadDocumentSections(permissions, doc),
+ db.documentTemplate.findUnique({
+ where: { id: doc.templateId },
+ select: { sheetTitle: true, sheetSubtitle: true, name: true },
+ }),
+ db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
+ db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
+ ])
+
+ const photoUrls = await resolvePhotoUrls(
+ permissions,
+ sections.map((section) => ({
+ fieldKey: section.fieldKey,
+ visible: section.visible,
+ value: section.visible ? section.value : null,
+ type: section.field.type,
+ })),
+ )
+
+ const levelName = new Map(levels.map((l) => [l.level, l.name]))
+ const werte = Object.fromEntries(
+ sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
+ )
+
+ // Das Aktenblatt bringt sein eigenes Aussehen mit und braucht keinen
+ // Berichtsrahmen darum.
+ if (doc.layout === 'DOSSIER') {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+ {withCover ? (
+
+
+ {doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
+
+ {doc.title}
+
+ {doc.documentNumber}
+
+
+
+
+
+ {de.case.status}
+ {de.document.statusNames[doc.status] ?? doc.status}
+
+
+ {de.document.kind}
+ {doc.templateKey}
+
+
+ {de.export.retrievedBy}
+ {displayName}
+
+
+ {de.export.asOf}
+
+ {new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '}
+ {permissions.clearanceLevel}
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {doc.retractedAt ? (
+
+ {de.document.statusNames.RETRACTED}
+
+ ) : null}
+
+ {sections.map((section) => {
+ const field = section.field
+
+ if (!isInputField(field)) {
+ if (field.type === 'pagebreak') {
+ return
+ }
+ const text = fillPlaceholders(field.text ?? '', werte)
+ return field.type === 'heading' ? (
+ {text || field.label}
+ ) : (
+
+ {text}
+
+ )
+ }
+
+ if (field.type === 'signature') {
+ return (
+
+
+
{fillPlaceholders(field.label, werte)}
+
+ )
+ }
+
+ return (
+
+
{field.label}
+ {!section.visible ? (
+
+ {' '}
+
+ {de.export.redactedAt(
+ section.value.requiredLevel,
+ levelName.get(section.value.requiredLevel) ?? '',
+ )}
+
+
+ ) : (
+
+ {String(section.value ?? '-')}
+
+ )}
+
+ )
+ })}
+ >
+ )
+}
diff --git a/web/components/print/DossierBody.tsx b/web/components/print/DossierBody.tsx
new file mode 100644
index 0000000..8befab4
--- /dev/null
+++ b/web/components/print/DossierBody.tsx
@@ -0,0 +1,133 @@
+import { db } from '@/lib/db'
+import { de } from '@/lib/i18n/de'
+import { collectPersonnelDossier, type Dossier } from '@/lib/export/dossier'
+import { CaseSheet } from '@/components/print/CaseSheet'
+import { DocumentSheet } from '@/components/print/DocumentSheet'
+import { PersonnelSheet, type PersonnelForPrint } from '@/components/print/PersonnelSheet'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Ein Dossier: die Personalakte, dahinter ihre Dokumente, dahinter die
+ * Fallakten, in denen die Person vorkommt.
+ *
+ * Die Reihenfolge ist die Vorgabe und keine Geschmacksfrage - wer eine Akte
+ * aufschlägt, will zuerst wissen, um wen es geht.
+ *
+ * Der Vermerk über das Weggelassene steht **vorn**, gleich hinter der Akte,
+ * nicht am Ende: wer die letzten Seiten nicht liest, muss trotzdem erfahren,
+ * dass die Mappe gekürzt ist.
+ */
+export async function DossierBody({
+ permissions,
+ displayName,
+ akte,
+ cap,
+ /** Im Abteilungsbogen steht der Deckel schon vorn. */
+ withCover = true,
+}: {
+ permissions: EffectivePermissions
+ displayName: string
+ akte: PersonnelForPrint
+ cap: number
+ withCover?: boolean
+}) {
+ const [inhalt, autoren] = await Promise.all([
+ collectPersonnelDossier(permissions, { id: akte.id, userId: akte.userId }, cap),
+ db.user.findMany({ select: { id: true, displayName: true } }),
+ ])
+ const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
+
+ return (
+ <>
+
+
+
+
+ {de.export.documentsHeading}
+ {inhalt.documents.length === 0 ? (
+ {de.export.noDocuments}
+ ) : (
+ inhalt.documents.map((doc, index) => (
+
+ ))
+ )}
+
+ {de.export.casesHeading}
+ {inhalt.cases.length === 0 ? (
+ {de.export.noCases}
+ ) : (
+ inhalt.cases.map((akte, index) => (
+
+ ))
+ )}
+ >
+ )
+}
+
+/**
+ * Was der Abrufer hätte sehen dürfen und selbst ausgeschlossen hat.
+ *
+ * Steht bewusst da, auch wenn nichts weggelassen wurde - dann als leere
+ * Aussage: "nichts weggelassen" ist eine Information. Nur was **über** der
+ * Stufe des Abrufers liegt, taucht hier nie auf; das existiert für ihn nicht.
+ */
+export function OmittedNote({
+ inhalt,
+ eigeneStufe,
+}: {
+ inhalt: Pick
+ eigeneStufe: number
+}) {
+ if (inhalt.omittedTotal === 0 && !inhalt.truncated) return null
+
+ return (
+
+ {de.export.omittedHeading}
+ {inhalt.omittedTotal > 0 ? (
+ <>
+
+ {de.export.omittedNote(eigeneStufe)}
+
+
+ {inhalt.omitted.map((zeile) => (
+
+ {de.export.omittedRow(zeile.level, zeile.documents, zeile.cases)}
+
+ ))}
+
+ >
+ ) : null}
+ {inhalt.truncated ? (
+
+ {de.export.truncated}
+
+ ) : null}
+
+ )
+}
diff --git a/web/components/print/PersonnelSheet.tsx b/web/components/print/PersonnelSheet.tsx
new file mode 100644
index 0000000..ee16b87
--- /dev/null
+++ b/web/components/print/PersonnelSheet.tsx
@@ -0,0 +1,124 @@
+import { db } from '@/lib/db'
+import { de } from '@/lib/i18n/de'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Der Druckbogen einer Personalakte: die Kopfdaten selbst.
+ *
+ * Die Personalakte trägt keine Vorlage mit Abschnitten wie eine Fallakte - was
+ * über die Person aktenkundig ist, steht in ihren Dokumenten. Deshalb ist
+ * dieser Bogen kurz; das Gewicht des Dossiers liegt in dem, was dahinter
+ * kommt. Siehe lib/export/dossier.ts.
+ */
+export type PersonnelForPrint = {
+ id: string
+ userId: string | null
+ displayName: string
+ rank: string | null
+ classification: number
+ statusTags: string[]
+ note: string | null
+ department: { key: string; name: string } | null
+ createdAt: Date
+}
+
+export async function PersonnelSheet({
+ permissions,
+ displayName,
+ akte,
+ withCover = true,
+}: {
+ permissions: EffectivePermissions
+ displayName: string
+ akte: PersonnelForPrint
+ withCover?: boolean
+}) {
+ const [levels, etiketten] = await Promise.all([
+ db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
+ akte.statusTags.length === 0
+ ? []
+ : db.personnelStatusTag.findMany({
+ where: { key: { in: akte.statusTags } },
+ orderBy: { sortOrder: 'asc' },
+ select: { key: true, name: true },
+ }),
+ ])
+
+ const levelName = new Map(levels.map((l) => [l.level, l.name]))
+ const stufe = `${akte.classification} · ${levelName.get(akte.classification) ?? de.common.unknown}`
+
+ return (
+ <>
+ {withCover ? (
+
+ {stufe}
+
+ {akte.rank ? `[${akte.rank}] ` : ''}
+ {akte.displayName}
+
+
+ {de.personnel.one}
+
+
+
+
+
+ {de.export.coverNote}
+
+
+ ) : (
+
+ )}
+
+
+
+ tag.name).join(', ')
+ }
+ />
+
+
+
+
+ >
+ )
+}
+
+function Zeile({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/web/lib/export/cacheKey.ts b/web/lib/export/cacheKey.ts
index 6c3e990..db64cad 100644
--- a/web/lib/export/cacheKey.ts
+++ b/web/lib/export/cacheKey.ts
@@ -9,7 +9,14 @@ import { createHash } from 'node:crypto'
* Dokument geben, das für eine höhere Stufe gerendert wurde.
*
* Der Stand (`updatedAt`) sorgt dafür, dass eine geänderte Akte neu gerendert
- * wird - ein Export darf nie einen älteren Stand zeigen, als die Akte hat.
+ * wird - ein Export darf nie einen älteren Stand zeigen, als die Akte hat. Bei
+ * einem Dossier ist das der **späteste** Stand über alle enthaltenen Akten,
+ * siehe lib/export/dossier.ts.
+ *
+ * `maxLevel` gehört aus demselben Grund hinein wie die Stufe: zwei Abzüge mit
+ * verschiedenen Deckeln sind verschiedene Dateien, und ein Treffer im
+ * Zwischenspeicher darf keinen ungedeckelten Abzug für einen gedeckelten
+ * ausgeben.
*/
export function exportCacheKey(input: {
kind: string
@@ -17,6 +24,7 @@ export function exportCacheKey(input: {
updatedAt: Date
clearanceLevel: number
templateVersion: number | string
+ maxLevel?: number | null
}): string {
return createHash('sha256')
.update(
@@ -26,6 +34,7 @@ export function exportCacheKey(input: {
input.updatedAt.toISOString(),
String(input.clearanceLevel),
String(input.templateVersion),
+ String(input.maxLevel ?? ''),
].join('|'),
)
.digest('hex')
diff --git a/web/lib/export/dossier.ts b/web/lib/export/dossier.ts
new file mode 100644
index 0000000..4a2c426
--- /dev/null
+++ b/web/lib/export/dossier.ts
@@ -0,0 +1,327 @@
+import type { Prisma } from '@afis/db'
+import { db } from '@/lib/db'
+import { visibleCaseWhere } from '@/lib/access/caseFilter'
+import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter'
+import {
+ visibleDepartmentEntryWhere,
+ visibleDepartmentWhere,
+} from '@/lib/access/departmentFilter'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+import type { CaseForPrint } from '@/components/print/CaseSheet'
+import type { DocumentForPrint } from '@/components/print/DocumentSheet'
+
+/**
+ * Was in ein Dossier gehört.
+ *
+ * Ein Dossier ist die Personalakte einer Person, dahinter alle ihre Dokumente
+ * und dahinter alle Fallakten, in denen sie vorkommt - alles in derselben
+ * Druckdarstellung wie einzeln. Für eine Abteilung dasselbe, je Mitglied
+ * getrennt.
+ *
+ * Zwei Grenzen wirken hier, und sie sind **nicht** dasselbe:
+ *
+ * 1. **Die Freigabestufe des Abrufers.** Was darüber liegt, existiert für ihn
+ * nicht: es steht in keiner Liste, in keiner Zählung und in keinem
+ * Hinweis. Das ist harte Regel 6, und sie gilt hier wie überall.
+ * 2. **Die gewählte Höchststufe des Exports.** Das ist eine Entscheidung des
+ * Abrufers, kein Schutz vor ihm. Was er dadurch weglässt, **muss im PDF
+ * stehen** - sonst hält der Empfänger eine gekürzte Mappe für eine
+ * vollständige. Deshalb zählt `omitted` mit, und deshalb zählt es nur,
+ * was der Abrufer selbst sehen dürfte.
+ *
+ * Beide laufen in der Datenbankabfrage, nicht danach in JavaScript.
+ */
+
+/**
+ * Obergrenze je Abschnitt. Bei dieser Nutzerzahl unerreichbar; sie steht hier,
+ * damit ein Abzug nicht unbemerkt ins Unendliche wächst. Wird sie erreicht,
+ * sagt das PDF es - abgeschnitten und stillschweigend wäre das Schlimmste.
+ */
+export const DOSSIER_LIMIT = 250
+
+export type OmittedCount = { level: number; documents: number; cases: number }
+
+export type Dossier = {
+ documents: DocumentForPrint[]
+ cases: CaseForPrint[]
+ /** Innerhalb der eigenen Stufe, aber über der gewählten Höchststufe. */
+ omitted: OmittedCount[]
+ omittedTotal: number
+ truncated: boolean
+}
+
+const DOKUMENT_FELDER = {
+ id: true,
+ documentNumber: true,
+ title: true,
+ templateId: true,
+ templateKey: true,
+ layout: true,
+ status: true,
+ classification: true,
+ createdAt: true,
+ updatedAt: true,
+ retractedAt: true,
+} satisfies Prisma.DocumentSelect
+
+const AKTE_FELDER = {
+ id: true,
+ caseNumber: true,
+ title: true,
+ status: true,
+ templateId: true,
+ templateKey: true,
+ classification: true,
+ createdById: true,
+ dueAt: true,
+ updatedAt: true,
+} satisfies Prisma.CaseSelect
+
+/** Die gewählte Höchststufe, gedeckelt auf das, was der Abrufer selbst darf. */
+export function effectiveCap(p: EffectivePermissions, requested: number): number {
+ const gewuenscht = Number.isInteger(requested) ? requested : p.clearanceLevel
+ return Math.max(0, Math.min(gewuenscht, p.clearanceLevel))
+}
+
+/** Dokumente an dieser Personalakte, und Fallakten, in denen sie vorkommt. */
+function bedingungen(fileId: string, userId: string | null) {
+ const wenDieAkteMeint: Prisma.CaseParticipantWhereInput[] = [{ personnelFileId: fileId }]
+ // Dieselbe Person kann über ihre Personalakte oder über ihr Discord-Konto
+ // eingetragen sein. Beides zählt, sonst fehlen Fälle - wie bei den
+ // Rückverweisen in lib/personnel/service.ts.
+ if (userId) wenDieAkteMeint.push({ userId })
+
+ return {
+ dokumente: { personnelFileId: fileId } satisfies Prisma.DocumentWhereInput,
+ faelle: {
+ participants: { some: { OR: wenDieAkteMeint } },
+ } satisfies Prisma.CaseWhereInput,
+ }
+}
+
+/**
+ * Alles, was zu einer Person ins Dossier gehört.
+ *
+ * `cap` ist bereits gedeckelt zu übergeben (siehe `effectiveCap`) - diese
+ * Funktion deckelt nicht noch einmal, damit klar bleibt, wo das passiert.
+ */
+export async function collectPersonnelDossier(
+ p: EffectivePermissions,
+ file: { id: string; userId: string | null },
+ cap: number,
+): Promise {
+ const wo = bedingungen(file.id, file.userId)
+
+ const [dokumente, faelle, dokumenteDrueber, faelleDrueber] = await Promise.all([
+ db.document.findMany({
+ where: {
+ AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }],
+ },
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
+ take: DOSSIER_LIMIT,
+ select: DOKUMENT_FELDER,
+ }),
+ db.case.findMany({
+ where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] },
+ orderBy: [{ caseNumber: 'asc' }],
+ take: DOSSIER_LIMIT,
+ select: AKTE_FELDER,
+ }),
+
+ // Was der Abrufer sehen dürfte, aber selbst ausgeschlossen hat. Gezählt
+ // wird in der Datenbank und **innerhalb** der Sichtbarkeitsbedingung: was
+ // über seiner eigenen Stufe liegt, taucht auch hier nicht auf.
+ db.document.groupBy({
+ by: ['classification'],
+ where: {
+ AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { gt: cap } }],
+ },
+ _count: { _all: true },
+ }),
+ db.case.groupBy({
+ by: ['classification'],
+ where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { gt: cap } }] },
+ _count: { _all: true },
+ }),
+ ])
+
+ return {
+ documents: dokumente,
+ cases: faelle,
+ ...zusammenfassen(dokumenteDrueber, faelleDrueber),
+ truncated: dokumente.length === DOSSIER_LIMIT || faelle.length === DOSSIER_LIMIT,
+ }
+}
+
+function zusammenfassen(
+ dokumente: { classification: number; _count: { _all: number } }[],
+ faelle: { classification: number; _count: { _all: number } }[],
+): { omitted: OmittedCount[]; omittedTotal: number } {
+ const nachStufe = new Map()
+ const hinein = (stufe: number, art: 'documents' | 'cases', anzahl: number) => {
+ const zeile = nachStufe.get(stufe) ?? { level: stufe, documents: 0, cases: 0 }
+ zeile[art] += anzahl
+ nachStufe.set(stufe, zeile)
+ }
+
+ for (const zeile of dokumente) hinein(zeile.classification, 'documents', zeile._count._all)
+ for (const zeile of faelle) hinein(zeile.classification, 'cases', zeile._count._all)
+
+ const omitted = [...nachStufe.values()].sort((a, b) => a.level - b.level)
+ return {
+ omitted,
+ omittedTotal: omitted.reduce((summe, zeile) => summe + zeile.documents + zeile.cases, 0),
+ }
+}
+
+/**
+ * Ein Mitglied mit **allen** Kopfdaten, die der Druckbogen zeigt.
+ *
+ * Nicht nur Name und Dienstgrad: würde der Bogen im Abteilungsabzug mit
+ * leeren Feldern gerendert, stünde dort "Kein Status gesetzt" bei einer
+ * Person, die sehr wohl einen trägt. Ein Export darf nie sauberer aussehen,
+ * als die Akte ist.
+ */
+export type DepartmentMember = {
+ id: string
+ userId: string | null
+ displayName: string
+ rank: string | null
+ classification: number
+ statusTags: string[]
+ note: string | null
+ createdAt: Date
+ department: { key: string; name: string } | null
+}
+
+/**
+ * Die Mitglieder einer Abteilung, soweit sie der Abrufer sehen darf.
+ *
+ * Eine Personalakte über seiner Stufe fehlt hier vollständig und wird auch
+ * nicht gezählt - sonst verriete die Mitgliederzahl ihre Existenz.
+ */
+export async function departmentMembers(
+ p: EffectivePermissions,
+ departmentId: string,
+ cap: number,
+): Promise<{ members: DepartmentMember[]; omittedMembers: number }> {
+ const [members, drueber] = await Promise.all([
+ db.personnelFile.findMany({
+ where: {
+ AND: [
+ visiblePersonnelWhere(p),
+ { departmentId },
+ { classification: { lte: cap } },
+ ],
+ },
+ orderBy: [{ displayName: 'asc' }],
+ take: DOSSIER_LIMIT,
+ select: {
+ id: true,
+ userId: true,
+ displayName: true,
+ rank: true,
+ classification: true,
+ statusTags: true,
+ note: true,
+ createdAt: true,
+ department: { select: { key: true, name: true } },
+ },
+ }),
+ db.personnelFile.count({
+ where: {
+ AND: [visiblePersonnelWhere(p), { departmentId }, { classification: { gt: cap } }],
+ },
+ }),
+ ])
+
+ return { members, omittedMembers: drueber }
+}
+
+/**
+ * Der Stand eines Dossiers für den Zwischenspeicherschlüssel.
+ *
+ * Nicht das `updatedAt` der Personalakte allein: ein Dossier ändert sich auch
+ * dann, wenn eines seiner Dokumente oder eine seiner Fallakten sich ändert.
+ * Ohne das gäbe der Zwischenspeicher einen veralteten Abzug heraus, und ein
+ * Export darf nie einen älteren Stand zeigen, als die Akten haben.
+ */
+export async function personnelDossierStand(
+ p: EffectivePermissions,
+ file: { id: string; userId: string | null; updatedAt: Date },
+ cap: number,
+): Promise {
+ const wo = bedingungen(file.id, file.userId)
+
+ const [dokument, akte] = await Promise.all([
+ db.document.aggregate({
+ where: { AND: [visibleDocumentWhere(p), wo.dokumente, { classification: { lte: cap } }] },
+ _max: { updatedAt: true },
+ }),
+ db.case.aggregate({
+ where: { AND: [visibleCaseWhere(p), wo.faelle, { classification: { lte: cap } }] },
+ _max: { updatedAt: true },
+ }),
+ ])
+
+ return neuestes([file.updatedAt, dokument._max.updatedAt, akte._max.updatedAt])
+}
+
+/** Derselbe Gedanke für eine Abteilung, über alle sichtbaren Mitglieder. */
+export async function departmentDossierStand(
+ p: EffectivePermissions,
+ department: { id: string; updatedAt: Date },
+ cap: number,
+): Promise {
+ const { members } = await departmentMembers(p, department.id, cap)
+
+ const staende = await Promise.all(
+ members.map((mitglied) =>
+ personnelDossierStand(
+ p,
+ { id: mitglied.id, userId: mitglied.userId, updatedAt: department.updatedAt },
+ cap,
+ ),
+ ),
+ )
+
+ // Vermerke tragen kein `updatedAt`: sie werden nicht überschrieben, sondern
+ // bekommen eine Fassung oder einen Grabstein. Der Stand ist deshalb der
+ // späteste dieser drei Zeitpunkte.
+ const vermerk = await db.departmentEntry.aggregate({
+ where: { AND: [visibleDepartmentEntryWhere(p), { departmentId: department.id }] },
+ _max: { createdAt: true, editedAt: true, retractedAt: true },
+ })
+
+ return neuestes([
+ department.updatedAt,
+ vermerk._max?.createdAt,
+ vermerk._max?.editedAt,
+ vermerk._max?.retractedAt,
+ ...staende,
+ ])
+}
+
+function neuestes(werte: (Date | null | undefined)[]): Date {
+ let neueste = new Date(0)
+ for (const wert of werte) {
+ if (wert && wert.getTime() > neueste.getTime()) neueste = wert
+ }
+ return neueste
+}
+
+/** Die Abteilung, sofern der Abrufer sie sehen darf. */
+export async function loadVisibleDepartmentForPrint(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,
+ updatedAt: true,
+ },
+ })
+}
diff --git a/web/lib/export/service.ts b/web/lib/export/service.ts
index d3d70a4..86603db 100644
--- a/web/lib/export/service.ts
+++ b/web/lib/export/service.ts
@@ -5,6 +5,13 @@ import { writeAudit } from '@/lib/audit/write'
import { AccessError } from '@/lib/errors'
import { loadVisibleCase } from '@/lib/cases/service'
import { loadVisibleDocument } from '@/lib/documents/service'
+import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
+import {
+ departmentDossierStand,
+ effectiveCap,
+ loadVisibleDepartmentForPrint,
+ personnelDossierStand,
+} from '@/lib/export/dossier'
import { ensureDirFor, storageKeyFor } from '@/lib/files/paths'
import { exportCacheKey } from '@/lib/export/cacheKey'
import { createRenderToken } from '@/lib/export/token'
@@ -36,6 +43,10 @@ import type { EffectivePermissions } from '@/lib/auth/permissions'
export type ExportTarget =
| { kind: 'CASE'; caseNumber: string }
| { kind: 'DOCUMENT'; documentNumber: string }
+ /** Personalakte samt Dokumenten und den Fallakten, in denen sie vorkommt. */
+ | { kind: 'PERSONNEL'; personnelFileId: string; maxLevel: number }
+ /** Abteilung, je Mitglied ein vollständiges Dossier. */
+ | { kind: 'DEPARTMENT'; departmentKey: string; maxLevel: number }
/**
* Legt einen Auftrag an - oder gibt einen fertigen zurück, wenn genau dieser
@@ -59,6 +70,7 @@ export async function requestExport(
updatedAt: ziel.updatedAt,
clearanceLevel: p.clearanceLevel,
templateVersion: ziel.templateVersion,
+ maxLevel: ziel.maxLevel,
})
// Derselbe Stand, dieselbe Stufe: das PDF liegt schon da.
@@ -67,7 +79,12 @@ export async function requestExport(
select: { id: true },
})
if (fertig) {
- await protokolliere(p, 'export.pdf', ziel.id, { cached: true, kind: target.kind })
+ await protokolliere(p, 'export.pdf', ziel.id, {
+ cached: true,
+ kind: target.kind,
+ label: ziel.label,
+ maxLevel: ziel.maxLevel,
+ })
return { jobId: fertig.id, status: 'READY' }
}
@@ -85,6 +102,7 @@ export async function requestExport(
kind: target.kind,
label: ziel.label,
classification: ziel.classification,
+ maxLevel: ziel.maxLevel,
})
return { jobId: laufend.id, status: laufend.status }
}
@@ -96,6 +114,7 @@ export async function requestExport(
targetId: ziel.id,
requestedById: p.userId,
clearanceAtTime: p.clearanceLevel,
+ maxLevel: ziel.maxLevel,
cacheKey,
status: 'QUEUED',
},
@@ -105,13 +124,17 @@ export async function requestExport(
await writeAudit(tx, {
actorId: p.userId,
action: 'export.pdf',
- targetType: target.kind === 'CASE' ? 'Case' : 'Document',
+ targetType: zielArt(target.kind),
targetId: ziel.id,
clearanceAtTime: p.clearanceLevel,
meta: {
jobId: angelegt.id,
+ kind: target.kind,
label: ziel.label,
classification: ziel.classification,
+ // Der Deckel gehört ins Protokoll: er sagt, wie viel das Haus
+ // verlassen hat.
+ maxLevel: ziel.maxLevel,
},
})
@@ -135,19 +158,66 @@ async function aufloesen(p: EffectivePermissions, target: ExportTarget) {
classification: akte.classification,
updatedAt: akte.updatedAt,
templateVersion: vorlage?.version ?? 0,
+ maxLevel: null,
printPath: `/faelle/${akte.caseNumber}/druck`,
}
}
- const doc = await loadVisibleDocument(p, target.documentNumber)
- if (!doc) throw new AccessError('not-found')
+ if (target.kind === 'DOCUMENT') {
+ const doc = await loadVisibleDocument(p, target.documentNumber)
+ if (!doc) throw new AccessError('not-found')
+ return {
+ id: doc.id,
+ label: doc.documentNumber,
+ classification: doc.classification,
+ updatedAt: doc.updatedAt,
+ templateVersion: doc.templateVersion,
+ maxLevel: null,
+ printPath: `/dokumente/${doc.documentNumber}/druck`,
+ }
+ }
+
+ // Ab hier die Dossiers. Der Deckel wird sofort auf die Stufe des Abrufers
+ // gebracht - alles Weitere rechnet mit dem gedeckelten Wert, damit es keinen
+ // zweiten Ort gibt, an dem das schiefgehen könnte.
+ const cap = effectiveCap(p, target.maxLevel)
+
+ if (target.kind === 'PERSONNEL') {
+ const akte = await db.personnelFile.findFirst({
+ where: { AND: [visiblePersonnelWhere(p), { id: target.personnelFileId }] },
+ select: {
+ id: true,
+ userId: true,
+ displayName: true,
+ classification: true,
+ updatedAt: true,
+ },
+ })
+ if (!akte) throw new AccessError('not-found')
+
+ return {
+ id: akte.id,
+ label: akte.displayName,
+ classification: akte.classification,
+ // Der Stand über das ganze Dossier, nicht nur über die Kopfdaten.
+ updatedAt: await personnelDossierStand(p, akte, cap),
+ templateVersion: 0,
+ maxLevel: cap,
+ printPath: `/personalakten/${akte.id}/druck`,
+ }
+ }
+
+ const abteilung = await loadVisibleDepartmentForPrint(p, target.departmentKey)
+ if (!abteilung) throw new AccessError('not-found')
+
return {
- id: doc.id,
- label: doc.documentNumber,
- classification: doc.classification,
- updatedAt: doc.updatedAt,
- templateVersion: doc.templateVersion,
- printPath: `/dokumente/${doc.documentNumber}/druck`,
+ id: abteilung.id,
+ label: abteilung.name,
+ classification: abteilung.minClearance,
+ updatedAt: await departmentDossierStand(p, abteilung, cap),
+ templateVersion: 0,
+ maxLevel: cap,
+ printPath: `/abteilungen/${abteilung.key}/druck`,
}
}
@@ -185,8 +255,10 @@ export async function runExport(jobId: string): Promise {
jobId,
userId: auftrag.requestedById,
clearanceLevel: auftrag.clearanceAtTime,
+ ...(auftrag.maxLevel === null ? {} : { maxLevel: auftrag.maxLevel }),
})
- const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}`
+ const deckel = auftrag.maxLevel === null ? '' : `&stufe=${auftrag.maxLevel}`
+ const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}${deckel}`
const antwort = await fetch(`${env.PDF_SERVICE_URL.replace(/\/$/, '')}/render`, {
method: 'POST',
@@ -198,6 +270,7 @@ export async function runExport(jobId: string): Promise {
headerHtml: wasserzeichen(
nutzer?.displayName ?? auftrag.requestedById,
auftrag.clearanceAtTime,
+ auftrag.maxLevel,
),
footerHtml:
'' +
- `${sicher} · Stufe ${stufe} ` +
+ `${sicher} · Stufe ${stufe}${bis} ` +
' ' +
'
'
)
}
+/** Aus dem gespeicherten Auftrag zurück zur Druckansicht. */
async function druckPfad(kind: string, targetId: string | null): Promise {
if (!targetId) return null
+
if (kind === 'CASE') {
const akte = await db.case.findUnique({
where: { id: targetId },
@@ -258,6 +336,23 @@ async function druckPfad(kind: string, targetId: string | null): Promise `bearbeitet, v${version}, ${datum}`,
redactedAt: (stufe: number, name: string) => `Geschwärzt, Stufe ${stufe} ${name}`.trim(),
+
+ // --- Dossier: Personalakte oder Abteilung mit allem, was daran hängt ---
+ dossier: 'Dossier',
+ dossierHeading: 'Vollständiges Dossier',
+ dossierIntro:
+ 'Die Akte selbst, dahinter alle ihre Dokumente, dahinter alle Fallakten, in denen die Person vorkommt - in derselben Darstellung wie einzeln gedruckt.',
+ departmentDossierIntro:
+ 'Die Abteilung mit ihren Vermerken, dahinter je Mitglied ein vollständiges Dossier. Das wird umfangreich.',
+ maxLevel: 'Höchste Stufe im Abzug',
+ maxLevelHint:
+ 'Alles über dieser Stufe bleibt draussen. Wie viel das war, steht im Abzug - der Empfänger soll sehen, dass er eine gekürzte Mappe hat.',
+ maxLevelCapped: 'Höher als deine eigene Freigabestufe geht nicht.',
+ documentsHeading: 'Dokumente',
+ casesHeading: 'Fallakten',
+ membersHeading: 'Mitglieder',
+ memberOf: (n: number) => (n === 1 ? '1 Mitglied' : `${n} Mitglieder`),
+ noDocuments: 'Keine Dokumente in diesem Abzug.',
+ noCases: 'Keine Fallakten in diesem Abzug.',
+ noMembers: 'Keine Mitglieder in diesem Abzug.',
+ omittedHeading: 'Nicht aufgenommen',
+ omittedNote: (stufe: number) =>
+ `Über der gewählten Höchststufe. Zum Abruf mit Stufe ${stufe} berechtigt, aber bewusst weggelassen.`,
+ omittedRow: (stufe: number, dokumente: number, faelle: number) =>
+ `Stufe ${stufe}: ${dokumente} Dokumente, ${faelle} Fallakten`,
+ omittedMembers: (n: number) =>
+ n === 1
+ ? '1 Mitglied über der gewählten Höchststufe nicht aufgenommen'
+ : `${n} Mitglieder über der gewählten Höchststufe nicht aufgenommen`,
+ truncated:
+ 'Der Abzug wurde bei der Höchstzahl je Abschnitt abgeschnitten. Enger fassen und erneut abrufen.',
+ cappedAt: (stufe: number, name: string) => `Abzug bis Stufe ${stufe} ${name}`.trim(),
fileName: 'Datei',
fileSize: 'Bytes',
fileHash: 'SHA-256',
diff --git a/web/tests/dossier.test.ts b/web/tests/dossier.test.ts
new file mode 100644
index 0000000..24aa0af
--- /dev/null
+++ b/web/tests/dossier.test.ts
@@ -0,0 +1,405 @@
+// @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(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[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' })
+ })
+})