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() 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 { 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 { 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 CLAUDE.md, harte Regel 2. */ export async function retractDocument( p: EffectivePermissions, documentNumber: string, reason: string, ): Promise { 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 { 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, }, }) }) }