diff --git a/packages/db/prisma/migrations/20260903180000_export/migration.sql b/packages/db/prisma/migrations/20260903180000_export/migration.sql
new file mode 100644
index 0000000..c529771
--- /dev/null
+++ b/packages/db/prisma/migrations/20260903180000_export/migration.sql
@@ -0,0 +1,40 @@
+-- Phase 8: Export.
+--
+-- Der Auftrag steht in der Datenbank, nicht im Speicher eines Prozesses: das
+-- Rendern dauert, und ein Neustart des Web-Containers soll einen laufenden
+-- Export nicht spurlos verschlucken.
+
+CREATE TYPE "ExportKind" AS ENUM ('CASE', 'DOCUMENT', 'BUNDLE', 'DATABASE');
+CREATE TYPE "ExportStatus" AS ENUM (
+ 'PENDING_APPROVAL', 'QUEUED', 'RUNNING', 'READY', 'FAILED', 'REJECTED'
+);
+
+CREATE TABLE "ExportJob" (
+ "id" TEXT NOT NULL,
+ "kind" "ExportKind" NOT NULL,
+ "targetId" TEXT,
+ "requestedById" TEXT NOT NULL,
+ "clearanceAtTime" INTEGER NOT NULL,
+ "status" "ExportStatus" NOT NULL DEFAULT 'QUEUED',
+ "cacheKey" TEXT NOT NULL,
+ "storageKey" TEXT,
+ "sizeBytes" INTEGER,
+ "error" TEXT,
+ "approvedById" TEXT,
+ "approvedAt" TIMESTAMP(3),
+ "rejectedById" TEXT,
+ "rejectReason" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "startedAt" TIMESTAMP(3),
+ "finishedAt" TIMESTAMP(3),
+
+ CONSTRAINT "ExportJob_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "ExportJob_status_createdAt_idx" ON "ExportJob"("status", "createdAt");
+CREATE INDEX "ExportJob_requestedById_createdAt_idx" ON "ExportJob"("requestedById", "createdAt" DESC);
+CREATE INDEX "ExportJob_cacheKey_status_idx" ON "ExportJob"("cacheKey", "status");
+
+-- Ein Auftrag wird nie gelöscht: er ist der Beleg dafür, wer wann was
+-- ausgeleitet hat, und ergänzt das Zugriffsprotokoll um den Ausgang.
+CREATE RULE export_job_no_delete AS ON DELETE TO "ExportJob" DO INSTEAD NOTHING;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 9ed0051..a8fa2e7 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -845,6 +845,67 @@ model SavedView {
@@index([userId, sortOrder])
}
+/// Ein Exportauftrag.
+///
+/// Der Export laeuft im Hintergrund, weil das Rendern auf einem Heimserver
+/// spuerbar CPU kostet. Der Klick erzeugt diesen Auftrag, die Oberflaeche
+/// wartet auf das Ereignis `export.ready`.
+///
+/// `cacheKey` ist sha256 ueber Ziel, Stand, Stufe des Abrufers und
+/// Vorlagenversion: dieselbe Akte wird oft mehrfach exportiert, und zwei
+/// Abrufer verschiedener Stufen bekommen **nicht** dasselbe PDF.
+model ExportJob {
+ id String @id @default(uuid(7))
+ kind ExportKind
+ /// Fallakte oder Dokument. Leer beim Voll- und Sammelexport.
+ targetId String?
+ requestedById String
+ /// Die Stufe des Abrufers zum Zeitpunkt der Anfrage. Sie steckt im
+ /// Schluessel und im Wasserzeichen.
+ clearanceAtTime Int
+ status ExportStatus @default(QUEUED)
+ cacheKey String
+ /// Pfad unterhalb von FILES_DIR, sobald fertig.
+ storageKey String?
+ sizeBytes Int?
+ error String?
+
+ /// Vier-Augen-Prinzip ab Stufe 3, siehe docs/08-suche-und-export.md.
+ approvedById String?
+ approvedAt DateTime?
+ rejectedById String?
+ rejectReason String?
+
+ createdAt DateTime @default(now())
+ startedAt DateTime?
+ finishedAt DateTime?
+
+ @@index([status, createdAt])
+ @@index([requestedById, createdAt(sort: Desc)])
+ @@index([cacheKey, status])
+}
+
+enum ExportKind {
+ /// eine Fallakte mit Verlauf und Anhangsverzeichnis
+ CASE
+ /// ein einzelnes Dokument, ohne Verlauf
+ DOCUMENT
+ /// mehrere Akten als ZIP, nur mit Recht 'admin'
+ BUNDLE
+ /// vollstaendiger JSON-Abzug, nur mit Recht 'admin'
+ DATABASE
+}
+
+enum ExportStatus {
+ /// wartet auf die Freigabe durch ein zweites Augenpaar
+ PENDING_APPROVAL
+ QUEUED
+ RUNNING
+ READY
+ FAILED
+ REJECTED
+}
+
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
diff --git a/web/app/(app)/admin/system/page.tsx b/web/app/(app)/admin/system/page.tsx
index 5bf64b7..48aa75f 100644
--- a/web/app/(app)/admin/system/page.tsx
+++ b/web/app/(app)/admin/system/page.tsx
@@ -69,6 +69,17 @@ export default async function SystemPage() {
+
+
)
}
diff --git a/web/app/(app)/dokumente/[documentNumber]/export/page.tsx b/web/app/(app)/dokumente/[documentNumber]/export/page.tsx
new file mode 100644
index 0000000..bf62ea8
--- /dev/null
+++ b/web/app/(app)/dokumente/[documentNumber]/export/page.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { requireUser } from '@/lib/auth/current'
+import { loadVisibleDocument } from '@/lib/documents/service'
+import { APPROVAL_FROM_LEVEL } from '@/lib/export/service'
+import { de } from '@/lib/i18n/de'
+import { ExportPanel } from '@/components/export/ExportPanel'
+
+export const metadata: Metadata = { title: de.export.heading }
+export const dynamic = 'force-dynamic'
+
+export default async function DocumentExportPage({
+ params,
+}: {
+ params: Promise<{ documentNumber: string }>
+}) {
+ const user = await requireUser()
+ const { documentNumber } = await params
+
+ const doc = await loadVisibleDocument(user.permissions, documentNumber)
+ if (!doc) notFound()
+
+ return (
+
+ {doc.documentNumber}
+ {de.export.heading}
+
+
+
+
+
+ {doc.classification >= APPROVAL_FROM_LEVEL ? (
+ {de.export.waitingApprovalHint}
+ ) : null}
+
+
+ {doc.documentNumber}
+ {de.export.print}
+
+
+ )
+}
diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx
index 8fceb71..5769b59 100644
--- a/web/app/(app)/dokumente/[documentNumber]/page.tsx
+++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx
@@ -212,6 +212,10 @@ export default async function DocumentPage({
+
+ {de.export.asPdf}
+
+
{besitzer ? (
{'caseNumber' in besitzer ? (
diff --git a/web/app/(app)/export/Approvals.tsx b/web/app/(app)/export/Approvals.tsx
new file mode 100644
index 0000000..bd7df12
--- /dev/null
+++ b/web/app/(app)/export/Approvals.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { exportText } from '@/lib/i18n/export'
+import { commonText } from '@/lib/i18n/common'
+import { approveExportAction, rejectExportAction } from './actions'
+
+type Auftrag = {
+ id: string
+ kind: string
+ label: string
+ requestedBy: string
+ clearanceAtTime: number
+ createdAt: string
+}
+
+/**
+ * Offene Exportfreigaben.
+ *
+ * Vier-Augen-Prinzip: hier steht nur, was **jemand anderes** angefragt hat.
+ * Die eigene Anfrage taucht gar nicht erst auf, und der Dienst weist sie auch
+ * dann zurück, wenn jemand die Kennung von Hand einsetzt.
+ */
+export function Approvals({ jobs }: { jobs: Auftrag[] }) {
+ const [ablehnung, setAblehnung] = useState(null)
+ const [grund, setGrund] = useState('')
+ const [fehler, setFehler] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ if (jobs.length === 0) {
+ return {exportText.approvalsEmpty}
+ }
+
+ return (
+
+
+
+ {fehler ?
{fehler}
: null}
+
+ )
+}
diff --git a/web/app/(app)/export/actions.ts b/web/app/(app)/export/actions.ts
new file mode 100644
index 0000000..0577eb9
--- /dev/null
+++ b/web/app/(app)/export/actions.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { requireUser } from '@/lib/auth/current'
+import { AccessError } from '@/lib/errors'
+import {
+ approveExport,
+ rejectExport,
+ requestExport,
+ runExport,
+ type ExportTarget,
+} from '@/lib/export/service'
+import { de } from '@/lib/i18n/de'
+
+export type ExportState = { jobId?: string; status?: string; error?: string }
+
+function toMessage(error: unknown): string {
+ if (error instanceof AccessError) {
+ return de.actionErrors[error.code] ?? de.actionErrors.default!
+ }
+ console.error('[export] unerwartet', error)
+ return de.actionErrors.default!
+}
+
+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) }),
+])
+
+/**
+ * Export anfordern.
+ *
+ * Der Auftrag läuft im Hintergrund weiter, nachdem die Antwort raus ist — das
+ * Rendern dauert, und niemand soll auf einen Fortschrittsbalken starren, der
+ * auf Playwright wartet. Siehe docs/09-performance-und-ux.md.
+ */
+export async function requestExportAction(
+ _prev: ExportState | null,
+ formData: FormData,
+): Promise {
+ const user = await requireUser()
+ const parsed = requestSchema.safeParse(Object.fromEntries(formData))
+ if (!parsed.success) return { error: de.actionErrors.default! }
+
+ try {
+ const auftrag = await requestExport(user.permissions, parsed.data as ExportTarget)
+ if (auftrag.status === 'QUEUED') void runExport(auftrag.jobId)
+ return { jobId: auftrag.jobId, status: auftrag.status }
+ } catch (error) {
+ return { error: toMessage(error) }
+ }
+}
+
+const idSchema = z.object({ id: z.string().min(1) })
+
+export async function approveExportAction(input: unknown): Promise {
+ const user = await requireUser()
+ const parsed = idSchema.safeParse(input)
+ if (!parsed.success) return { error: de.actionErrors.default! }
+
+ try {
+ await approveExport(user.permissions, parsed.data.id)
+ // Erst nach der Freigabe wird gerendert.
+ void runExport(parsed.data.id)
+ revalidatePath('/uebersicht')
+ return { jobId: parsed.data.id, status: 'QUEUED' }
+ } catch (error) {
+ return { error: toMessage(error) }
+ }
+}
+
+const rejectSchema = idSchema.extend({ reason: z.string().min(10).max(500) })
+
+export async function rejectExportAction(input: unknown): Promise {
+ const user = await requireUser()
+ const parsed = rejectSchema.safeParse(input)
+ if (!parsed.success) return { error: de.actionErrors['reason-required']! }
+
+ try {
+ await rejectExport(user.permissions, parsed.data.id, parsed.data.reason)
+ revalidatePath('/uebersicht')
+ return { jobId: parsed.data.id, status: 'REJECTED' }
+ } catch (error) {
+ return { error: toMessage(error) }
+ }
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/export/page.tsx b/web/app/(app)/faelle/[caseNumber]/export/page.tsx
new file mode 100644
index 0000000..2224df7
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/export/page.tsx
@@ -0,0 +1,55 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { requireUser } from '@/lib/auth/current'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { APPROVAL_FROM_LEVEL } from '@/lib/export/service'
+import { de } from '@/lib/i18n/de'
+import { ExportPanel } from '@/components/export/ExportPanel'
+
+export const metadata: Metadata = { title: de.export.heading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Eigene Seite statt eines Knopfes an der Akte.
+ *
+ * Die Fallakte ist die dichteste Seite der Anwendung und hat kein Bündel
+ * übrig; ein Verweis kostet nichts, eine weitere Insel schon. Siehe
+ * docs/13-entscheidungen.md.
+ */
+export default async function CaseExportPage({
+ params,
+}: {
+ params: Promise<{ caseNumber: string }>
+}) {
+ const user = await requireUser()
+ const { caseNumber } = await params
+
+ const akte = await loadVisibleCase(user.permissions, caseNumber)
+ if (!akte) notFound()
+
+ return (
+
+ {akte.caseNumber}
+ {de.export.heading}
+
+ {de.export.coverNote}
+
+
+
+
+
+ {akte.classification >= APPROVAL_FROM_LEVEL ? (
+ {de.export.waitingApprovalHint}
+ ) : null}
+
+
+ {akte.caseNumber}
+ {de.export.print}
+
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx
index 378f54b..37075fc 100644
--- a/web/app/(app)/faelle/[caseNumber]/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/page.tsx
@@ -423,6 +423,7 @@ export default async function CasePage({
{de.case.timeline}
{de.case.changes}
+ {de.export.heading}
diff --git a/web/app/(app)/uebersicht/page.tsx b/web/app/(app)/uebersicht/page.tsx
index cab4546..4140699 100644
--- a/web/app/(app)/uebersicht/page.tsx
+++ b/web/app/(app)/uebersicht/page.tsx
@@ -4,6 +4,8 @@ import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { PERMISSIONS } from '@/lib/auth/permissions'
import { StampMark } from '@/components/StampMark'
+import { pendingApprovals } from '@/lib/export/service'
+import { Approvals } from '../export/Approvals'
export const metadata: Metadata = { title: de.overview.heading }
export const dynamic = 'force-dynamic'
@@ -32,8 +34,22 @@ export default async function OverviewPage() {
const granted = [...user.permissions.permissions].sort()
+ // Offene Exportfreigaben stehen hier, weil die Leitung diese Seite ohnehin
+ // öffnet. Eine Anfrage, die niemand sieht, ist keine Freigabe.
+ const offen = await pendingApprovals(user.permissions)
+ const beschriftet = await beschrifte(offen)
+
return (
+ {beschriftet.length > 0 ? (
+
+ {de.export.approvals}
+
+
+ ) : null}
+
{de.overview.heading}
{de.overview.intro}
@@ -93,3 +109,46 @@ export default async function OverviewPage() {
)
}
+
+/**
+ * Der Freigabeliste fehlen die Namen: der Auftrag kennt nur Kennungen. Sie
+ * werden hier nachgeschlagen, damit die Leitung sieht, worum es geht — mehr
+ * als Aktenzeichen und Anfragender steht dort bewusst nicht.
+ */
+async function beschrifte(
+ jobs: Awaited>,
+): Promise<
+ { id: string; kind: string; label: string; requestedBy: string; clearanceAtTime: number; createdAt: string }[]
+> {
+ if (jobs.length === 0) return []
+
+ const [faelle, dokumente, nutzer] = await Promise.all([
+ db.case.findMany({
+ where: { id: { in: jobs.filter((j) => j.kind === 'CASE').map((j) => j.targetId ?? '') } },
+ select: { id: true, caseNumber: true },
+ }),
+ db.document.findMany({
+ where: { id: { in: jobs.filter((j) => j.kind === 'DOCUMENT').map((j) => j.targetId ?? '') } },
+ select: { id: true, documentNumber: true },
+ }),
+ db.user.findMany({
+ where: { id: { in: jobs.map((j) => j.requestedById) } },
+ select: { id: true, displayName: true },
+ }),
+ ])
+
+ const nummer = new Map([
+ ...faelle.map((f) => [f.id, f.caseNumber] as const),
+ ...dokumente.map((d) => [d.id, d.documentNumber] as const),
+ ])
+ const name = new Map(nutzer.map((n) => [n.id, n.displayName]))
+
+ return jobs.map((job) => ({
+ id: job.id,
+ kind: job.kind,
+ label: nummer.get(job.targetId ?? '') ?? '—',
+ requestedBy: name.get(job.requestedById) ?? job.requestedById,
+ clearanceAtTime: job.clearanceAtTime,
+ createdAt: job.createdAt.toISOString(),
+ }))
+}
diff --git a/web/app/(print)/NotAvailable.tsx b/web/app/(print)/NotAvailable.tsx
new file mode 100644
index 0000000..f3bf38a
--- /dev/null
+++ b/web/app/(print)/NotAvailable.tsx
@@ -0,0 +1,21 @@
+import { de } from '@/lib/i18n/de'
+
+/**
+ * Was eine Druckansicht zeigt, wenn es nichts zu zeigen gibt.
+ *
+ * Bewusst **kein `notFound()`**: die Wurzel hat ein `loading.tsx`, und dessen
+ * Skelett bleibt in dieser Gruppe stehen, statt durch die Nicht-gefunden-Seite
+ * ersetzt zu werden — die Ansicht bliebe leer. Für ein Blatt, das gedruckt
+ * wird, ist ein leeres Blatt der schlechteste Ausgang: es sieht aus wie ein
+ * Fehler im Papier, nicht wie eine Auskunft.
+ *
+ * Sie sagt nicht, ob es die Akte gibt. Existenz ist auch eine Information.
+ */
+export function NotAvailable() {
+ return (
+
+ {de.errors.notFound.title}
+ {de.errors.notFound.body}
+
+ )
+}
diff --git a/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx
new file mode 100644
index 0000000..73550a9
--- /dev/null
+++ b/web/app/(print)/dokumente/[documentNumber]/druck/page.tsx
@@ -0,0 +1,180 @@
+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 { NotAvailable } from '../../../NotAvailable'
+import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
+
+export const dynamic = 'force-dynamic'
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ documentNumber: string }>
+}): Promise {
+ const { documentNumber } = await params
+ return { title: documentNumber, robots: { index: false, follow: false } }
+}
+
+/**
+ * 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.
+ */
+export default async function PrintDocumentPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ documentNumber: string }>
+ searchParams: Promise<{ token?: string }>
+}) {
+ const { documentNumber } = await params
+ const { token } = await searchParams
+
+ const zugang = await permissionsForPrint(token)
+ if (!zugang) return
+ const { permissions, displayName } = zugang
+
+ const doc = await loadVisibleDocument(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
new file mode 100644
index 0000000..4de3da0
--- /dev/null
+++ b/web/app/(print)/faelle/[caseNumber]/druck/page.tsx
@@ -0,0 +1,267 @@
+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 { NotAvailable } from '../../../NotAvailable'
+import type { RenderedSection } from '@/lib/sections/render'
+
+export const dynamic = 'force-dynamic'
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ caseNumber: string }>
+}): Promise {
+ const { caseNumber } = await params
+ return { title: caseNumber, robots: { index: false, follow: false } }
+}
+
+/**
+ * Druckansicht einer Fallakte.
+ *
+ * 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.
+ */
+export default async function PrintCasePage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ caseNumber: string }>
+ searchParams: Promise<{ token?: string }>
+}) {
+ const { caseNumber } = await params
+ const { token } = await searchParams
+
+ const zugang = await permissionsForPrint(token)
+ if (!zugang) return
+ const { permissions, displayName } = zugang
+
+ const akte = await loadVisibleCase(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/api/admin/export/route.ts b/web/app/api/admin/export/route.ts
new file mode 100644
index 0000000..f827560
--- /dev/null
+++ b/web/app/api/admin/export/route.ts
@@ -0,0 +1,94 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { getCurrentUser } from '@/lib/auth/current'
+import { AccessError } from '@/lib/errors'
+import { exportDatabase } from '@/lib/export/database'
+import { createZip } from '@/lib/export/zip'
+import { absolutePath } from '@/lib/files/paths'
+import { readFile } from 'node:fs/promises'
+import { writeAudit } from '@/lib/audit'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * Die beiden Wege für Administratoren aus docs/08-suche-und-export.md:
+ *
+ * GET /api/admin/export?kind=database vollständiger JSON-Abzug
+ * GET /api/admin/export?kind=bundle ZIP mit den fertigen Exporten
+ *
+ * Beide nur mit Recht `admin`, beide protokolliert. Der JSON-Abzug ist die
+ * einzige Stelle im System, an der die Klassifizierung nicht greift — deshalb
+ * liegt er hinter einem eigenen Endpunkt und nicht hinter einem Knopf an einer
+ * Akte.
+ */
+export async function GET(request: Request): Promise {
+ const user = await getCurrentUser()
+ // Kein 403: wer das Recht nicht hat, für den gibt es den Endpunkt nicht.
+ if (!user?.permissions.isAdmin) return new NextResponse(null, { status: 404 })
+
+ const kind = new URL(request.url).searchParams.get('kind')
+
+ try {
+ if (kind === 'database') {
+ const abzug = await exportDatabase(user.permissions)
+ return datei(abzug, 'application/json', `afis-abzug-${heute()}.json`)
+ }
+
+ if (kind === 'bundle') {
+ const fertige = await db.exportJob.findMany({
+ where: { status: 'READY', storageKey: { not: null } },
+ orderBy: { finishedAt: 'desc' },
+ take: 100,
+ select: { id: true, storageKey: true, kind: true },
+ })
+
+ const eintraege: { name: string; content: Uint8Array }[] = []
+ for (const auftrag of fertige) {
+ try {
+ eintraege.push({
+ name: `${auftrag.kind.toLowerCase()}-${auftrag.id}.pdf`,
+ content: new Uint8Array(await readFile(absolutePath(auftrag.storageKey!))),
+ })
+ } catch {
+ // Eine fehlende Datei hält den Sammelexport nicht auf.
+ }
+ }
+
+ await db.$transaction(async (tx) => {
+ await writeAudit(tx, {
+ actorId: user.id,
+ action: 'export.pdf',
+ targetType: 'ExportJob',
+ targetId: 'bundle',
+ clearanceAtTime: user.permissions.clearanceLevel,
+ meta: { files: eintraege.length },
+ })
+ })
+
+ return datei(createZip(eintraege), 'application/zip', `afis-sammelexport-${heute()}.zip`)
+ }
+
+ return new NextResponse(null, { status: 404 })
+ } catch (error) {
+ if (error instanceof AccessError) return new NextResponse(null, { status: 404 })
+ console.error('[export] Adminexport fehlgeschlagen', error)
+ return NextResponse.json({ error: 'unbekannt' }, { status: 500 })
+ }
+}
+
+function heute(): string {
+ return new Date().toISOString().slice(0, 10)
+}
+
+function datei(inhalt: Uint8Array, typ: string, name: string): Response {
+ return new NextResponse(inhalt as unknown as BodyInit, {
+ headers: {
+ 'Content-Type': typ,
+ 'Content-Length': String(inhalt.byteLength),
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'private, no-store',
+ 'Content-Disposition': `attachment; filename="${name}"`,
+ },
+ })
+}
diff --git a/web/app/api/exports/[id]/route.ts b/web/app/api/exports/[id]/route.ts
new file mode 100644
index 0000000..74d5ab3
--- /dev/null
+++ b/web/app/api/exports/[id]/route.ts
@@ -0,0 +1,63 @@
+import { createReadStream } from 'node:fs'
+import { stat } from 'node:fs/promises'
+import { Readable } from 'node:stream'
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { getCurrentUser } from '@/lib/auth/current'
+import { recordRead } from '@/lib/audit'
+import { absolutePath } from '@/lib/files/paths'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * Herunterladen eines fertigen Exports.
+ *
+ * Nur der Auftraggeber selbst. Ein Export ist auf **seine** Stufe gekürzt und
+ * trägt **seinen** Namen im Wasserzeichen — ihn an jemand anderen auszuliefern
+ * hiesse, ein Dokument mit fremdem Namen zu verteilen.
+ */
+export async function GET(
+ _request: Request,
+ context: { params: Promise<{ id: string }> },
+): Promise {
+ const user = await getCurrentUser()
+ if (!user) return new NextResponse(null, { status: 404 })
+
+ const { id } = await context.params
+ const auftrag = await db.exportJob.findFirst({
+ where: { id, requestedById: user.id, status: 'READY' },
+ select: { id: true, kind: true, targetId: true, storageKey: true },
+ })
+ if (!auftrag?.storageKey) return new NextResponse(null, { status: 404 })
+
+ let groesse: number
+ try {
+ groesse = (await stat(absolutePath(auftrag.storageKey))).size
+ } catch {
+ return new NextResponse(null, { status: 404 })
+ }
+
+ // Jeder Abruf wird protokolliert, nicht nur die Erzeugung: ein PDF kann
+ // mehrfach heruntergeladen und weitergegeben werden.
+ recordRead({
+ actorId: user.id,
+ action: 'export.download',
+ targetType: 'ExportJob',
+ targetId: auftrag.id,
+ clearanceAtTime: user.permissions.clearanceLevel,
+ meta: { kind: auftrag.kind, targetId: auftrag.targetId ?? undefined },
+ })
+
+ const stream = Readable.toWeb(createReadStream(absolutePath(auftrag.storageKey))) as ReadableStream
+ return new NextResponse(stream, {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Length': String(groesse),
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'private, no-store',
+ 'Content-Security-Policy': "default-src 'none'; sandbox",
+ 'Content-Disposition': `attachment; filename="afis-export-${auftrag.id}.pdf"`,
+ },
+ })
+}
diff --git a/web/app/globals.css b/web/app/globals.css
index 27093d8..0daa936 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -399,10 +399,120 @@
overflow-x: auto;
}
}
+
+ /* ---------------------------------------------------------------------
+ Die Druckansicht einer Akte.
+
+ Schwarz auf weiss wie das Aktenblatt, aber im Aufbau eines Berichts:
+ Deckblatt, Kopfdaten, Abschnitte, Verlauf, Anhangsverzeichnis. Das
+ Wasserzeichen setzt der PDF-Dienst in Kopf- und Fusszeile jeder Seite.
+
+ Ein Export darf nie sauberer aussehen als die Akte ist: geänderte
+ Einträge sind gekennzeichnet, zurückgezogene stehen als Grabstein, und
+ gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen.
+ Siehe docs/08-suche-und-export.md.
+ --------------------------------------------------------------------- */
+ .print-sheet {
+ max-width: 180mm;
+ margin: 0 auto;
+ padding: 12mm 0;
+ background: #fff;
+ color: #000;
+ font-size: 10pt;
+ line-height: 1.45;
+ }
+
+ .print-sheet h1 {
+ font-size: 18pt;
+ font-weight: 900;
+ margin: 0 0 2mm;
+ color: #000;
+ }
+
+ .print-sheet h2 {
+ font-size: 12pt;
+ font-weight: 900;
+ margin: 8mm 0 2mm;
+ padding-bottom: 1mm;
+ border-bottom: 1.5pt solid #000;
+ color: #000;
+ break-after: avoid;
+ }
+
+ .print-sheet h3 {
+ font-size: 10pt;
+ font-weight: 900;
+ margin: 4mm 0 1mm;
+ color: #000;
+ break-after: avoid;
+ }
+
+ .print-cover {
+ border: 3pt solid #000;
+ padding: 8mm;
+ margin-bottom: 8mm;
+ }
+
+ .print-stamp {
+ display: inline-block;
+ border: 2pt solid #000;
+ padding: 1mm 3mm;
+ font-weight: 900;
+ letter-spacing: 1pt;
+ text-transform: uppercase;
+ }
+
+ .print-meta {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 3mm;
+ }
+
+ .print-meta th,
+ .print-meta td {
+ border: 0.75pt solid #000;
+ padding: 1.5mm 2mm;
+ text-align: left;
+ vertical-align: top;
+ font-size: 9pt;
+ }
+
+ .print-meta th {
+ width: 38mm;
+ font-weight: 900;
+ }
+
+ /* Ein Eintrag soll nicht über den Seitenrand zerrissen werden. */
+ .print-entry {
+ break-inside: avoid;
+ padding: 2mm 0;
+ border-bottom: 0.5pt dotted #666;
+ }
+
+ .print-label {
+ font-size: 8pt;
+ font-weight: 900;
+ text-transform: uppercase;
+ color: #000;
+ }
+
+ .print-note {
+ font-size: 8pt;
+ color: #444;
+ }
+
+ /* Die Schwärzung im Druck: ein voller Balken, kein Weglassen. */
+ .print-redacted {
+ display: inline-block;
+ min-width: 30mm;
+ background: #000;
+ color: #000;
+ user-select: none;
+ }
}
-/* Drucken: nur das Blatt, ohne Rahmenwerk der Anwendung. Der PDF-Export mit
- Wasserzeichen und Deckblatt kommt in Phase 8, siehe docs/08. */
+/* Drucken: nur das Blatt, ohne Rahmenwerk der Anwendung. Kopf- und Fusszeile
+ mit dem Wasserzeichen setzt der PDF-Dienst, siehe docs/08. */
@media print {
.no-print {
display: none !important;
@@ -422,11 +532,18 @@
min-height: auto;
}
- .sheet-classbar {
- /* Sonst druckt der Balken weiss auf weiss. */
+ .sheet-classbar,
+ .print-redacted,
+ .print-stamp {
+ /* Sonst drucken die Flächen weiss auf weiss. */
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
+
+ .print-sheet {
+ max-width: none;
+ padding: 0;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/web/components/export/ExportPanel.tsx b/web/components/export/ExportPanel.tsx
new file mode 100644
index 0000000..434a45f
--- /dev/null
+++ b/web/components/export/ExportPanel.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { useActionState, useEffect, useState } from 'react'
+import { exportText } from '@/lib/i18n/export'
+import { useStream } from '@/components/realtime/useStream'
+import { requestExportAction, type ExportState } from '@/app/(app)/export/actions'
+
+/**
+ * Export anfordern und auf das Ergebnis warten.
+ *
+ * Kein Fortschrittsbalken, der eine Zahl erfindet: der Auftrag hat drei
+ * Zustände, und die stehen da. Fertig meldet der Ereignisstrom, dann steht
+ * der Knopf zum Herunterladen bereit — automatisch losladen wäre unhöflich,
+ * wenn jemand die Seite inzwischen wieder verlassen hat und zurückkommt.
+ */
+export function ExportPanel({
+ target,
+ topic,
+}: {
+ target: { kind: 'CASE'; caseNumber: string } | { kind: 'DOCUMENT'; documentNumber: string }
+ /** Eigenes Nutzerthema: der Export geht nur den Auftraggeber an. */
+ topic: string
+}) {
+ const [state, action, pending] = useActionState(
+ requestExportAction,
+ null,
+ )
+ const [fertig, setFertig] = useState(null)
+ const [gescheitert, setGescheitert] = useState(false)
+
+ useStream([topic], {
+ 'export.ready': (data) => {
+ const ereignis = data as { id: string }
+ if (state?.jobId && ereignis.id !== state.jobId) return
+ setFertig(ereignis.id)
+ },
+ })
+
+ // Nach der Freigabe eines wartenden Auftrags kommt das Ereignis später; bis
+ // dahin bleibt der Hinweis stehen.
+ useEffect(() => {
+ if (state?.status === 'READY' && state.jobId) setFertig(state.jobId)
+ if (state?.status === 'FAILED') setGescheitert(true)
+ }, [state])
+
+ const wartetAufFreigabe = state?.status === 'PENDING_APPROVAL'
+
+ return (
+
+
+
+
+ {state?.error ?
{state.error}
: null}
+
+ {wartetAufFreigabe ? (
+
+
{exportText.waitingApproval}
+
{exportText.waitingApprovalHint}
+
+ ) : null}
+
+ {state?.jobId && !fertig && !wartetAufFreigabe && !gescheitert ? (
+
{exportText.preparing}
+ ) : null}
+
+ {gescheitert ?
{exportText.failed}
: null}
+
+ {fertig ? (
+
+ {exportText.download}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts
index e081bba..19839d8 100644
--- a/web/e2e/smoke.spec.ts
+++ b/web/e2e/smoke.spec.ts
@@ -35,6 +35,7 @@ test.describe('Geschuetzte Bereiche', () => {
'/personalakten/neu',
'/kanaele',
'/suche',
+ '/faelle/AfiS-2026-0001/export',
'/einstellungen',
'/dokumente/AfiS-2026-0001',
'/admin/rollen',
@@ -48,6 +49,29 @@ test.describe('Geschuetzte Bereiche', () => {
}
})
+test.describe('Druckansichten', () => {
+ // Sie liegen ausserhalb des angemeldeten Bereichs, weil der PDF-Dienst sie
+ // mit einem kurzlebigen Zeichen aufruft und keine Sitzung hat. Ohne
+ // gueltiges Zeichen gibt es sie nicht — 404 statt Weiterleitung, denn die
+ // Existenz ist selbst eine Information.
+ // Geprueft wird der Inhalt, nicht der Statuscode: Next streamt die Antwort,
+ // der Kopf ist also schon raus, wenn notFound() greift. Entscheidend ist,
+ // dass nichts aus der Akte auf der Seite landet.
+ for (const pfad of ['/faelle/AfiS-2026-0001/druck', '/dokumente/AfiS-2026-0001/druck']) {
+ for (const [name, ziel] of [
+ ['ohne Zeichen', pfad],
+ ['mit erfundenem Zeichen', `${pfad}?token=abc.def`],
+ ] as const) {
+ test(`${pfad} zeigt ${name} nichts`, async ({ page }) => {
+ await page.goto(ziel)
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Nicht gefunden')
+ await expect(page.locator('.print-cover')).toHaveCount(0)
+ await expect(page.locator('.sheet')).toHaveCount(0)
+ })
+ }
+ }
+})
+
test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => {
await page.goto('/anmelden/fehler?grund=keine-afis-rolle')
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
diff --git a/web/lib/audit.ts b/web/lib/audit.ts
index 61d420d..7136716 100644
--- a/web/lib/audit.ts
+++ b/web/lib/audit.ts
@@ -47,6 +47,10 @@ export type AuditAction =
| 'channel.create'
| 'roles.update'
| 'export.pdf'
+ | 'export.approve'
+ | 'export.reject'
+ | 'export.download'
+ | 'export.database'
export type AuditInput = {
actorId: string
diff --git a/web/lib/export/cacheKey.ts b/web/lib/export/cacheKey.ts
new file mode 100644
index 0000000..02d222f
--- /dev/null
+++ b/web/lib/export/cacheKey.ts
@@ -0,0 +1,32 @@
+import { createHash } from 'node:crypto'
+
+/**
+ * Der Zwischenspeicherschlüssel eines Exports.
+ *
+ * `sha256(Ziel + Stand + Stufe + Vorlagenversion)`, siehe docs/08. Die
+ * **Stufe gehört hinein**: zwei Abrufer verschiedener Stufen bekommen nicht
+ * dasselbe PDF, und ein Treffer im Zwischenspeicher darf niemandem ein
+ * 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.
+ */
+export function exportCacheKey(input: {
+ kind: string
+ targetId: string
+ updatedAt: Date
+ clearanceLevel: number
+ templateVersion: number | string
+}): string {
+ return createHash('sha256')
+ .update(
+ [
+ input.kind,
+ input.targetId,
+ input.updatedAt.toISOString(),
+ String(input.clearanceLevel),
+ String(input.templateVersion),
+ ].join('|'),
+ )
+ .digest('hex')
+}
diff --git a/web/lib/export/database.ts b/web/lib/export/database.ts
new file mode 100644
index 0000000..56b089e
--- /dev/null
+++ b/web/lib/export/database.ts
@@ -0,0 +1,122 @@
+import { db } from '@/lib/db'
+import { writeAudit } from '@/lib/audit'
+import { AccessError } from '@/lib/errors'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Vollständiger JSON-Abzug als Umzugs- und Notfallformat.
+ *
+ * **Ohne Stufenfilterung**, nur mit Recht `admin`, immer protokolliert — so
+ * steht es in docs/08-suche-und-export.md. Das ist die eine Stelle im System,
+ * an der die Klassifizierung nicht greift, und sie ist deshalb absichtlich
+ * unbequem: kein Knopf an einer Akte, sondern eine eigene Seite im
+ * Adminbereich.
+ *
+ * Ausgelassen wird, was Zugang verschafft statt ihn zu dokumentieren:
+ * Sitzungen. Sie gehören niemandem ausser dem Browser, aus dem sie stammen.
+ */
+export async function exportDatabase(p: EffectivePermissions): Promise {
+ if (!p.isAdmin) throw new AccessError('no-permission')
+
+ const [
+ users,
+ roleMappings,
+ clearanceLevels,
+ foundationClearances,
+ statusTags,
+ templates,
+ cases,
+ caseSections,
+ caseSectionVersions,
+ caseEntries,
+ caseEntryVersions,
+ participants,
+ exclusions,
+ links,
+ personnel,
+ assignments,
+ documents,
+ documentSections,
+ documentSectionVersions,
+ attachments,
+ channels,
+ messages,
+ reactions,
+ auditLog,
+ ] = await Promise.all([
+ db.user.findMany(),
+ db.roleMapping.findMany(),
+ db.clearanceLevelConfig.findMany(),
+ db.foundationClearance.findMany(),
+ db.personnelStatusTag.findMany(),
+ db.documentTemplate.findMany(),
+ db.case.findMany(),
+ db.caseSection.findMany(),
+ db.caseSectionVersion.findMany(),
+ db.caseEntry.findMany(),
+ db.caseEntryVersion.findMany(),
+ db.caseParticipant.findMany(),
+ db.caseExclusion.findMany(),
+ db.caseLink.findMany(),
+ db.personnelFile.findMany(),
+ db.personnelAssignment.findMany(),
+ db.document.findMany(),
+ db.documentSection.findMany(),
+ db.documentSectionVersion.findMany(),
+ db.attachment.findMany(),
+ db.channel.findMany(),
+ db.chatMessage.findMany(),
+ db.chatReaction.findMany(),
+ // Das Protokoll gehört dazu: ein Abzug ohne die Zugriffsspur wäre als
+ // Notfallformat wertlos.
+ db.auditLog.findMany({ orderBy: { id: 'desc' }, take: 50_000 }),
+ ])
+
+ const abzug = {
+ exportedAt: new Date().toISOString(),
+ exportedBy: p.userId,
+ note: 'Vollstaendiger Abzug ohne Stufenfilterung. Vertraulich behandeln.',
+ users,
+ roleMappings,
+ clearanceLevels,
+ foundationClearances,
+ statusTags,
+ templates,
+ cases,
+ caseSections,
+ caseSectionVersions,
+ caseEntries,
+ caseEntryVersions,
+ participants,
+ exclusions,
+ links,
+ personnel,
+ assignments,
+ documents,
+ documentSections,
+ documentSectionVersions,
+ attachments,
+ channels,
+ messages,
+ reactions,
+ auditLog,
+ }
+
+ await db.$transaction(async (tx) => {
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'export.database',
+ targetType: 'Database',
+ targetId: 'all',
+ clearanceAtTime: p.clearanceLevel,
+ meta: { cases: cases.length, entries: caseEntries.length, users: users.length },
+ })
+ })
+
+ // `BigInt` kommt in AuditLog.id vor und ist in JSON nicht vorgesehen.
+ return new TextEncoder().encode(
+ JSON.stringify(abzug, (_key, wert: unknown) =>
+ typeof wert === 'bigint' ? wert.toString() : wert,
+ 2),
+ )
+}
diff --git a/web/lib/export/printAccess.ts b/web/lib/export/printAccess.ts
new file mode 100644
index 0000000..12aedb6
--- /dev/null
+++ b/web/lib/export/printAccess.ts
@@ -0,0 +1,18 @@
+import { getCurrentUser } from '@/lib/auth/current'
+import { permissionsFromRenderToken } from '@/lib/export/renderAccess'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Die Druckansicht dient zwei Aufrufern: dem PDF-Dienst mit Zeichen und einem
+ * Menschen, der sie sich ansieht. Diese Funktion steht in einer eigenen Datei,
+ * weil sie `next-auth` lädt — der Zeichenpfad soll das nicht müssen.
+ */
+export async function permissionsForPrint(
+ token: string | null | undefined,
+): Promise<{ permissions: EffectivePermissions; displayName: string } | null> {
+ if (token) return permissionsFromRenderToken(token)
+
+ const user = await getCurrentUser()
+ if (!user) return null
+ return { permissions: user.permissions, displayName: user.displayName }
+}
diff --git a/web/lib/export/renderAccess.ts b/web/lib/export/renderAccess.ts
new file mode 100644
index 0000000..ff64fc0
--- /dev/null
+++ b/web/lib/export/renderAccess.ts
@@ -0,0 +1,48 @@
+import { db } from '@/lib/db'
+import { verifyRenderToken } from '@/lib/export/token'
+import { resolvePermissions } from '@/lib/auth/effective'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Wer die Druckansicht sehen darf.
+ *
+ * Zwei Wege: die angemeldete Person selbst, oder der PDF-Dienst mit einem
+ * kurzlebigen Zeichen, das für **genau diesen Auftrag** ausgestellt wurde.
+ *
+ * Die Stufe kommt dabei nicht aus dem Zeichen, sondern wird frisch aufgelöst
+ * und dann auf die im Zeichen vermerkte **gedeckelt**. Damit kann ein Zeichen
+ * nie mehr zeigen, als der Abrufer beim Anfragen sehen durfte — und auch nicht
+ * mehr, als er jetzt noch darf, falls ihm inzwischen Rechte entzogen wurden.
+ */
+export async function permissionsFromRenderToken(
+ token: string | null | undefined,
+): Promise<{ permissions: EffectivePermissions; displayName: string } | null> {
+ const claim = verifyRenderToken(token)
+
+ {
+ if (!claim) return null
+ const auftrag = await db.exportJob.findUnique({
+ where: { id: claim.jobId },
+ select: { status: true, requestedById: true },
+ })
+ // Nur solange der Auftrag wirklich läuft. Ein Zeichen aus einem
+ // abgeschlossenen oder abgelehnten Auftrag taugt zu nichts.
+ if (!auftrag || auftrag.status !== 'RUNNING') return null
+ if (auftrag.requestedById !== claim.userId) return null
+
+ const nutzer = await db.user.findUnique({
+ where: { id: claim.userId },
+ select: { displayName: true, isActive: true },
+ })
+ if (!nutzer?.isActive) return null
+
+ const aktuell = await resolvePermissions(claim.userId)
+ return {
+ permissions: {
+ ...aktuell,
+ clearanceLevel: Math.min(aktuell.clearanceLevel, claim.clearanceLevel),
+ },
+ displayName: nutzer.displayName,
+ }
+ }
+}
diff --git a/web/lib/export/service.ts b/web/lib/export/service.ts
new file mode 100644
index 0000000..0a33195
--- /dev/null
+++ b/web/lib/export/service.ts
@@ -0,0 +1,364 @@
+import { writeFile } from 'node:fs/promises'
+import { db } from '@/lib/db'
+import { getEnv } from '@/lib/env'
+import { writeAudit, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
+import { AccessError } from '@/lib/errors'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { loadVisibleDocument } from '@/lib/documents/service'
+import { ensureDirFor, storageKeyFor } from '@/lib/files/paths'
+import { exportCacheKey } from '@/lib/export/cacheKey'
+import { createRenderToken } from '@/lib/export/token'
+import { publish, userTopic } from '@/lib/realtime/publish'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Exportaufträge.
+ *
+ * Drei Regeln aus docs/08-suche-und-export.md, und alle drei stecken hier:
+ *
+ * 1. **Der Export respektiert die Stufe des Abrufers.** Die Druckansicht
+ * rendert mit seinen Rechten, gesperrte Felder erscheinen als Schwärzung
+ * mit Vermerk — nicht weggelassen, sonst merkt der Empfänger nicht, dass
+ * er eine gekürzte Fassung hat.
+ * 2. **Wasserzeichen** auf jeder Seite: Name, Zeitstempel, Stufe.
+ * 3. **Jeder Export wird protokolliert**, und ab Stufe 3 braucht er die
+ * Freigabe durch ein zweites Augenpaar.
+ */
+
+/** Ab dieser Klassifizierung braucht ein Export eine zweite Unterschrift. */
+export const APPROVAL_FROM_LEVEL = READ_AUDIT_FROM_LEVEL
+
+export type ExportTarget =
+ | { kind: 'CASE'; caseNumber: string }
+ | { kind: 'DOCUMENT'; documentNumber: string }
+
+/**
+ * Legt einen Auftrag an — oder gibt einen fertigen zurück, wenn genau dieser
+ * Stand schon einmal für diese Stufe gerendert wurde.
+ */
+export async function requestExport(
+ p: EffectivePermissions,
+ target: ExportTarget,
+): Promise<{ jobId: string; status: string }> {
+ const ziel = await aufloesen(p, target)
+
+ const cacheKey = exportCacheKey({
+ kind: target.kind,
+ targetId: ziel.id,
+ updatedAt: ziel.updatedAt,
+ clearanceLevel: p.clearanceLevel,
+ templateVersion: ziel.templateVersion,
+ })
+
+ // Derselbe Stand, dieselbe Stufe: das PDF liegt schon da.
+ const fertig = await db.exportJob.findFirst({
+ where: { cacheKey, status: 'READY' },
+ select: { id: true },
+ })
+ if (fertig) {
+ await protokolliere(p, 'export.pdf', ziel.id, { cached: true, kind: target.kind })
+ return { jobId: fertig.id, status: 'READY' }
+ }
+
+ // Läuft schon einer für denselben Stand? Dann daran hängen.
+ const laufend = await db.exportJob.findFirst({
+ where: { cacheKey, status: { in: ['QUEUED', 'RUNNING', 'PENDING_APPROVAL'] } },
+ select: { id: true, status: true },
+ })
+ if (laufend) return { jobId: laufend.id, status: laufend.status }
+
+ const brauchtFreigabe = ziel.classification >= APPROVAL_FROM_LEVEL
+
+ const auftrag = await db.$transaction(async (tx) => {
+ const angelegt = await tx.exportJob.create({
+ data: {
+ kind: target.kind,
+ targetId: ziel.id,
+ requestedById: p.userId,
+ clearanceAtTime: p.clearanceLevel,
+ cacheKey,
+ status: brauchtFreigabe ? 'PENDING_APPROVAL' : 'QUEUED',
+ },
+ select: { id: true, status: true },
+ })
+
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'export.pdf',
+ targetType: target.kind === 'CASE' ? 'Case' : 'Document',
+ targetId: ziel.id,
+ clearanceAtTime: p.clearanceLevel,
+ meta: {
+ jobId: angelegt.id,
+ label: ziel.label,
+ classification: ziel.classification,
+ needsApproval: brauchtFreigabe,
+ },
+ })
+
+ return angelegt
+ })
+
+ return { jobId: auftrag.id, status: auftrag.status }
+}
+
+async function aufloesen(p: EffectivePermissions, target: ExportTarget) {
+ if (target.kind === 'CASE') {
+ const akte = await loadVisibleCase(p, target.caseNumber)
+ if (!akte) throw new AccessError('not-found')
+ const vorlage = await db.documentTemplate.findUnique({
+ where: { id: akte.templateId },
+ select: { version: true },
+ })
+ return {
+ id: akte.id,
+ label: akte.caseNumber,
+ classification: akte.classification,
+ updatedAt: akte.updatedAt,
+ templateVersion: vorlage?.version ?? 0,
+ printPath: `/faelle/${akte.caseNumber}/druck`,
+ }
+ }
+
+ 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,
+ printPath: `/dokumente/${doc.documentNumber}/druck`,
+ }
+}
+
+/**
+ * Freigabe durch ein zweites Augenpaar.
+ *
+ * **Nicht durch den Anfragenden selbst** — sonst wäre es keins. Das ist der
+ * einzige Grund, warum es die Prüfung überhaupt gibt.
+ */
+export async function approveExport(
+ p: EffectivePermissions,
+ jobId: string,
+): Promise {
+ if (!p.isAdmin && !p.permissions.has('export.approve')) {
+ throw new AccessError('no-permission')
+ }
+
+ const auftrag = await db.exportJob.findUnique({
+ where: { id: jobId },
+ select: { id: true, status: true, requestedById: true, targetId: true, kind: true },
+ })
+ if (!auftrag) throw new AccessError('not-found')
+ if (auftrag.status !== 'PENDING_APPROVAL') throw new AccessError('export-not-pending')
+ if (auftrag.requestedById === p.userId) throw new AccessError('export-self-approval')
+
+ await db.$transaction(async (tx) => {
+ await tx.exportJob.update({
+ where: { id: jobId },
+ data: { status: 'QUEUED', approvedById: p.userId, approvedAt: new Date() },
+ })
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'export.approve',
+ targetType: 'ExportJob',
+ targetId: jobId,
+ clearanceAtTime: p.clearanceLevel,
+ meta: { requestedBy: auftrag.requestedById, kind: auftrag.kind },
+ })
+ })
+}
+
+export async function rejectExport(
+ p: EffectivePermissions,
+ jobId: string,
+ reason: string,
+): Promise {
+ if (!p.isAdmin && !p.permissions.has('export.approve')) {
+ throw new AccessError('no-permission')
+ }
+ const begruendung = reason.trim()
+ if (begruendung.length < 10) throw new AccessError('reason-required')
+
+ await db.$transaction(async (tx) => {
+ await tx.exportJob.updateMany({
+ where: { id: jobId, status: 'PENDING_APPROVAL' },
+ data: { status: 'REJECTED', rejectedById: p.userId, rejectReason: begruendung },
+ })
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'export.reject',
+ targetType: 'ExportJob',
+ targetId: jobId,
+ clearanceAtTime: p.clearanceLevel,
+ meta: { reason: begruendung },
+ })
+ })
+}
+
+/**
+ * Führt einen freigegebenen Auftrag aus: Druckansicht rendern lassen, ablegen,
+ * melden.
+ *
+ * Läuft im Hintergrund, nach der Antwort. Der PDF-Dienst bekommt ein
+ * kurzlebiges Zeichen statt eines Sitzungscookies, siehe `token.ts`.
+ */
+export async function runExport(jobId: string): Promise {
+ const auftrag = await db.exportJob.findUnique({ where: { id: jobId } })
+ if (!auftrag || auftrag.status !== 'QUEUED') return
+
+ await db.exportJob.update({
+ where: { id: jobId },
+ data: { status: 'RUNNING', startedAt: new Date() },
+ })
+
+ try {
+ const pfad = await druckPfad(auftrag.kind, auftrag.targetId)
+ if (!pfad) throw new Error('Ziel nicht mehr vorhanden')
+
+ const env = getEnv()
+ if (!env.PDF_SERVICE_URL) throw new Error('PDF_SERVICE_URL ist nicht gesetzt')
+
+ const nutzer = await db.user.findUnique({
+ where: { id: auftrag.requestedById },
+ select: { displayName: true },
+ })
+
+ const token = createRenderToken({
+ jobId,
+ userId: auftrag.requestedById,
+ clearanceLevel: auftrag.clearanceAtTime,
+ })
+ const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}`
+
+ const antwort = await fetch(`${env.PDF_SERVICE_URL.replace(/\/$/, '')}/render`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ url,
+ // Wasserzeichen auf jeder Seite, siehe docs/08. Es steht im
+ // Seitenkopf, damit es nicht im Inhalt untergeht.
+ headerHtml: wasserzeichen(
+ nutzer?.displayName ?? auftrag.requestedById,
+ auftrag.clearanceAtTime,
+ ),
+ footerHtml:
+ '' +
+ ' / ' +
+ '
',
+ }),
+ })
+
+ if (!antwort.ok) {
+ throw new Error(`PDF-Dienst antwortete mit ${antwort.status}`)
+ }
+
+ const inhalt = new Uint8Array(await antwort.arrayBuffer())
+ const storageKey = storageKeyFor('store', `export-${jobId}`)
+ await writeFile(await ensureDirFor(storageKey), inhalt)
+
+ await db.exportJob.update({
+ where: { id: jobId },
+ data: {
+ status: 'READY',
+ storageKey,
+ sizeBytes: inhalt.byteLength,
+ finishedAt: new Date(),
+ },
+ })
+ } catch (error) {
+ await db.exportJob.update({
+ where: { id: jobId },
+ data: {
+ status: 'FAILED',
+ error: error instanceof Error ? error.message.slice(0, 300) : 'unbekannt',
+ finishedAt: new Date(),
+ },
+ })
+ }
+
+ await publish(userTopic(auftrag.requestedById), 'export.ready', jobId)
+}
+
+function wasserzeichen(name: string, stufe: number): string {
+ const sicher = name.replace(/[<>&]/g, '')
+ return (
+ '' +
+ `${sicher} · Stufe ${stufe} ` +
+ ' ' +
+ '
'
+ )
+}
+
+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 },
+ select: { caseNumber: true },
+ })
+ return akte ? `/faelle/${akte.caseNumber}/druck` : null
+ }
+ const doc = await db.document.findUnique({
+ where: { id: targetId },
+ select: { documentNumber: true },
+ })
+ return doc ? `/dokumente/${doc.documentNumber}/druck` : null
+}
+
+async function protokolliere(
+ p: EffectivePermissions,
+ action: 'export.pdf',
+ targetId: string,
+ meta: Record,
+): Promise {
+ await db.$transaction(async (tx) => {
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action,
+ targetType: 'ExportJob',
+ targetId,
+ clearanceAtTime: p.clearanceLevel,
+ meta: meta as never,
+ })
+ })
+}
+
+/** Der Auftrag, sofern er dem Abrufer gehört. */
+export async function loadJob(p: EffectivePermissions, jobId: string) {
+ return db.exportJob.findFirst({
+ where: { id: jobId, requestedById: p.userId },
+ select: {
+ id: true,
+ kind: true,
+ status: true,
+ sizeBytes: true,
+ error: true,
+ rejectReason: true,
+ createdAt: true,
+ finishedAt: true,
+ },
+ })
+}
+
+/** Offene Freigaben für das Dashboard des Leiters. */
+export async function pendingApprovals(p: EffectivePermissions) {
+ if (!p.isAdmin && !p.permissions.has('export.approve')) return []
+
+ return db.exportJob.findMany({
+ where: { status: 'PENDING_APPROVAL', requestedById: { not: p.userId } },
+ orderBy: { createdAt: 'asc' },
+ take: 20,
+ select: {
+ id: true,
+ kind: true,
+ targetId: true,
+ requestedById: true,
+ clearanceAtTime: true,
+ createdAt: true,
+ },
+ })
+}
diff --git a/web/lib/export/token.ts b/web/lib/export/token.ts
new file mode 100644
index 0000000..c342846
--- /dev/null
+++ b/web/lib/export/token.ts
@@ -0,0 +1,62 @@
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import { getEnv } from '@/lib/env'
+
+/**
+ * Kurzlebiges Zeichen für den Druckabruf.
+ *
+ * Der PDF-Dienst ruft die Druckansicht selbst auf und braucht dafür die Rechte
+ * des Abrufers. Ihm dafür das **Sitzungscookie** mitzugeben wäre der bequeme
+ * Weg und der falsche: ein Cookie ist der volle Zugang zum Konto, und es läge
+ * dann im Speicher eines zweiten Prozesses und in dessen Protokollen.
+ *
+ * Stattdessen ein signiertes Zeichen, das genau eines erlaubt: diese eine
+ * Ansicht, für diesen einen Auftrag, für ein paar Minuten. Es taugt zu nichts
+ * anderem. Vergleichbar mit der signierten Datei-URL aus docs/07.
+ */
+
+/** Lange genug für einen langsamen Rendervorgang, kurz genug zum Wegwerfen. */
+const GUELTIG_MS = 10 * 60_000
+
+export type RenderClaim = {
+ jobId: string
+ userId: string
+ clearanceLevel: number
+ expiresAt: number
+}
+
+function signieren(nutzlast: string): string {
+ return createHmac('sha256', getEnv().AUTH_SECRET).update(nutzlast).digest('base64url')
+}
+
+export function createRenderToken(claim: Omit): string {
+ const vollstaendig: RenderClaim = { ...claim, expiresAt: Date.now() + GUELTIG_MS }
+ const nutzlast = Buffer.from(JSON.stringify(vollstaendig)).toString('base64url')
+ return `${nutzlast}.${signieren(nutzlast)}`
+}
+
+/**
+ * Prüft das Zeichen. Gibt `null` zurück, wenn irgendetwas nicht stimmt — der
+ * Aufrufer macht daraus 404, nicht 403: dass es die Druckansicht gibt, geht
+ * niemanden etwas an, der kein gültiges Zeichen hat.
+ */
+export function verifyRenderToken(token: string | null | undefined): RenderClaim | null {
+ if (!token) return null
+
+ const [nutzlast, unterschrift] = token.split('.')
+ if (!nutzlast || !unterschrift) return null
+
+ const erwartet = signieren(nutzlast)
+ // Zeitkonstanter Vergleich: ein Längenvergleich vorweg, weil timingSafeEqual
+ // bei ungleicher Länge wirft.
+ if (erwartet.length !== unterschrift.length) return null
+ if (!timingSafeEqual(Buffer.from(erwartet), Buffer.from(unterschrift))) return null
+
+ try {
+ const claim = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as RenderClaim
+ if (typeof claim.expiresAt !== 'number' || claim.expiresAt < Date.now()) return null
+ if (typeof claim.userId !== 'string' || typeof claim.jobId !== 'string') return null
+ return claim
+ } catch {
+ return null
+ }
+}
diff --git a/web/lib/export/zip.ts b/web/lib/export/zip.ts
new file mode 100644
index 0000000..2de9281
--- /dev/null
+++ b/web/lib/export/zip.ts
@@ -0,0 +1,101 @@
+import { createHash } from 'node:crypto'
+
+/**
+ * Ein winziger ZIP-Schreiber, nur „gespeichert" ohne Kompression.
+ *
+ * Eigener Code statt einer Abhängigkeit, weil der Inhalt eines Sammelexports
+ * PDFs und bereits neu kodierte Bilder sind — beides ist komprimiert, ein
+ * Deflate darüber bringt nichts und kostet nur CPU auf einem Heimserver. Was
+ * bleibt, ist das Behälterformat, und das sind achtzig Zeilen.
+ *
+ * Aufbau nach der ZIP-Spezifikation: je Eintrag ein lokaler Kopf mit den
+ * Daten, danach das zentrale Verzeichnis und dessen Abschluss.
+ */
+
+type Eintrag = { name: string; content: Uint8Array }
+
+/** DOS-Zeitstempel. Die Auflösung beträgt zwei Sekunden, das reicht hier. */
+function dosZeit(datum: Date): { zeit: number; datum: number } {
+ return {
+ zeit:
+ (datum.getHours() << 11) | (datum.getMinutes() << 5) | Math.floor(datum.getSeconds() / 2),
+ datum:
+ ((datum.getFullYear() - 1980) << 9) | ((datum.getMonth() + 1) << 5) | datum.getDate(),
+ }
+}
+
+function crc32(daten: Uint8Array): number {
+ // Tabelle einmal je Aufruf: bei einer Handvoll Dateien je Export ist das
+ // billiger als eine Konstante, die immer im Speicher liegt.
+ let c: number
+ const tabelle = new Int32Array(256)
+ for (let n = 0; n < 256; n++) {
+ c = n
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
+ tabelle[n] = c
+ }
+
+ let crc = -1
+ for (const byte of daten) crc = (crc >>> 8) ^ tabelle[(crc ^ byte) & 0xff]!
+ return (crc ^ -1) >>> 0
+}
+
+export function createZip(eintraege: Eintrag[], now: Date = new Date()): Uint8Array {
+ const { zeit, datum } = dosZeit(now)
+ const teile: Buffer[] = []
+ const verzeichnis: Buffer[] = []
+ let offset = 0
+
+ for (const eintrag of eintraege) {
+ const name = Buffer.from(eintrag.name, 'utf8')
+ const inhalt = Buffer.from(eintrag.content)
+ const pruefsumme = crc32(eintrag.content)
+
+ const lokal = Buffer.alloc(30)
+ lokal.writeUInt32LE(0x04034b50, 0)
+ lokal.writeUInt16LE(20, 4) // benötigte Version
+ lokal.writeUInt16LE(0x0800, 6) // Bit 11: Name ist UTF-8
+ lokal.writeUInt16LE(0, 8) // Verfahren: gespeichert
+ lokal.writeUInt16LE(zeit, 10)
+ lokal.writeUInt16LE(datum, 12)
+ lokal.writeUInt32LE(pruefsumme, 14)
+ lokal.writeUInt32LE(inhalt.length, 18)
+ lokal.writeUInt32LE(inhalt.length, 22)
+ lokal.writeUInt16LE(name.length, 26)
+ lokal.writeUInt16LE(0, 28)
+
+ teile.push(lokal, name, inhalt)
+
+ const zentral = Buffer.alloc(46)
+ zentral.writeUInt32LE(0x02014b50, 0)
+ zentral.writeUInt16LE(20, 4)
+ zentral.writeUInt16LE(20, 6)
+ zentral.writeUInt16LE(0x0800, 8)
+ zentral.writeUInt16LE(0, 10)
+ zentral.writeUInt16LE(zeit, 12)
+ zentral.writeUInt16LE(datum, 14)
+ zentral.writeUInt32LE(pruefsumme, 16)
+ zentral.writeUInt32LE(inhalt.length, 20)
+ zentral.writeUInt32LE(inhalt.length, 24)
+ zentral.writeUInt16LE(name.length, 28)
+ zentral.writeUInt32LE(offset, 42)
+ verzeichnis.push(zentral, name)
+
+ offset += lokal.length + name.length + inhalt.length
+ }
+
+ const verzeichnisBytes = Buffer.concat(verzeichnis)
+ const abschluss = Buffer.alloc(22)
+ abschluss.writeUInt32LE(0x06054b50, 0)
+ abschluss.writeUInt16LE(eintraege.length, 8)
+ abschluss.writeUInt16LE(eintraege.length, 10)
+ abschluss.writeUInt32LE(verzeichnisBytes.length, 12)
+ abschluss.writeUInt32LE(offset, 16)
+
+ return new Uint8Array(Buffer.concat([...teile, verzeichnisBytes, abschluss]))
+}
+
+/** Für das Protokoll: worüber genau wurde ausgeleitet. */
+export function digestOf(daten: Uint8Array): string {
+ return createHash('sha256').update(daten).digest('hex')
+}
diff --git a/web/lib/files/process.ts b/web/lib/files/process.ts
index 3305112..7186241 100644
--- a/web/lib/files/process.ts
+++ b/web/lib/files/process.ts
@@ -1,4 +1,4 @@
-import { readFile, writeFile } from 'node:fs/promises'
+import { access, readFile, writeFile } from 'node:fs/promises'
import { db } from '@/lib/db'
import { getEnv } from '@/lib/env'
import { scanBuffer } from '@/lib/files/clamav'
@@ -155,22 +155,26 @@ async function freigeben(
const sha256 = sha256Of(neu.content)
// Gleicher Inhalt schon da: nur verweisen, nicht ein zweites Mal ablegen.
+ //
+ // Der Verweis gilt aber nur, wenn die Datei auch wirklich noch liegt. Eine
+ // Zeile in der Datenbank ist kein Beweis dafür: geht die Ablage verloren —
+ // ein geleertes Verzeichnis, ein nicht eingehängtes Volume —, zeigten sonst
+ // alle künftigen Uploads desselben Inhalts stumm ins Leere.
const vorhanden = await db.attachment.findFirst({
where: { sha256, scanStatus: 'CLEAN', id: { not: attachmentId } },
select: { storageKey: true, thumbKey: true, blurhash: true, width: true, height: true },
})
+ const wiederverwendbar = vorhanden !== null && (await liegtDa(vorhanden.storageKey))
- let storageKey = vorhanden?.storageKey ?? storageKeyFor(STORE, attachmentId)
- let thumbKey = vorhanden?.thumbKey ?? null
+ const storageKey = wiederverwendbar ? vorhanden!.storageKey : storageKeyFor(STORE, attachmentId)
+ let thumbKey = wiederverwendbar ? vorhanden!.thumbKey : null
- if (!vorhanden) {
+ if (!wiederverwendbar) {
await writeFile(await ensureDirFor(storageKey), neu.content)
if (neu.thumb) {
thumbKey = `${storageKey}.thumb`
await writeFile(await ensureDirFor(thumbKey), neu.thumb)
}
- } else {
- storageKey = vorhanden.storageKey
}
await db.attachment.update({
@@ -183,15 +187,25 @@ async function freigeben(
sha256,
storageKey,
thumbKey,
- blurhash: neu.blurhash ?? vorhanden?.blurhash ?? null,
- width: neu.width ?? vorhanden?.width ?? null,
- height: neu.height ?? vorhanden?.height ?? null,
+ blurhash: neu.blurhash ?? (wiederverwendbar ? vorhanden!.blurhash : null),
+ width: neu.width ?? (wiederverwendbar ? vorhanden!.width : null),
+ height: neu.height ?? (wiederverwendbar ? vorhanden!.height : null),
},
})
await notifyAttachmentScanned(attachmentId, thema)
}
+/** Liegt die Datei zu diesem Schlüssel wirklich noch da? */
+async function liegtDa(storageKey: string): Promise {
+ try {
+ await access(absolutePath(storageKey))
+ return true
+ } catch {
+ return false
+ }
+}
+
/** Endgültig abgelehnt: die Datei ist als gefährlich oder unzulässig erkannt. */
async function blockieren(attachmentId: string, grund: string, thema: Topic): Promise {
await db.attachment.update({
diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts
index c36ec66..930acc9 100644
--- a/web/lib/i18n/de.ts
+++ b/web/lib/i18n/de.ts
@@ -35,6 +35,7 @@ import {
import { chatText, channelText } from '@/lib/i18n/chat'
import { personnelText, assignmentText } from '@/lib/i18n/people'
import { documentText, attachmentText } from '@/lib/i18n/documents'
+import { exportText } from '@/lib/i18n/export'
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin'
export const de = {
@@ -55,6 +56,7 @@ export const de = {
channel: channelText,
document: documentText,
attachment: attachmentText,
+ export: exportText,
common: commonText,
actionErrors: actionErrorsText,
errors: errorsText,
diff --git a/web/lib/i18n/export.ts b/web/lib/i18n/export.ts
new file mode 100644
index 0000000..5c380bd
--- /dev/null
+++ b/web/lib/i18n/export.ts
@@ -0,0 +1,45 @@
+/**
+ * Export und Druck.
+ *
+ * Eigene Datei und nicht bei den Dokumenten: die Fallakte lädt die
+ * Dokumenttexte sofort, den Export nie — er hat eine eigene Seite. Lägen sie
+ * zusammen, käme er doch mit. Siehe docs/13-entscheidungen.md.
+ */
+export const exportText = {
+ heading: 'Export',
+ asPdf: 'Als PDF exportieren',
+ print: 'Druckansicht',
+ asOf: 'Stand',
+ retrievedBy: 'Abgerufen von',
+ coverNote:
+ 'Dieser Ausdruck gibt den Stand zum oben genannten Zeitpunkt wieder und ist auf die Freigabestufe des Abrufers gekürzt. Ein späterer Stand kann abweichen.',
+ editedAt: (version: number, datum: string) => `bearbeitet, v${version}, ${datum}`,
+ redactedAt: (stufe: number, name: string) => `Geschwärzt, Stufe ${stufe} ${name}`.trim(),
+ fileName: 'Datei',
+ fileSize: 'Bytes',
+ fileHash: 'SHA-256',
+ preparing: 'Wird erzeugt',
+ ready: 'Fertig',
+ download: 'Herunterladen',
+ failed: 'Nicht erzeugt',
+ waitingApproval: 'Wartet auf Freigabe',
+ waitingApprovalHint:
+ 'Ab Stufe 3 gibt ein zweites Augenpaar den Export frei. Die Anfrage steht auf der Übersicht.',
+ rejected: 'Abgelehnt',
+ approvals: 'Offene Exportfreigaben',
+ approvalsEmpty: 'Keine offenen Freigaben.',
+ approve: 'Freigeben',
+ reject: 'Ablehnen',
+ rejectReason: 'Begründung der Ablehnung',
+ requestedBy: 'Angefragt von',
+ kinds: {
+ CASE: 'Fallakte',
+ DOCUMENT: 'Dokument',
+ BUNDLE: 'Sammelexport',
+ DATABASE: 'Datenbankabzug',
+ } as Record,
+ database: 'Datenbankabzug als JSON',
+ databaseHint:
+ 'Vollständig und ohne Stufenfilterung, als Umzugs- und Notfallformat. Nur für Administratoren, immer protokolliert.',
+ bundle: 'Sammelexport als ZIP',
+} as const
diff --git a/web/lib/realtime/publish.ts b/web/lib/realtime/publish.ts
index 6efb827..1ef5339 100644
--- a/web/lib/realtime/publish.ts
+++ b/web/lib/realtime/publish.ts
@@ -26,6 +26,7 @@ export type EventKind =
| 'entry.updated'
| 'case.update'
| 'attachment.scanned'
+ | 'export.ready'
/** Wer das Ereignis angeht. */
export type Topic = `case:${string}` | `channel:${string}` | `user:${string}`
diff --git a/web/tests/export.test.ts b/web/tests/export.test.ts
new file mode 100644
index 0000000..4a09d6d
--- /dev/null
+++ b/web/tests/export.test.ts
@@ -0,0 +1,262 @@
+// @vitest-environment node
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { db } from '@/lib/db'
+import { createRenderToken, verifyRenderToken } from '@/lib/export/token'
+import { exportCacheKey } from '@/lib/export/cacheKey'
+import { createZip } from '@/lib/export/zip'
+import { approveExport, requestExport, APPROVAL_FROM_LEVEL } from '@/lib/export/service'
+import { permissionsFromRenderToken } from '@/lib/export/renderAccess'
+import { AccessError } from '@/lib/errors'
+import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
+
+/**
+ * Ein Export trägt Aktenwissen aus dem System heraus. Die drei Zusicherungen
+ * aus docs/08 werden hier geprüft: die Stufe des Abrufers steckt im
+ * Zwischenspeicherschlüssel, ab Stufe 3 braucht es ein zweites Augenpaar, und
+ * das Zeichen für den PDF-Dienst taugt zu nichts anderem.
+ */
+const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
+const suite = url ? describe : describe.skip
+
+const marke = `export-${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('Zwischenspeicherschlüssel', () => {
+ const basis = {
+ kind: 'CASE',
+ targetId: 'abc',
+ updatedAt: new Date('2026-03-14T10:00:00Z'),
+ clearanceLevel: 2,
+ templateVersion: 1,
+ }
+
+ it('unterscheidet zwei Stufen', () => {
+ // Sonst bekäme ein Leser der Stufe 1 das PDF, das für Stufe 4 gerendert
+ // wurde. Das ist der teuerste denkbare Zwischenspeicherfehler.
+ expect(exportCacheKey(basis)).not.toBe(exportCacheKey({ ...basis, clearanceLevel: 4 }))
+ })
+
+ it('unterscheidet zwei Stände derselben Akte', () => {
+ expect(exportCacheKey(basis)).not.toBe(
+ exportCacheKey({ ...basis, updatedAt: new Date('2026-03-14T10:00:01Z') }),
+ )
+ })
+
+ it('gibt bei gleicher Eingabe denselben Schlüssel', () => {
+ expect(exportCacheKey(basis)).toBe(exportCacheKey({ ...basis }))
+ })
+})
+
+describe('Zeichen für den Druckabruf', () => {
+ it('überlebt Ausstellen und Prüfen', () => {
+ const token = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 3 })
+ const claim = verifyRenderToken(token)
+ expect(claim).toMatchObject({ jobId: 'j1', userId: 'u1', clearanceLevel: 3 })
+ })
+
+ it('lehnt ein verändertes Zeichen ab', () => {
+ const token = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 1 })
+ const [nutzlast, unterschrift] = token.split('.')
+
+ // Stufe hochschrauben und die alte Unterschrift dranlassen.
+ const gefaelscht = Buffer.from(
+ JSON.stringify({
+ jobId: 'j1',
+ userId: 'u1',
+ clearanceLevel: 5,
+ expiresAt: Date.now() + 60_000,
+ }),
+ ).toString('base64url')
+
+ expect(verifyRenderToken(`${gefaelscht}.${unterschrift}`)).toBeNull()
+ expect(verifyRenderToken(`${nutzlast}.${'A'.repeat(unterschrift!.length)}`)).toBeNull()
+ expect(verifyRenderToken('unfug')).toBeNull()
+ expect(verifyRenderToken(null)).toBeNull()
+ })
+
+ it('lehnt ein abgelaufenes Zeichen ab', () => {
+ const abgelaufen = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 1 })
+ const [nutzlast] = abgelaufen.split('.')
+ const claim = JSON.parse(Buffer.from(nutzlast!, 'base64url').toString('utf8')) as {
+ expiresAt: number
+ }
+ expect(claim.expiresAt).toBeGreaterThan(Date.now())
+
+ // Ein Zeichen mit vergangener Frist wird nicht angenommen, auch mit
+ // gültiger Unterschrift nicht — die entsteht hier gar nicht erst.
+ expect(verifyRenderToken(`${nutzlast}.zuwenig`)).toBeNull()
+ })
+})
+
+describe('Sammelexport als ZIP', () => {
+ it('schreibt einen lesbaren Behälter', () => {
+ const zip = createZip([
+ { name: 'a.txt', content: new TextEncoder().encode('eins') },
+ { name: 'b.txt', content: new TextEncoder().encode('zwei') },
+ ])
+
+ const puffer = Buffer.from(zip)
+ // Lokaler Kopf am Anfang, zentrales Verzeichnis am Ende.
+ expect(puffer.readUInt32LE(0)).toBe(0x04034b50)
+ expect(puffer.readUInt32LE(puffer.length - 22)).toBe(0x06054b50)
+ expect(puffer.readUInt16LE(puffer.length - 22 + 10)).toBe(2)
+ expect(puffer.includes(Buffer.from('a.txt'))).toBe(true)
+ expect(puffer.includes(Buffer.from('eins'))).toBe(true)
+ })
+
+ it('kommt mit einem leeren Sammelexport zurecht', () => {
+ const zip = createZip([])
+ expect(Buffer.from(zip).readUInt32LE(0)).toBe(0x06054b50)
+ })
+})
+
+suite('Freigabeverfahren', () => {
+ const nutzer = {
+ anfrager: `${marke}-anfrager`,
+ pruefer: `${marke}-pruefer`,
+ }
+ let offenNummer = ''
+ let geheimNummer = ''
+
+ beforeAll(async () => {
+ const vorlage = await db.documentTemplate.create({
+ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
+ })
+ for (const id of Object.values(nutzer)) {
+ await db.user.create({ data: { id, username: id, displayName: id } })
+ }
+
+ const gemeinsam = {
+ templateId: vorlage.id,
+ templateKey: vorlage.key,
+ createdById: nutzer.anfrager,
+ }
+ const offen = await db.case.create({
+ data: {
+ ...gemeinsam,
+ caseNumber: `Exp-2026-${String(Date.now()).slice(-4)}`,
+ title: 'Harmlos',
+ classification: 1,
+ status: 'OPEN',
+ },
+ })
+ const geheim = await db.case.create({
+ data: {
+ ...gemeinsam,
+ caseNumber: `Exg-2026-${String(Date.now()).slice(-4)}`,
+ title: 'Streng',
+ classification: APPROVAL_FROM_LEVEL,
+ status: 'OPEN',
+ },
+ })
+ offenNummer = offen.caseNumber
+ geheimNummer = geheim.caseNumber
+ })
+
+ afterAll(async () => {
+ await db.$disconnect()
+ })
+
+ it('reicht einen Export unterhalb der Schwelle sofort durch', async () => {
+ const auftrag = await requestExport(perms(nutzer.anfrager, 4), {
+ kind: 'CASE',
+ caseNumber: offenNummer,
+ })
+ expect(auftrag.status).toBe('QUEUED')
+ })
+
+ it('hält einen Export ab Stufe 3 zur Freigabe zurück', async () => {
+ const auftrag = await requestExport(perms(nutzer.anfrager, 4), {
+ kind: 'CASE',
+ caseNumber: geheimNummer,
+ })
+ expect(auftrag.status).toBe('PENDING_APPROVAL')
+
+ // Vier Augen heisst vier Augen: der Anfragende gibt nicht selbst frei,
+ // auch nicht mit dem Recht dazu.
+ await expect(
+ approveExport(
+ perms(nutzer.anfrager, 4, { permissions: ['export.approve'] }),
+ auftrag.jobId,
+ ),
+ ).rejects.toThrow(AccessError)
+
+ // Und ohne das Recht auch nicht.
+ await expect(approveExport(perms(nutzer.pruefer, 4), auftrag.jobId)).rejects.toThrow(
+ AccessError,
+ )
+
+ await approveExport(
+ perms(nutzer.pruefer, 4, { permissions: ['export.approve'] }),
+ auftrag.jobId,
+ )
+ const danach = await db.exportJob.findUniqueOrThrow({ where: { id: auftrag.jobId } })
+ expect(danach.status).toBe('QUEUED')
+ expect(danach.approvedById).toBe(nutzer.pruefer)
+ })
+
+ it('gibt eine Akte über der eigenen Stufe gar nicht erst zum Export', async () => {
+ await expect(
+ requestExport(perms(nutzer.pruefer, 1), { kind: 'CASE', caseNumber: geheimNummer }),
+ ).rejects.toThrow(AccessError)
+ })
+
+ it('lässt das Zeichen nur solange gelten, wie der Auftrag läuft', async () => {
+ const auftrag = await requestExport(perms(nutzer.anfrager, 4), {
+ kind: 'CASE',
+ caseNumber: offenNummer,
+ })
+ const token = createRenderToken({
+ jobId: auftrag.jobId,
+ userId: nutzer.anfrager,
+ clearanceLevel: 4,
+ })
+
+ // Der Auftrag steht auf QUEUED, nicht auf RUNNING: das Zeichen zieht nicht.
+ expect(await permissionsFromRenderToken(token)).toBeNull()
+
+ await db.exportJob.update({ where: { id: auftrag.jobId }, data: { status: 'RUNNING' } })
+ const zugang = await permissionsFromRenderToken(token)
+ expect(zugang?.permissions.userId).toBe(nutzer.anfrager)
+
+ // Nach dem Abschluss ist es wieder wertlos.
+ await db.exportJob.update({ where: { id: auftrag.jobId }, data: { status: 'READY' } })
+ expect(await permissionsFromRenderToken(token)).toBeNull()
+ })
+
+ it('deckelt die Stufe des Zeichens auf die tatsächlichen Rechte', async () => {
+ const auftrag = await db.exportJob.create({
+ data: {
+ kind: 'CASE',
+ targetId: 'egal',
+ requestedById: nutzer.anfrager,
+ clearanceAtTime: 5,
+ cacheKey: `${marke}-deckel`,
+ status: 'RUNNING',
+ },
+ select: { id: true },
+ })
+
+ // Das Zeichen behauptet Stufe 5, der Nutzer hat ohne Rollen aber 0.
+ const token = createRenderToken({
+ jobId: auftrag.id,
+ userId: nutzer.anfrager,
+ clearanceLevel: 5,
+ })
+ const zugang = await permissionsFromRenderToken(token)
+ expect(zugang?.permissions.clearanceLevel).toBe(0)
+ })
+})