Files
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

339 lines
11 KiB
TypeScript

import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit/write'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite'
import { nextNumber } from '@/lib/numbering'
import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle'
import { AccessError, must } from '@/lib/errors'
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
import { caseTopic, publish } from '@/lib/realtime/publish'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Fachlogik der Akte. Jede Funktion prüft die Berechtigung selbst und
* schreibt ihren Protokolleintrag in derselben Transaktion wie die Änderung.
*
* Die Server Actions daneben sind dünn: sie nehmen entgegen, rufen hier auf
* und geben zurück. Die Regeln stehen an genau einer Stelle.
*/
export { AccessError } from '@/lib/errors'
/** Lädt eine Akte, sofern der Nutzer sie sehen darf. Sonst `null` - nie 403. */
export async function loadVisibleCase(p: EffectivePermissions, caseNumber: string) {
return db.case.findFirst({
where: { AND: [visibleCaseWhere(p), { caseNumber }] },
select: {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
templateId: true,
templateKey: true,
dueAt: true,
closedAt: true,
outcome: true,
createdById: true,
createdAt: true,
updatedAt: true,
},
})
}
export async function createCase(
p: EffectivePermissions,
input: { title: string; classification: number; templateKey: string; dueAt?: Date | null },
): Promise<{ caseNumber: string; id: string }> {
must(canCreateCase(p, input.classification))
const template = await db.documentTemplate.findFirst({
where: { key: input.templateKey, isActive: true },
orderBy: { version: 'desc' },
select: { id: true, key: true, numberPrefix: true, numberPadding: true },
})
if (!template) throw new AccessError('template-missing')
return db.$transaction(async (tx) => {
// Das Muster kommt aus der Vorlage, jede Fallart hat ihren Nummernkreis.
const caseNumber = await nextNumber(tx, {
prefix: template.numberPrefix,
padding: template.numberPadding,
})
const created = await tx.case.create({
data: {
caseNumber,
title: input.title,
classification: input.classification,
templateId: template.id,
templateKey: template.key,
createdById: p.userId,
dueAt: input.dueAt ?? null,
status: 'DRAFT',
},
select: { id: true, caseNumber: true },
})
// Wer eine Akte anlegt, ist ihr Ermittler. Sonst verliert er sie aus dem
// Blick, sobald jemand die Klassifizierung anhebt.
await tx.caseParticipant.create({
data: { caseId: created.id, userId: p.userId, role: 'LEAD', addedById: p.userId },
})
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: created.id,
type: 'SYSTEM',
body: `Akte angelegt mit Klassifizierung ${input.classification}`,
authorId: p.userId,
minClearance: 0,
},
})
await writeAudit(tx, {
actorId: p.userId,
action: 'case.create',
targetType: 'Case',
targetId: created.id,
clearanceAtTime: p.clearanceLevel,
meta: { caseNumber: created.caseNumber, classification: input.classification },
})
return created
})
}
export async function addEntry(
p: EffectivePermissions,
input: { id: string; caseNumber: string; body: string; type?: 'NOTE' | 'INTERROGATION' | 'EVIDENCE'; minClearance?: number },
): Promise<{ id: string }> {
const target = await loadVisibleCase(p, input.caseNumber)
if (!target) throw new AccessError('not-found')
must(canEditCase(p, target, 'entry.create'))
const minClearance = input.minClearance ?? 0
if (minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
const verfasser = await db.user.findUnique({
where: { id: p.userId },
select: { displayName: true },
})
const angelegt = await db.$transaction(async (tx) => {
// Die clientseitige UUIDv7 ist zugleich Idempotenzschlüssel: ein
// wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten
// Eintrag. Siehe docs/09-performance-und-ux.md.
const existing = await tx.caseEntry.findUnique({ where: { id: input.id }, select: { id: true } })
if (existing) return existing
const entry = await tx.caseEntry.create({
data: {
id: input.id,
caseId: target.id,
type: input.type ?? 'NOTE',
body: input.body,
authorId: p.userId,
minClearance,
},
select: { id: true },
})
await enqueueCaseNotification(tx, {
kind: 'entry.new',
caseId: target.id,
caseNumber: target.caseNumber,
classification: target.classification,
title: target.title,
actorId: p.userId,
actorName: verfasser?.displayName ?? p.userId,
meta: { entryId: entry.id },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'entry.create',
targetType: 'CaseEntry',
targetId: entry.id,
clearanceAtTime: p.clearanceLevel,
meta: { caseId: target.id, caseNumber: target.caseNumber, minClearance },
})
return entry
})
await publish(caseTopic(target.id), 'entry.new', angelegt.id)
return angelegt
}
export async function changeStatus(
p: EffectivePermissions,
input: { caseNumber: string; to: CaseStatusName; reason?: string },
): Promise<void> {
const target = await loadVisibleCase(p, input.caseNumber)
if (!target) throw new AccessError('not-found')
const transition = findTransition(target.status as CaseStatusName, input.to)
if (!transition) throw new AccessError('transition-invalid')
must(canEditCase(p, target, transition.permission))
const reason = input.reason?.trim() ?? ''
if (transition.requiresReason && reason.length < 10) throw new AccessError('reason-required')
if (transition.requiresCompleteSections) {
const missing = await missingRequiredSections(target.id, target.templateId)
if (missing.length > 0) throw new AccessError('sections-incomplete')
}
await db.$transaction(async (tx) => {
await tx.case.update({
where: { id: target.id },
data: {
status: input.to,
closedAt: input.to === 'CLOSED' ? new Date() : target.closedAt,
},
})
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: target.id,
type: 'STATUS_CHANGE',
body: reason
? `Status von ${target.status} auf ${input.to} geändert. Begründung: ${reason}`
: `Status von ${target.status} auf ${input.to} geändert`,
authorId: p.userId,
minClearance: 0,
},
})
await writeAudit(tx, {
actorId: p.userId,
action: 'case.status',
targetType: 'Case',
targetId: target.id,
clearanceAtTime: p.clearanceLevel,
meta: { from: target.status, to: input.to, reason: reason || undefined },
})
})
}
/**
* Klassifizierung ändern.
*
* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher nicht
* sehen durften. Deshalb landet sie nicht nur im Protokoll, sondern als
* sichtbarer Systemeintrag im Verlauf der Akte.
*/
export async function reclassify(
p: EffectivePermissions,
input: { caseNumber: string; to: number; reason?: string },
): Promise<void> {
const target = await loadVisibleCase(p, input.caseNumber)
if (!target) throw new AccessError('not-found')
if (target.classification === input.to) return
must(canReclassify(p, target, input.to))
const lowering = input.to < target.classification
const reason = input.reason?.trim() ?? ''
if (lowering && reason.length < 10) throw new AccessError('reason-required')
await db.$transaction(async (tx) => {
await tx.case.update({ where: { id: target.id }, data: { classification: input.to } })
await tx.caseEntry.create({
data: {
id: crypto.randomUUID(),
caseId: target.id,
type: 'SYSTEM',
body: lowering
? `Klassifizierung von ${target.classification} auf ${input.to} herabgesetzt. Begründung: ${reason}`
: `Klassifizierung von ${target.classification} auf ${input.to} angehoben`,
authorId: p.userId,
minClearance: 0,
},
})
await writeAudit(tx, {
actorId: p.userId,
action: 'case.reclassify',
targetType: 'Case',
targetId: target.id,
clearanceAtTime: p.clearanceLevel,
meta: {
from: target.classification,
to: input.to,
lowering,
reason: reason || undefined,
},
})
})
}
/** Felder mit `requiredToClose`, die noch leer sind. Kommt mit Phase 3 zum Tragen. */
async function missingRequiredSections(caseId: string, templateId: string): Promise<string[]> {
const template = await db.documentTemplate.findUnique({
where: { id: templateId },
select: { fields: true },
})
const fields = Array.isArray(template?.fields) ? template.fields : []
const required = fields
.filter((field): field is { key: string; requiredToClose?: boolean } =>
typeof field === 'object' && field !== null && 'key' in field,
)
.filter((field) => field.requiredToClose === true)
.map((field) => field.key)
if (required.length === 0) return []
const filled = await db.caseSection.findMany({
where: { caseId, fieldKey: { in: required } },
select: { fieldKey: true, value: true },
})
const filledKeys = new Set(
filled.filter((s) => s.value !== null && s.value !== '').map((s) => s.fieldKey),
)
return required.filter((key) => !filledKeys.has(key))
}
/**
* Wiedervorlage setzen oder abräumen.
*
* Das Feld gab es von Anfang an - die Übersicht zeigt darauf ihre Fristen, die
* Suche filtert darauf, der Ausdruck druckt es. Nur setzen konnte es niemand:
* es fehlte jede Stelle, an der man ein Datum einträgt. Deshalb stand unter
* „Fristen" immer „Nichts steht an". Siehe docs/13-entscheidungen.md.
*
* Kein Verlaufseintrag: eine Wiedervorlage ist eine Merkhilfe für die
* Bearbeitung und keine Aussage über den Fall. Protokolliert wird sie
* trotzdem - sie ist eine Änderung an der Akte, und dafür gilt harte Regel 3.
*/
export async function setDueDate(
p: EffectivePermissions,
input: { caseNumber: string; dueAt: Date | null },
): Promise<void> {
const akte = await loadVisibleCase(p, input.caseNumber)
if (!akte) throw new AccessError('not-found')
must(canEditCase(p, akte, 'case.edit'))
if (input.dueAt && Number.isNaN(input.dueAt.getTime())) {
throw new AccessError('date-invalid')
}
await db.$transaction(async (tx) => {
await tx.case.update({ where: { id: akte.id }, data: { dueAt: input.dueAt } })
await writeAudit(tx, {
actorId: p.userId,
action: 'case.update',
targetType: 'Case',
targetId: akte.id,
clearanceAtTime: p.clearanceLevel,
meta: { dueAt: input.dueAt?.toISOString() ?? null },
})
})
}