Phase 8 abgeschlossen: Export als PDF, Wasserzeichen, Vier-Augen-Freigabe

Die Druckansicht ist die einzige Vorlage für den Export: kein zweites Layout
von Hand in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite
auf und bekommt dafür ein signiertes, kurzlebiges Zeichen statt eines
Sitzungscookies. Es gilt nur, solange der Auftrag läuft, ist an den Anfragenden
gebunden, und die Stufe wird beim Einlösen frisch aufgelöst und auf die im
Zeichen vermerkte gedeckelt.

Der Export darf nie sauberer aussehen als die Akte: gesperrte Felder erscheinen
als Balken mit Vermerk statt zu fehlen, geänderte Einträge tragen ihre Version,
zurückgezogene stehen als Grabstein. Wasserzeichen mit Name, Zeitstempel und
Stufe auf jeder Seite.

Die Stufe steckt im Zwischenspeicherschlüssel. Ohne sie bekäme ein Leser der
Stufe 1 das PDF, das für Stufe 4 gerendert wurde.

Ab Stufe 3 wartet der Auftrag auf Freigabe durch ein zweites Augenpaar - und
der Anfragende darf sich nicht selbst freigeben, auch nicht mit dem Recht
dazu. Offene Freigaben stehen auf der Übersicht.

Dazu Einzeldokumente als PDF (Aktenblatt bleibt Aktenblatt), ZIP-Sammelexport
mit eigenem achtzigzeiligem Schreiber statt einer Abhängigkeit, und der
JSON-Vollabzug ohne Stufenfilterung - die einzige Stelle, an der die
Klassifizierung nicht greift, deshalb hinter einem eigenen Admin-Endpunkt.

Zwei Funde unterwegs, beide in docs/13 festgehalten:

Die Druckansicht warf notFound(), und das Skelett aus dem Wurzel-loading.tsx
blieb im Browser stehen - der PDF-Dienst hätte leere Blätter gedruckt.
Gefunden nur, weil der e2e-Test auf den Inhalt prüft und nicht auf den
Statuscode. Sie rendert die Auskunft jetzt selbst.

Und die Entdopplung der Anhänge verwies auf Dateien, ohne nachzusehen, ob sie
noch daliegen. Nach einem geleerten /tmp zeigte jeder weitere Upload desselben
Inhalts stumm ins Leere.

178 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 44 e2e.
Fallakte bei 119,8 von 120 kB - das Budget ist aufgebraucht.
This commit is contained in:
Harlekin
2026-09-03 10:01:57 +02:00
parent cb0eabb266
commit f1106a57d6
31 changed files with 2429 additions and 13 deletions
@@ -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;
+61
View File
@@ -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.
+11
View File
@@ -69,6 +69,17 @@ export default async function SystemPage() {
<SystemState />
</div>
</Suspense>
<section className="mt-10">
<h2 className="font-condensed">{de.export.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.export.databaseHint}
</p>
<p className="mt-3 flex flex-wrap gap-4 font-condensed text-xs">
<a href="/api/admin/export?kind=database">{de.export.database}</a>
<a href="/api/admin/export?kind=bundle">{de.export.bundle}</a>
</p>
</section>
</main>
)
}
@@ -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 (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<p className="font-mono text-xs text-text-dim">{doc.documentNumber}</p>
<h1 className="mt-1 font-condensed">{de.export.heading}</h1>
<div className="mt-6">
<ExportPanel
target={{ kind: 'DOCUMENT', documentNumber: doc.documentNumber }}
topic={`user:${user.id}`}
/>
</div>
{doc.classification >= APPROVAL_FROM_LEVEL ? (
<p className="mt-4 text-2xs text-stamp-3">{de.export.waitingApprovalHint}</p>
) : null}
<nav className="mt-8 flex gap-4 font-condensed text-xs">
<Link href={`/dokumente/${doc.documentNumber}`}>{doc.documentNumber}</Link>
<Link href={`/dokumente/${doc.documentNumber}/druck`}>{de.export.print}</Link>
</nav>
</main>
)
}
@@ -212,6 +212,10 @@ export default async function DocumentPage({
</div>
</dl>
<p className="no-print mt-2 text-xs">
<Link href={`/dokumente/${doc.documentNumber}/export`}>{de.export.asPdf}</Link>
</p>
{besitzer ? (
<p className="mt-2 text-xs">
{'caseNumber' in besitzer ? (
+117
View File
@@ -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<string | null>(null)
const [grund, setGrund] = useState('')
const [fehler, setFehler] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
if (jobs.length === 0) {
return <p className="text-text-dim">{exportText.approvalsEmpty}</p>
}
return (
<div>
<ul className="border-t border-line">
{jobs.map((auftrag) => (
<li key={auftrag.id} className="border-b border-line py-2">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-2xs">
<span className="font-condensed text-text-dim">
{exportText.kinds[auftrag.kind] ?? auftrag.kind}
</span>
<span className="font-mono">{auftrag.label}</span>
<span className="grow text-text-dim">
{exportText.requestedBy} {auftrag.requestedBy}
</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const ergebnis = await approveExportAction({ id: auftrag.id })
if (ergebnis.error) setFehler(ergebnis.error)
})
}
className="border border-line bg-raised px-2 py-0.5 font-condensed text-accent"
>
{exportText.approve}
</button>
<button
type="button"
onClick={() => setAblehnung(auftrag.id)}
className="font-condensed text-text-dim"
>
{exportText.reject}
</button>
</div>
{ablehnung === auftrag.id ? (
<div className="mt-2">
<textarea
value={grund}
onChange={(event) => setGrund(event.target.value)}
rows={2}
placeholder={exportText.rejectReason}
aria-label={exportText.rejectReason}
className="block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
/>
<div className="mt-1 flex gap-3 text-2xs">
<button
type="button"
disabled={pending || grund.trim().length < 10}
onClick={() =>
startTransition(async () => {
const ergebnis = await rejectExportAction({
id: auftrag.id,
reason: grund,
})
if (ergebnis.error) setFehler(ergebnis.error)
else {
setAblehnung(null)
setGrund('')
}
})
}
className="font-condensed text-stamp-4 disabled:text-text-dim"
>
{exportText.reject}
</button>
<button
type="button"
onClick={() => setAblehnung(null)}
className="font-condensed text-text-dim"
>
{commonText.cancel}
</button>
</div>
</div>
) : null}
</li>
))}
</ul>
{fehler ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{fehler}</p> : null}
</div>
)
}
+87
View File
@@ -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<ExportState> {
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<ExportState> {
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<ExportState> {
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) }
}
}
@@ -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 (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<p className="font-mono text-xs text-text-dim">{akte.caseNumber}</p>
<h1 className="mt-1 font-condensed">{de.export.heading}</h1>
<p className="mt-3 text-sm text-text-dim">{de.export.coverNote}</p>
<div className="mt-6">
<ExportPanel
target={{ kind: 'CASE', caseNumber: akte.caseNumber }}
topic={`user:${user.id}`}
/>
</div>
{akte.classification >= APPROVAL_FROM_LEVEL ? (
<p className="mt-4 text-2xs text-stamp-3">{de.export.waitingApprovalHint}</p>
) : null}
<nav className="mt-8 flex gap-4 font-condensed text-xs">
<Link href={`/faelle/${akte.caseNumber}`}>{akte.caseNumber}</Link>
<Link href={`/faelle/${akte.caseNumber}/druck`}>{de.export.print}</Link>
</nav>
</main>
)
}
@@ -423,6 +423,7 @@ export default async function CasePage({
<nav className="mt-12 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
<span className="text-text-dim">{de.case.timeline}</span>
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
<Link href={`/faelle/${akte.caseNumber}/export`}>{de.export.heading}</Link>
</nav>
<section className="mt-6">
+59
View File
@@ -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 (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
{beschriftet.length > 0 ? (
<section className="mb-10 border border-stamp-3 p-4">
<h2 className="font-condensed text-stamp-3">{de.export.approvals}</h2>
<div className="mt-3">
<Approvals jobs={beschriftet} />
</div>
</section>
) : null}
<h1 className="font-condensed">{de.overview.heading}</h1>
<p className="mt-2 text-text-dim">{de.overview.intro}</p>
@@ -93,3 +109,46 @@ export default async function OverviewPage() {
</main>
)
}
/**
* 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<ReturnType<typeof pendingApprovals>>,
): 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<string, string>([
...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(),
}))
}
+21
View File
@@ -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 (
<main className="print-sheet">
<h1>{de.errors.notFound.title}</h1>
<p className="print-note">{de.errors.notFound.body}</p>
</main>
)
}
@@ -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<Metadata> {
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 <NotAvailable />
const { permissions, displayName } = zugang
const doc = await loadVisibleDocument(permissions, documentNumber)
if (!doc) return <NotAvailable />
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 (
<Sheet
sections={sections}
title={template?.sheetTitle ?? de.app.name}
subtitle={template?.sheetSubtitle ?? template?.name ?? doc.title}
documentNumber={doc.documentNumber}
issuedAt={doc.createdAt}
foundationLevels={foundationLevels}
footerNote={de.document.footerNote}
photoUrls={photoUrls}
/>
)
}
return (
<main className="print-sheet">
<section className="print-cover">
<span className="print-stamp">
{doc.classification} · {levelName.get(doc.classification) ?? de.common.unknown}
</span>
<h1 style={{ marginTop: '4mm' }}>{doc.title}</h1>
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>
{doc.documentNumber}
</p>
<table className="print-meta">
<tbody>
<tr>
<th>{de.case.status}</th>
<td>{de.document.statusNames[doc.status] ?? doc.status}</td>
</tr>
<tr>
<th>{de.document.kind}</th>
<td>{doc.templateKey}</td>
</tr>
<tr>
<th>{de.export.retrievedBy}</th>
<td>{displayName}</td>
</tr>
<tr>
<th>{de.export.asOf}</th>
<td>
{new Date().toLocaleString('de-DE')} · {de.clearance.label}{' '}
{permissions.clearanceLevel}
</td>
</tr>
</tbody>
</table>
</section>
{doc.retractedAt ? (
<p className="print-stamp" style={{ marginBottom: '4mm' }}>
{de.document.statusNames.RETRACTED}
</p>
) : null}
{sections.map((section) => {
const field = section.field
if (!isInputField(field)) {
if (field.type === 'pagebreak') {
return <div key={field.key} style={{ breakAfter: 'page' }} />
}
const text = fillPlaceholders(field.text ?? '', werte)
return field.type === 'heading' ? (
<h3 key={field.key}>{text || field.label}</h3>
) : (
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
{text}
</p>
)
}
if (field.type === 'signature') {
return (
<div key={field.key} style={{ marginTop: '10mm', breakInside: 'avoid' }}>
<div style={{ borderTop: '1pt solid #000', width: '70mm' }} />
<p className="print-note">{fillPlaceholders(field.label, werte)}</p>
</div>
)
}
return (
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
<p className="print-label">{field.label}</p>
{!section.visible ? (
<p style={{ margin: '1mm 0' }}>
<span className="print-redacted">&nbsp;</span>{' '}
<span className="print-note">
{de.export.redactedAt(
section.value.requiredLevel,
levelName.get(section.value.requiredLevel) ?? '',
)}
</span>
</p>
) : (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
{String(section.value ?? '—')}
</p>
)}
</div>
)
})}
</main>
)
}
@@ -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<Metadata> {
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 <NotAvailable />
const { permissions, displayName } = zugang
const akte = await loadVisibleCase(permissions, caseNumber)
if (!akte) return <NotAvailable />
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 (
<main className="print-sheet">
<section className="print-cover">
<span className="print-stamp">
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
</span>
<h1 style={{ marginTop: '4mm' }}>{akte.title}</h1>
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>{akte.caseNumber}</p>
<table className="print-meta">
<tbody>
<Zeile label={de.case.status} value={de.case.statusNames[akte.status] ?? akte.status} />
<Zeile label={de.case.type} value={akte.templateKey} />
<Zeile
label={de.case.createdBy}
value={nameVon.get(akte.createdById) ?? akte.createdById}
/>
<Zeile
label={de.case.dueAt}
value={akte.dueAt ? akte.dueAt.toLocaleDateString('de-DE') : '—'}
/>
<Zeile label={de.export.retrievedBy} value={displayName} />
<Zeile
label={de.export.asOf}
value={`${stand.toLocaleString('de-DE')} · ${de.clearance.label} ${permissions.clearanceLevel}`}
/>
</tbody>
</table>
<p className="print-note" style={{ marginTop: '4mm' }}>
{de.export.coverNote}
</p>
</section>
<h2>{de.section.heading}</h2>
<PrintSections sections={sections} levelName={levelName} />
<h2>{de.case.timeline}</h2>
{entries.length === 0 ? (
<p className="print-note">{de.entry.empty}</p>
) : (
entries.map((eintrag) => (
<div key={eintrag.id} className="print-entry">
<p className="print-label">
{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'))}`
: ''}
</p>
{eintrag.retractedAt ? (
<p style={{ margin: '1mm 0', fontStyle: 'italic' }}>
{de.entry.retracted(
eintrag.retractedAt.toLocaleDateString('de-DE'),
nameVon.get(eintrag.authorId) ?? eintrag.authorId,
eintrag.retractionReason ?? '',
)}
</p>
) : (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>{eintrag.body}</p>
)}
</div>
))
)}
<h2>{de.attachment.heading}</h2>
{anhaenge.length === 0 ? (
<p className="print-note">{de.attachment.empty}</p>
) : (
<table className="print-meta">
<thead>
<tr>
<th>{de.export.fileName}</th>
<th>{de.export.fileSize}</th>
<th>{de.export.fileHash}</th>
</tr>
</thead>
<tbody>
{anhaenge.map((anhang) => (
<tr key={anhang.id}>
<td>{anhang.originalName}</td>
<td>{anhang.sizeBytes}</td>
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
gehört — deshalb steht er im Verzeichnis. */}
<td style={{ fontFamily: 'monospace', fontSize: '7pt', wordBreak: 'break-all' }}>
{anhang.sha256}
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
)
}
function Zeile({ label, value }: { label: string; value: string }) {
return (
<tr>
<th>{label}</th>
<td>{value}</td>
</tr>
)
}
/**
* 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<number, string>
}) {
const werte = Object.fromEntries(
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
)
return (
<div>
{sections.map((section) => {
const field = section.field
if (!isInputField(field)) {
if (field.type === 'pagebreak') {
return <div key={field.key} style={{ breakAfter: 'page' }} />
}
const text = fillPlaceholders(field.text ?? '', werte)
return field.type === 'heading' ? (
<h3 key={field.key}>{text || field.label}</h3>
) : (
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
{text}
</p>
)
}
return (
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
<p className="print-label">{field.label}</p>
{!section.visible ? (
<p style={{ margin: '1mm 0' }}>
<span className="print-redacted">&nbsp;</span>{' '}
<span className="print-note">
{de.export.redactedAt(
section.value.requiredLevel,
levelName.get(section.value.requiredLevel) ?? '',
)}
</span>
</p>
) : section.segments ? (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
{section.segments.map((segment, index) =>
segment.kind === 'text' ? (
<span key={index}>{segment.text}</span>
) : (
// Balken statt Auslassung, mit der Stufe daneben: der
// Empfaenger soll sehen, dass hier etwas fehlt.
<span key={index}>
<span
className="print-redacted"
style={{ width: `${segment.blockLength}ch` }}
>
&nbsp;
</span>
<span className="print-note"> [{segment.requiredLevel}]</span>
</span>
),
)}
</p>
) : (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
{String(section.value ?? '—')}
</p>
)}
</div>
)
})}
</div>
)
}
+94
View File
@@ -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<Response> {
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}"`,
},
})
}
+63
View File
@@ -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<Response> {
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"`,
},
})
}
+121 -4
View File
@@ -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;
}
/* Drucken: nur das Blatt, ohne Rahmenwerk der Anwendung. Der PDF-Export mit
Wasserzeichen und Deckblatt kommt in Phase 8, siehe docs/08. */
.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. 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) {
+94
View File
@@ -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<ExportState | null, FormData>(
requestExportAction,
null,
)
const [fertig, setFertig] = useState<string | null>(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 (
<div className="border border-line p-4">
<form action={action}>
<input type="hidden" name="kind" value={target.kind} />
{target.kind === 'CASE' ? (
<input type="hidden" name="caseNumber" value={target.caseNumber} />
) : (
<input type="hidden" name="documentNumber" value={target.documentNumber} />
)}
<button
type="submit"
disabled={pending || Boolean(state?.jobId)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? exportText.preparing : exportText.asPdf}
</button>
</form>
<div className="mt-3 text-2xs">
{state?.error ? <p className="text-stamp-4">{state.error}</p> : null}
{wartetAufFreigabe ? (
<div>
<p className="font-condensed text-stamp-3">{exportText.waitingApproval}</p>
<p className="mt-1 text-text-dim">{exportText.waitingApprovalHint}</p>
</div>
) : null}
{state?.jobId && !fertig && !wartetAufFreigabe && !gescheitert ? (
<p className="text-text-dim">{exportText.preparing}</p>
) : null}
{gescheitert ? <p className="text-stamp-4">{exportText.failed}</p> : null}
{fertig ? (
<a
href={`/api/exports/${fertig}`}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent"
>
{exportText.download}
</a>
) : null}
</div>
</div>
)
}
+24
View File
@@ -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(
+4
View File
@@ -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
+32
View File
@@ -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')
}
+122
View File
@@ -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<Uint8Array> {
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),
)
}
+18
View File
@@ -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 }
}
+48
View File
@@ -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,
}
}
}
+364
View File
@@ -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<void> {
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<void> {
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<void> {
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:
'<div style="width:100%;font-size:7pt;font-family:monospace;padding:0 16mm;' +
'display:flex;justify-content:space-between;color:#444">' +
'<span class="date"></span><span><span class="pageNumber"></span>/<span class="totalPages"></span></span>' +
'</div>',
}),
})
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 (
'<div style="width:100%;font-size:7pt;font-family:monospace;padding:0 16mm;' +
'display:flex;justify-content:space-between;color:#666">' +
`<span>${sicher} · Stufe ${stufe}</span>` +
'<span class="date"></span>' +
'</div>'
)
}
async function druckPfad(kind: string, targetId: string | null): Promise<string | null> {
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<string, unknown>,
): Promise<void> {
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,
},
})
}
+62
View File
@@ -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<RenderClaim, 'expiresAt'>): 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
}
}
+101
View File
@@ -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')
}
+23 -9
View File
@@ -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<boolean> {
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<void> {
await db.attachment.update({
+2
View File
@@ -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,
+45
View File
@@ -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<string, string>,
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
+1
View File
@@ -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}`
+262
View File
@@ -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<Permission>(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)
})
})