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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
303 lines
9.6 KiB
TypeScript
303 lines
9.6 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 { 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 }
|
|
|
|
/**
|
|
* 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,
|
|
})
|
|
|
|
// 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 - 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,
|
|
})
|
|
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,
|
|
cacheKey,
|
|
status: '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,
|
|
},
|
|
})
|
|
|
|
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`,
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
})
|
|
}
|
|
|