Files
AfiS-Aktensystem/web/lib/documents/service.ts
T
Harlekin e0b03ea336
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
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

453 lines
14 KiB
TypeScript

import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit/write'
import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import {
canCreateDocument,
canEditDocument,
canFinalizeDocument,
canRetractDocument,
canWritePersonnel,
} from '@/lib/access/documentWrite'
import { canEditCase } from '@/lib/access/caseWrite'
import { AccessError, must } from '@/lib/errors'
import { nextNumber } from '@/lib/numbering'
import { parseTemplateFields } from '@/lib/templates/schema'
import { asJson, planSectionSave, renderSections, type RenderedSection } from '@/lib/sections/render'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Dokumente. Eine Ausfertigung aus einer Vorlage - Vorfallsbericht,
* Sonderarbeitsvertrag, Bußgeldbescheid - an einer Fallakte, an einer
* Personalakte oder für sich stehend.
*
* Eine neue Dokumentart braucht hier keine Zeile: sie ist eine Vorlage mit
* passendem `uses`. Siehe docs/13-entscheidungen.md.
*/
export type TemplateUse = 'CASE' | 'CASE_DOCUMENT' | 'PERSON_DOCUMENT' | 'STANDALONE'
/** Wo soll das Dokument hängen? Genau eine der drei Formen. */
export type Owner =
| { kind: 'case'; caseNumber: string }
| { kind: 'personnel'; personnelFileId: string }
| { kind: 'standalone' }
const KOPFDATEN = {
id: true,
documentNumber: true,
title: true,
templateId: true,
templateKey: true,
templateVersion: true,
layout: true,
status: true,
classification: true,
caseId: true,
personnelFileId: true,
createdById: true,
createdAt: true,
updatedAt: true,
finalizedAt: true,
finalizedById: true,
retractedAt: true,
retractedById: true,
retractionReason: true,
} satisfies Prisma.DocumentSelect
/** Die aktiven Vorlagen, die an dieser Stelle verwendet werden dürfen. */
export async function listTemplatesFor(use: TemplateUse) {
const rows = await db.documentTemplate.findMany({
where: { isActive: true, uses: { has: use } },
orderBy: [{ key: 'asc' }, { version: 'desc' }],
select: { id: true, key: true, version: true, name: true, description: true, layout: true },
})
// Nur die jeweils höchste Version je Schlüssel. Ältere Versionen bleiben
// bestehen, weil Dokumente sich ihre merken, aber neu angelegt wird stets
// mit der aktuellen.
const gesehen = new Set<string>()
return rows.filter((row) => (gesehen.has(row.key) ? false : (gesehen.add(row.key), true)))
}
export async function loadVisibleDocument(p: EffectivePermissions, documentNumber: string) {
return db.document.findFirst({
where: { AND: [visibleDocumentWhere(p), { documentNumber }] },
select: KOPFDATEN,
})
}
/**
* Die Dokumente eines Besitzers. Der Filter steckt in der Abfrage: ein
* Dokument über der Stufe des Lesers taucht weder auf noch in der Zählung.
*/
export async function listDocuments(
p: EffectivePermissions,
owner: { caseId: string } | { personnelFileId: string },
) {
const wo: Prisma.DocumentWhereInput =
'caseId' in owner ? { caseId: owner.caseId } : { personnelFileId: owner.personnelFileId }
return db.document.findMany({
where: { AND: [visibleDocumentWhere(p), wo] },
orderBy: { createdAt: 'desc' },
take: 100,
select: KOPFDATEN,
})
}
/**
* Legt ein Dokument an.
*
* Reihenfolge der Prüfungen, jede einzeln nötig:
* 1. Die Vorlage muss an dieser Stelle erlaubt sein (`uses`).
* 2. Der Besitzer muss sichtbar **und** beschreibbar sein.
* 3. Der Nutzer braucht `document.create` und die Stufe.
*/
export async function createDocument(
p: EffectivePermissions,
input: { templateKey: string; title?: string; classification: number; owner: Owner },
): Promise<{ documentNumber: string; id: string }> {
const use: TemplateUse =
input.owner.kind === 'case'
? 'CASE_DOCUMENT'
: input.owner.kind === 'personnel'
? 'PERSON_DOCUMENT'
: 'STANDALONE'
const template = await db.documentTemplate.findFirst({
where: { key: input.templateKey, isActive: true, uses: { has: use } },
orderBy: { version: 'desc' },
select: {
id: true,
key: true,
version: true,
name: true,
layout: true,
numberPrefix: true,
numberPadding: true,
},
})
if (!template) throw new AccessError('template-missing')
must(canCreateDocument(p, input.classification))
let caseId: string | null = null
let personnelFileId: string | null = null
let caseNumber: string | null = null
if (input.owner.kind === 'case') {
const akte = await db.case.findFirst({
where: { AND: [visibleCaseWhere(p), { caseNumber: input.owner.caseNumber }] },
select: { id: true, caseNumber: true, status: true, classification: true },
})
if (!akte) throw new AccessError('not-found')
must(canEditCase(p, akte, 'entry.create'))
caseId = akte.id
caseNumber = akte.caseNumber
} else if (input.owner.kind === 'personnel') {
const akte = await db.personnelFile.findFirst({
where: { AND: [visiblePersonnelWhere(p), { id: input.owner.personnelFileId }] },
select: { id: true, classification: true },
})
if (!akte) throw new AccessError('not-found')
must(canWritePersonnel(p, akte.classification))
personnelFileId = akte.id
}
const titel = input.title?.trim() || template.name
return db.$transaction(async (tx) => {
const documentNumber = await nextNumber(tx, {
prefix: template.numberPrefix,
padding: template.numberPadding,
})
const created = await tx.document.create({
data: {
documentNumber,
title: titel,
templateId: template.id,
templateKey: template.key,
templateVersion: template.version,
// Kopie, kein Verweis: ein späterer Umbau der Vorlage darf das
// Aussehen eines ausgefertigten Dokuments nicht verändern.
layout: template.layout,
classification: input.classification,
caseId,
personnelFileId,
createdById: p.userId,
},
select: { id: true, documentNumber: true },
})
// Ein Dokument in einer Fallakte gehört in ihren Verlauf, sonst entsteht
// es unsichtbar neben der Chronologie.
if (caseId) {
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId,
type: 'SYSTEM',
body: `Dokument ${created.documentNumber} angelegt: ${titel}`,
authorId: p.userId,
minClearance: 0,
},
})
}
await writeAudit(tx, {
actorId: p.userId,
action: 'document.create',
targetType: 'Document',
targetId: created.id,
clearanceAtTime: p.clearanceLevel,
meta: {
documentNumber: created.documentNumber,
templateKey: template.key,
classification: input.classification,
caseNumber: caseNumber ?? undefined,
personnelFileId: personnelFileId ?? undefined,
},
})
return created
})
}
/** Die Abschnitte eines Dokuments, mit der Stufe des Lesers angewendet. */
export async function loadDocumentSections(
p: EffectivePermissions,
document: { id: string; templateId: string },
): Promise<RenderedSection[]> {
const [template, rows] = await Promise.all([
db.documentTemplate.findUnique({
where: { id: document.templateId },
select: { fields: true },
}),
db.documentSection.findMany({ where: { documentId: document.id } }),
])
return renderSections(parseTemplateFields(template?.fields), rows, p)
}
export async function saveDocumentSection(
p: EffectivePermissions,
input: {
documentNumber: string
fieldKey: string
value: unknown
baseVersion: number
minClearance?: number
},
): Promise<{ version: number }> {
const target = await loadVisibleDocument(p, input.documentNumber)
if (!target) throw new AccessError('not-found')
const check = canEditDocument(p, target)
if (!check.ok) throw new AccessError(check.reason)
const template = await db.documentTemplate.findUnique({
where: { id: target.templateId },
select: { fields: true },
})
const field = parseTemplateFields(template?.fields).find((f) => f.key === input.fieldKey)
if (!field) throw new AccessError('field-unknown')
const existing = await db.documentSection.findUnique({
where: { documentId_fieldKey: { documentId: target.id, fieldKey: input.fieldKey } },
})
const { nextClearance, nextVersion } = planSectionSave({
p,
field,
existing,
baseVersion: input.baseVersion,
minClearance: input.minClearance,
})
await db.$transaction(async (tx) => {
if (existing) {
await tx.documentSectionVersion.create({
data: {
sectionId: existing.id,
version: existing.currentVersion,
value: asJson(existing.value),
authorId: existing.updatedById,
},
})
await tx.documentSection.update({
where: { id: existing.id },
data: {
value: asJson(input.value),
currentVersion: nextVersion,
updatedById: p.userId,
minClearance: nextClearance,
},
})
} else {
await tx.documentSection.create({
data: {
documentId: target.id,
fieldKey: input.fieldKey,
value: asJson(input.value),
currentVersion: 1,
updatedById: p.userId,
minClearance: nextClearance,
},
})
}
// Sonst bliebe `updatedAt` des Dokuments auf dem Anlagezeitpunkt stehen.
await tx.document.update({ where: { id: target.id }, data: { updatedAt: new Date() } })
await writeAudit(tx, {
actorId: p.userId,
action: 'document.update',
targetType: 'DocumentSection',
targetId: `${target.id}:${input.fieldKey}`,
clearanceAtTime: p.clearanceLevel,
meta: {
documentNumber: target.documentNumber,
fieldKey: input.fieldKey,
version: nextVersion,
minClearance: nextClearance,
},
})
})
return { version: nextVersion }
}
/**
* Ausfertigen. Aus dem Entwurf wird ein gültiges Dokument; wer es danach noch
* ändern will, braucht dasselbe Recht erneut.
*/
export async function finalizeDocument(
p: EffectivePermissions,
documentNumber: string,
): Promise<void> {
const target = await loadVisibleDocument(p, documentNumber)
if (!target) throw new AccessError('not-found')
must(canFinalizeDocument(p, target))
await db.$transaction(async (tx) => {
await tx.document.update({
where: { id: target.id },
data: { status: 'FINAL', finalizedAt: new Date(), finalizedById: p.userId },
})
if (target.caseId) {
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: target.caseId,
type: 'SYSTEM',
body: `Dokument ${target.documentNumber} ausgefertigt`,
authorId: p.userId,
minClearance: 0,
},
})
}
await writeAudit(tx, {
actorId: p.userId,
action: 'document.finalize',
targetType: 'Document',
targetId: target.id,
clearanceAtTime: p.clearanceLevel,
meta: { documentNumber: target.documentNumber },
})
})
}
/**
* Zurückziehen. Es gibt kein Löschen: das Dokument bleibt als Grabstein
* stehen, mit Datum, Person und Begründung. Siehe Zusicherung 2 der README.
*/
export async function retractDocument(
p: EffectivePermissions,
documentNumber: string,
reason: string,
): Promise<void> {
const target = await loadVisibleDocument(p, documentNumber)
if (!target) throw new AccessError('not-found')
must(canRetractDocument(p, target))
const begruendung = reason.trim()
if (begruendung.length < 10) throw new AccessError('reason-required')
await db.$transaction(async (tx) => {
await tx.document.update({
where: { id: target.id },
data: {
status: 'RETRACTED',
retractedAt: new Date(),
retractedById: p.userId,
retractionReason: begruendung,
},
})
if (target.caseId) {
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: target.caseId,
type: 'SYSTEM',
body: `Dokument ${target.documentNumber} zurückgezogen. Begründung: ${begruendung}`,
authorId: p.userId,
minClearance: 0,
},
})
}
await writeAudit(tx, {
actorId: p.userId,
action: 'document.retract',
targetType: 'Document',
targetId: target.id,
clearanceAtTime: p.clearanceLevel,
meta: { documentNumber: target.documentNumber, reason: begruendung },
})
})
}
/** Klassifizierung eines Dokuments ändern, mit denselben Regeln wie beim Fall. */
export async function reclassifyDocument(
p: EffectivePermissions,
documentNumber: string,
to: number,
reason?: string,
): Promise<void> {
const target = await loadVisibleDocument(p, documentNumber)
if (!target) throw new AccessError('not-found')
if (target.classification === to) return
if (to < 0 || to > 5) throw new AccessError('clearance-too-low')
if (to > p.clearanceLevel && !p.isAdmin) {
throw new AccessError('clearance-too-low')
}
must(canEditDocument(p, target))
const lowering = to < target.classification
const begruendung = reason?.trim() ?? ''
if (lowering && begruendung.length < 10) throw new AccessError('reason-required')
await db.$transaction(async (tx) => {
await tx.document.update({ where: { id: target.id }, data: { classification: to } })
await writeAudit(tx, {
actorId: p.userId,
action: 'document.reclassify',
targetType: 'Document',
targetId: target.id,
clearanceAtTime: p.clearanceLevel,
meta: {
documentNumber: target.documentNumber,
from: target.classification,
to,
lowering,
reason: begruendung || undefined,
},
})
})
}