Files
AfiS-Aktensystem/web/lib/export/service.ts
T
Harlekin 6b2b4c4c8c
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Personalakten und Abteilungen als vollstaendiges Dossier speichern
Bisher liess sich eine einzelne Fallakte und ein einzelnes Dokument als PDF
sichern. Wer den Stand zu einer Person brauchte, rief beides einzeln ab,
legte es von Hand zusammen und wusste danach nicht, ob er alles hatte.

Ein Dossier ist die Personalakte, dahinter alle ihre Dokumente, dahinter
alle Fallakten, in denen die Person vorkommt - jeweils in derselben
Druckdarstellung wie einzeln. Fuer eine Abteilung dasselbe, je Mitglied
getrennt: eine Abteilung hat keine eigenen Fallakten, sie hat Mitglieder,
die in Fallakten vorkommen. Alles in einen Topf geschuettet staende dieselbe
Akte mehrfach da, ohne dass man saehe, ueber wen sie dort steht.

Der Abrufer waehlt die hoechste Stufe, die aufgenommen wird. Das ist keine
Zugriffspruefung, sondern eine Entscheidung ueber den Umfang - und beides
darf nie durcheinandergeraten:

  - Was ueber der Freigabestufe des Abrufers liegt, existiert fuer ihn
    nicht. Es steht nicht im Abzug, wird nicht gezaehlt und nicht erwaehnt.
  - Was er selbst ausgeschlossen hat, steht als Vermerk im PDF, nach Stufe
    aufgeschluesselt. Eine gekuerzte Mappe darf nicht wie eine
    vollstaendige aussehen, und der Deckel steht zusaetzlich auf jeder
    Seite im Wasserzeichen.

Beide Grenzen laufen in der Datenbankabfrage, nicht danach in JavaScript.

Alles wird als eine Druckseite gerendert statt als mehrere
zusammengeheftete PDFs. So laufen Seitenzahlen, Kopfzeile und Wasserzeichen
durch, und es gibt keinen zweiten Weg, der irgendwann anders aussieht als
der erste. Dafuer sind die vorhandenen Druckansichten zu Bausteinen
geworden - CaseSheet und DocumentSheet stehen jetzt in components/print und
werden von den alten Seiten wie vom Dossier benutzt.

Der Zwischenspeicherschluessel traegt den Deckel, sonst bekaeme ein Abruf
bis Stufe 1 die Datei eines Abrufs bis Stufe 4. Und der Stand ist der
spaeteste ueber alle enthaltenen Akten, nicht der der Kopfdaten allein -
sonst gaebe der Zwischenspeicher einen Abzug heraus, in dem ein
inzwischen geaendertes Dokument im alten Wortlaut steht.

Jeder Abruf steht mit seinem Deckel im Protokoll. Ohne den sagt der
Eintrag, dass jemand etwas ausser Haus gegeben hat, aber nicht wie viel.
2026-09-04 15:34:32 +02:00

406 lines
13 KiB
TypeScript

import { writeFile } from 'node:fs/promises'
import { db } from '@/lib/db'
import { getEnv } from '@/lib/env'
import { writeAudit } from '@/lib/audit/write'
import { AccessError } from '@/lib/errors'
import { loadVisibleCase } from '@/lib/cases/service'
import { loadVisibleDocument } from '@/lib/documents/service'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import {
departmentDossierStand,
effectiveCap,
loadVisibleDepartmentForPrint,
personnelDossierStand,
} from '@/lib/export/dossier'
import { ensureDirFor, storageKeyFor } from '@/lib/files/paths'
import { exportCacheKey } from '@/lib/export/cacheKey'
import { createRenderToken } from '@/lib/export/token'
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**, ausnahmslos und mit Stufe,
* Zeitpunkt und Anlass.
*
* Wer exportieren darf, entscheidet allein die Freigabestufe: **mindestens die
* des Ziels.** Das ist strenger als das Lesen - wer als Beteiligter
* ausnahmsweise in eine höher eingestufte Akte darf, darf sie deshalb noch
* nicht ausser Haus geben. Und es ist einfacher als das frühere
* Vier-Augen-Verfahren, das ab Stufe 3 eine zweite Unterschrift verlangte:
* das hielt die Arbeit auf und ersetzte keine Kontrolle, die das Protokoll
* nicht ohnehin leistet. Siehe docs/13-entscheidungen.md.
*/
export type ExportTarget =
| { kind: 'CASE'; caseNumber: string }
| { kind: 'DOCUMENT'; documentNumber: string }
/** Personalakte samt Dokumenten und den Fallakten, in denen sie vorkommt. */
| { kind: 'PERSONNEL'; personnelFileId: string; maxLevel: number }
/** Abteilung, je Mitglied ein vollständiges Dossier. */
| { kind: 'DEPARTMENT'; departmentKey: string; maxLevel: number }
/**
* Legt einen Auftrag an - oder gibt einen fertigen zurück, wenn genau dieser
* 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)
// Lesen darf man eine Akte auch als Beteiligter unterhalb ihrer Stufe.
// Ausser Haus geben nicht.
if (!p.isAdmin && p.clearanceLevel < ziel.classification) {
throw new AccessError('clearance-too-low')
}
const cacheKey = exportCacheKey({
kind: target.kind,
targetId: ziel.id,
updatedAt: ziel.updatedAt,
clearanceLevel: p.clearanceLevel,
templateVersion: ziel.templateVersion,
maxLevel: ziel.maxLevel,
})
// 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,
label: ziel.label,
maxLevel: ziel.maxLevel,
})
return { jobId: fertig.id, status: 'READY' }
}
// Läuft schon einer für denselben Stand? Dann daran hängen - aber der
// Protokolleintrag entsteht trotzdem. „Jeder Export wird protokolliert"
// heisst jeder Abruf, nicht jeder Rendervorgang: wer sich an einen fremden
// Auftrag hängt, bekommt dieselbe Datei und muss genauso darin stehen.
const laufend = await db.exportJob.findFirst({
where: { cacheKey, status: { in: ['QUEUED', 'RUNNING'] } },
select: { id: true, status: true },
})
if (laufend) {
await protokolliere(p, 'export.pdf', ziel.id, {
joined: true,
kind: target.kind,
label: ziel.label,
classification: ziel.classification,
maxLevel: ziel.maxLevel,
})
return { jobId: laufend.id, status: laufend.status }
}
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,
maxLevel: ziel.maxLevel,
cacheKey,
status: 'QUEUED',
},
select: { id: true, status: true },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'export.pdf',
targetType: zielArt(target.kind),
targetId: ziel.id,
clearanceAtTime: p.clearanceLevel,
meta: {
jobId: angelegt.id,
kind: target.kind,
label: ziel.label,
classification: ziel.classification,
// Der Deckel gehört ins Protokoll: er sagt, wie viel das Haus
// verlassen hat.
maxLevel: ziel.maxLevel,
},
})
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,
maxLevel: null,
printPath: `/faelle/${akte.caseNumber}/druck`,
}
}
if (target.kind === 'DOCUMENT') {
const doc = await loadVisibleDocument(p, target.documentNumber)
if (!doc) throw new AccessError('not-found')
return {
id: doc.id,
label: doc.documentNumber,
classification: doc.classification,
updatedAt: doc.updatedAt,
templateVersion: doc.templateVersion,
maxLevel: null,
printPath: `/dokumente/${doc.documentNumber}/druck`,
}
}
// Ab hier die Dossiers. Der Deckel wird sofort auf die Stufe des Abrufers
// gebracht - alles Weitere rechnet mit dem gedeckelten Wert, damit es keinen
// zweiten Ort gibt, an dem das schiefgehen könnte.
const cap = effectiveCap(p, target.maxLevel)
if (target.kind === 'PERSONNEL') {
const akte = await db.personnelFile.findFirst({
where: { AND: [visiblePersonnelWhere(p), { id: target.personnelFileId }] },
select: {
id: true,
userId: true,
displayName: true,
classification: true,
updatedAt: true,
},
})
if (!akte) throw new AccessError('not-found')
return {
id: akte.id,
label: akte.displayName,
classification: akte.classification,
// Der Stand über das ganze Dossier, nicht nur über die Kopfdaten.
updatedAt: await personnelDossierStand(p, akte, cap),
templateVersion: 0,
maxLevel: cap,
printPath: `/personalakten/${akte.id}/druck`,
}
}
const abteilung = await loadVisibleDepartmentForPrint(p, target.departmentKey)
if (!abteilung) throw new AccessError('not-found')
return {
id: abteilung.id,
label: abteilung.name,
classification: abteilung.minClearance,
updatedAt: await departmentDossierStand(p, abteilung, cap),
templateVersion: 0,
maxLevel: cap,
printPath: `/abteilungen/${abteilung.key}/druck`,
}
}
/**
* 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,
...(auftrag.maxLevel === null ? {} : { maxLevel: auftrag.maxLevel }),
})
const deckel = auftrag.maxLevel === null ? '' : `&stufe=${auftrag.maxLevel}`
const url = `${env.APP_URL.replace(/\/$/, '')}${pfad}?token=${encodeURIComponent(token)}${deckel}`
const antwort = await fetch(`${env.PDF_SERVICE_URL.replace(/\/$/, '')}/render`, {
method: 'POST',
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,
auftrag.maxLevel,
),
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, deckel: number | null): string {
const sicher = name.replace(/[<>&]/g, '')
// Der Deckel gehört auf jede Seite, nicht nur auf den Deckel: wer Blatt 40
// in der Hand hält, soll sehen, dass der Abzug gekürzt ist.
const bis = deckel === null || deckel >= stufe ? '' : ` · Abzug bis ${deckel}`
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}${bis}</span>` +
'<span class="date"></span>' +
'</div>'
)
}
/** Aus dem gespeicherten Auftrag zurück zur Druckansicht. */
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
}
if (kind === 'PERSONNEL') {
const akte = await db.personnelFile.findUnique({
where: { id: targetId },
select: { id: true },
})
return akte ? `/personalakten/${akte.id}/druck` : null
}
if (kind === 'DEPARTMENT') {
const abteilung = await db.department.findUnique({
where: { id: targetId },
select: { key: true },
})
return abteilung ? `/abteilungen/${abteilung.key}/druck` : null
}
const doc = await db.document.findUnique({
where: { id: targetId },
select: { documentNumber: true },
})
return doc ? `/dokumente/${doc.documentNumber}/druck` : null
}
/** Für das Protokoll: worauf sich der Eintrag bezieht. */
function zielArt(kind: ExportTarget['kind']): string {
if (kind === 'CASE') return 'Case'
if (kind === 'DOCUMENT') return 'Document'
if (kind === 'PERSONNEL') return 'PersonnelFile'
return 'Department'
}
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,
},
})
}