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/write' 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, }, }) }