import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { AccessError } from '@/lib/errors' import { visibleDepartmentEntryWhere, visibleDepartmentWhere, } from '@/lib/access/departmentFilter' import type { EffectivePermissions } from '@/lib/auth/permissions' /** * Vermerke an einer Abteilung. * * Harte Regel 2 aus CLAUDE.md gilt hier genauso wie im Verlauf einer Akte: * **es gibt kein Löschen.** Jede Änderung legt den vorherigen Stand als * Version ab, jede Zurücknahme lässt einen Grabstein stehen, und beides sieht * jeder, der den Vermerk sehen darf. Die Regeln stehen in der Datenbank * (`dept_entry_no_delete`, `dept_entry_immutable_guard`) und nicht nur hier. * * Schmaler als der Aktenverlauf ist er trotzdem: keine Korrekturkette, keine * Anhänge, keine Karenzzeit. Ein Abteilungsvermerk ist ein laufendes * Protokoll, kein Beweismittel — die Ermittlung gehört in eine Fallakte. */ const AUSWAHL = { id: true, body: true, minClearance: true, authorId: true, createdAt: true, version: true, editedAt: true, editedById: true, retractedAt: true, retractedById: true, retractionReason: true, } as const export async function listEntries(p: EffectivePermissions, departmentId: string, take = 50) { return db.departmentEntry.findMany({ where: { AND: [visibleDepartmentEntryWhere(p), { departmentId }] }, orderBy: { createdAt: 'desc' }, take, select: AUSWAHL, }) } /** Wer Vermerke schreiben darf. Dasselbe Recht wie für den Aktenverlauf. */ function darfSchreiben(p: EffectivePermissions): boolean { return p.isAdmin || p.permissions.has('entry.create') } export async function createEntry( p: EffectivePermissions, input: { id: string; departmentKey: string; body: string; minClearance: number }, ): Promise<{ id: string }> { if (!darfSchreiben(p)) throw new AccessError('no-permission') const abteilung = await db.department.findFirst({ where: { AND: [visibleDepartmentWhere(p), { key: input.departmentKey }] }, select: { id: true, key: true }, }) if (!abteilung) throw new AccessError('not-found') const text = input.body.trim() if (text.length === 0) throw new AccessError('entry-empty') // Niemand schreibt über seiner eigenen Stufe: sonst legte man einen Vermerk // an, den man selbst nicht mehr lesen kann. Dieselbe Regel wie bei Akten. if (!p.isAdmin && input.minClearance > p.clearanceLevel) { throw new AccessError('clearance-too-low') } // Dieselbe Kennung zweimal erzeugt keinen zweiten Vermerk. const vorhanden = await db.departmentEntry.findUnique({ where: { id: input.id }, select: { id: true }, }) if (vorhanden) return vorhanden await db.$transaction(async (tx) => { await tx.departmentEntry.create({ data: { id: input.id, departmentId: abteilung.id, body: text, minClearance: input.minClearance, authorId: p.userId, }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.entry.create', targetType: 'DepartmentEntry', targetId: input.id, clearanceAtTime: p.clearanceLevel, meta: { department: abteilung.key, minClearance: input.minClearance }, }) }) return { id: input.id } } /** * Ändern. Der vorherige Stand wird zur Version — in derselben Transaktion, * sonst gäbe es einen Moment, in dem der alte Text nirgends mehr steht. */ export async function editEntry( p: EffectivePermissions, input: { id: string; body: string }, ): Promise { const vermerk = await db.departmentEntry.findFirst({ where: { AND: [visibleDepartmentEntryWhere(p), { id: input.id }] }, select: { id: true, body: true, minClearance: true, authorId: true, version: true }, }) if (!vermerk) throw new AccessError('not-found') const eigener = vermerk.authorId === p.userId const darf = p.isAdmin || (eigener && p.permissions.has('entry.edit_own')) || p.permissions.has('entry.edit_any') if (!darf) throw new AccessError('no-permission') const text = input.body.trim() if (text.length === 0) throw new AccessError('entry-empty') if (text === vermerk.body) return await db.$transaction(async (tx) => { await tx.departmentEntryVersion.create({ data: { entryId: vermerk.id, version: vermerk.version, body: vermerk.body, minClearance: vermerk.minClearance, editedById: p.userId, }, }) await tx.departmentEntry.update({ where: { id: vermerk.id }, data: { body: text, version: vermerk.version + 1, editedAt: new Date(), editedById: p.userId }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.entry.edit', targetType: 'DepartmentEntry', targetId: vermerk.id, clearanceAtTime: p.clearanceLevel, meta: { version: vermerk.version + 1 }, }) }) } /** * Zurückziehen. Kein Löschen: der Vermerk bleibt als Grabstein stehen, mit * Grund und Namen. Wer etwas anderes sagen will, schreibt einen neuen. */ export async function retractEntry( p: EffectivePermissions, input: { id: string; reason: string }, ): Promise { const vermerk = await db.departmentEntry.findFirst({ where: { AND: [visibleDepartmentEntryWhere(p), { id: input.id }] }, select: { id: true, retractedAt: true }, }) if (!vermerk) throw new AccessError('not-found') if (!p.isAdmin && !p.permissions.has('entry.retract')) throw new AccessError('no-permission') if (vermerk.retractedAt) return const grund = input.reason.trim() if (grund.length === 0) throw new AccessError('reason-required') await db.$transaction(async (tx) => { await tx.departmentEntry.update({ where: { id: vermerk.id }, data: { retractedAt: new Date(), retractedById: p.userId, retractionReason: grund }, }) await writeAudit(tx, { actorId: p.userId, action: 'department.entry.retract', targetType: 'DepartmentEntry', targetId: vermerk.id, clearanceAtTime: p.clearanceLevel, meta: { reason: grund }, }) }) }