Richtigstellung zuerst: der vorige Commit trägt die Nachricht "Nichts davon gebaut", enthält aber bereits die Schema-Änderung an CaseTemplate und CaseCounter. Ein abgebrochener Aufruf hatte die Datei schon geschrieben, und das nachfolgende `git add -A` hat sie mitgenommen. Die Nachricht war damit falsch. Hier ist die Änderung vollständig und geprüft. Das Muster des Aktenzeichens gehört zur Vorlage: Präfix und Stellenzahl je Fallart, ein Nummernkreis je Präfix und Jahr. Die Abteilung führt gewachsene Präfixe wie AKT-PA-2026-62843 neben dem AfiS-2026-0042 aus dem Glossar. Die Migration ist von Hand geschrieben, weil der Wechsel des Primärschlüssels auf CaseCounter den bestehenden Zähler erhalten muss: eine zurückgesetzte Nummer wäre ein doppeltes Aktenzeichen. Vorher und nachher geprüft. parseCaseNumber zerlegt von hinten, damit Präfixe mit Bindestrich funktionieren. Ein Test belegt, dass AKT-PA und AfiS getrennte Nummernkreise führen. Dazu start.sh für die lokale Entwicklung, solange der Dockge-Stack nicht läuft: Datenbank hochfahren, Migrationen, Grundzustand, Worker und Web. Mit --ohne-worker für Arbeit ohne Discord und --nur-db für die Datenbank allein.
289 lines
9.1 KiB
TypeScript
289 lines
9.1 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit'
|
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
|
import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite'
|
|
import { nextCaseNumber } from '@/lib/cases/caseNumber'
|
|
import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle'
|
|
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 class AccessError extends Error {
|
|
constructor(readonly code: string) {
|
|
super(code)
|
|
this.name = 'AccessError'
|
|
}
|
|
}
|
|
|
|
function must(check: { ok: boolean; reason?: string }): void {
|
|
if (!check.ok) throw new AccessError(check.reason ?? 'denied')
|
|
}
|
|
|
|
/** 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.caseTemplate.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 nextCaseNumber(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')
|
|
|
|
return 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 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
|
|
})
|
|
}
|
|
|
|
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.caseTemplate.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))
|
|
}
|