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.
453 lines
14 KiB
TypeScript
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,
|
|
},
|
|
})
|
|
})
|
|
}
|