import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit/write' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { canEditCase } from '@/lib/access/caseWrite' import { AccessError, must } from '@/lib/errors' import { loadVisibleCase } from '@/lib/cases/service' import type { LinkKindName } from '@/lib/cases/terms' import type { EffectivePermissions } from '@/lib/auth/permissions' /** * Verknüpfungen zwischen Akten. * * Die Richtung trägt Bedeutung: `SUCCESSOR` und `MERGED_INTO` sagen etwas * anderes, je nachdem, von welcher Seite man liest. `RELATED` ist symmetrisch. * * Beide Seiten laufen durch die Sichtbarkeitsprüfung. Eine Akte, die der Leser * nicht sehen darf, taucht auch als Verknüpfung nicht auf — sonst verriete die * Gegenseite ihre Existenz. Das gilt in **beide** Richtungen, deshalb wird der * Filter zweimal angewandt. */ const AKTE = { id: true, caseNumber: true, title: true, status: true, classification: true, updatedAt: true, } as const /** Alle Verknüpfungen einer Akte, aus beiden Richtungen, gefiltert. */ export async function listLinks(p: EffectivePermissions, caseId: string) { const sichtbar = visibleCaseWhere(p) const [hinaus, herein] = await Promise.all([ db.caseLink.findMany({ where: { AND: [{ fromId: caseId }, { to: { is: sichtbar } }] }, select: { kind: true, createdAt: true, to: { select: AKTE } }, orderBy: { createdAt: 'desc' }, }), db.caseLink.findMany({ where: { AND: [{ toId: caseId }, { from: { is: sichtbar } }] }, select: { kind: true, createdAt: true, from: { select: AKTE } }, orderBy: { createdAt: 'desc' }, }), ]) return [ ...hinaus.map((link) => ({ kind: link.kind, direction: 'out' as const, other: link.to, createdAt: link.createdAt, })), ...herein.map((link) => ({ kind: link.kind, direction: 'in' as const, other: link.from, createdAt: link.createdAt, })), ] } export async function linkCases( p: EffectivePermissions, input: { fromCaseNumber: string; toCaseNumber: string; kind: LinkKindName }, ): Promise { const from = await loadVisibleCase(p, input.fromCaseNumber) if (!from) throw new AccessError('not-found') must(canEditCase(p, from)) const to = await loadVisibleCase(p, input.toCaseNumber.trim()) if (!to) throw new AccessError('not-found') if (to.id === from.id) throw new AccessError('link-self') await db.$transaction(async (tx) => { await tx.caseLink.upsert({ where: { fromId_toId_kind: { fromId: from.id, toId: to.id, kind: input.kind } }, create: { fromId: from.id, toId: to.id, kind: input.kind, createdById: p.userId }, update: {}, }) await writeAudit(tx, { actorId: p.userId, action: 'case.link', targetType: 'Case', targetId: from.id, clearanceAtTime: p.clearanceLevel, meta: { from: from.caseNumber, to: to.caseNumber, kind: input.kind }, }) }) } /** * Verknüpfung lösen. Umkehrbar und deshalb ohne Bestätigungsdialog, siehe * docs/09-performance-und-ux.md. Das Protokoll behält sie. */ export async function unlinkCases( p: EffectivePermissions, input: { fromCaseNumber: string; toCaseNumber: string; kind: LinkKindName }, ): Promise { const from = await loadVisibleCase(p, input.fromCaseNumber) if (!from) throw new AccessError('not-found') must(canEditCase(p, from)) const to = await loadVisibleCase(p, input.toCaseNumber) if (!to) throw new AccessError('not-found') await db.$transaction(async (tx) => { await tx.caseLink.deleteMany({ where: { OR: [ { fromId: from.id, toId: to.id, kind: input.kind }, { fromId: to.id, toId: from.id, kind: input.kind }, ], }, }) await writeAudit(tx, { actorId: p.userId, action: 'case.unlink', targetType: 'Case', targetId: from.id, clearanceAtTime: p.clearanceLevel, meta: { from: from.caseNumber, to: to.caseNumber, kind: input.kind }, }) }) }