Files
AfiS-Aktensystem/web/lib/cases/links.ts
T
Harlekin 89edaa6ae1 Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog -
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die
Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes
Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten
aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst - ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt - elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.
2026-09-03 15:16:10 +02:00

129 lines
4.0 KiB
TypeScript

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<void> {
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<void> {
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 },
})
})
}