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() { + +
+

{de.export.heading}

+

+ {de.export.databaseHint} +

+

+ {de.export.database} + {de.export.bundle} +

+
) } 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} + + +
+ ) +} 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 ( +
+