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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit/write'
|
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { AccessError, must } from '@/lib/errors'
|
|
import { loadVisibleCase } from '@/lib/cases/service'
|
|
import type { ParticipantRoleName } from '@/lib/cases/terms'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Beteiligte einer Akte.
|
|
*
|
|
* Zwei Dinge zugleich: eine fachliche Angabe („wer ist Zeuge") und der Weg zum
|
|
* **ausnahmsweisen Zugang**. Wer als Beteiligter eingetragen ist, liest die
|
|
* Akte unabhängig von seiner Freigabestufe. Das ist der vorgesehene Weg,
|
|
* jemanden gezielt hinzuzuziehen, ohne ihm allgemein eine höhere Stufe zu
|
|
* geben. Es setzt `case.edit` voraus und wird protokolliert.
|
|
* Siehe docs/03-auth-und-rechte.md.
|
|
*/
|
|
|
|
export async function listParticipants(p: EffectivePermissions, caseId: string) {
|
|
const rows = await db.caseParticipant.findMany({
|
|
where: { AND: [{ caseId }, { case: { is: visibleCaseWhere(p) } }] },
|
|
orderBy: { createdAt: 'asc' },
|
|
select: {
|
|
id: true,
|
|
role: true,
|
|
createdAt: true,
|
|
addedById: true,
|
|
user: { select: { id: true, displayName: true } },
|
|
// Die Personalakte kommt nur mit, wenn der Leser sie sehen darf. Sonst
|
|
// steht der Name da, aber der Verweis führt ins Leere — das ist richtig
|
|
// so: der Beteiligte ist Teil der Akte, seine Personalakte nicht.
|
|
personnelFile: { select: { id: true, displayName: true } },
|
|
},
|
|
})
|
|
|
|
const sichtbareAkten = new Set(
|
|
(
|
|
await db.personnelFile.findMany({
|
|
where: {
|
|
AND: [
|
|
visiblePersonnelWhere(p),
|
|
{
|
|
id: {
|
|
in: rows.flatMap((row) => (row.personnelFile ? [row.personnelFile.id] : [])),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
select: { id: true },
|
|
})
|
|
).map((row) => row.id),
|
|
)
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
role: row.role,
|
|
createdAt: row.createdAt,
|
|
userId: row.user?.id ?? null,
|
|
displayName: row.user?.displayName ?? row.personnelFile?.displayName ?? null,
|
|
personnelFileId:
|
|
row.personnelFile && sichtbareAkten.has(row.personnelFile.id) ? row.personnelFile.id : null,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Trägt einen Beteiligten ein. Entweder ein Discord-Konto oder eine
|
|
* Personalakte, nicht beides und nicht keins.
|
|
*/
|
|
export async function addParticipant(
|
|
p: EffectivePermissions,
|
|
input: {
|
|
caseNumber: string
|
|
role: ParticipantRoleName
|
|
userId?: string | null
|
|
personnelFileId?: string | null
|
|
},
|
|
): Promise<{ id: string }> {
|
|
const target = await loadVisibleCase(p, input.caseNumber)
|
|
if (!target) throw new AccessError('not-found')
|
|
must(canEditCase(p, target))
|
|
|
|
const userId = input.userId || null
|
|
const personnelFileId = input.personnelFileId || null
|
|
if ((userId === null) === (personnelFileId === null)) throw new AccessError('participant-target')
|
|
|
|
if (userId) {
|
|
const vorhanden = await db.user.findUnique({ where: { id: userId }, select: { id: true } })
|
|
if (!vorhanden) throw new AccessError('not-found')
|
|
}
|
|
if (personnelFileId) {
|
|
// Eine Personalakte, die der Eintragende nicht sehen darf, kann er auch
|
|
// nicht als Beteiligte setzen — sonst verrät ihm die Fehlermeldung ihre
|
|
// Existenz.
|
|
const akte = await db.personnelFile.findFirst({
|
|
where: { AND: [visiblePersonnelWhere(p), { id: personnelFileId }] },
|
|
select: { id: true },
|
|
})
|
|
if (!akte) throw new AccessError('not-found')
|
|
}
|
|
|
|
const bereitsDa = await db.caseParticipant.findFirst({
|
|
where: { caseId: target.id, role: input.role, userId, personnelFileId },
|
|
select: { id: true },
|
|
})
|
|
if (bereitsDa) return bereitsDa
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const created = await tx.caseParticipant.create({
|
|
data: { caseId: target.id, role: input.role, userId, personnelFileId, addedById: p.userId },
|
|
select: { id: true },
|
|
})
|
|
|
|
// Sichtbar im Verlauf, nicht nur im Protokoll: dass jemand Zugang zu einer
|
|
// Akte bekommt, geht alle an, die die Akte lesen.
|
|
await tx.caseEntry.create({
|
|
data: {
|
|
id: crypto.randomUUID(),
|
|
caseId: target.id,
|
|
type: 'SYSTEM',
|
|
body: `Beteiligte eingetragen: ${input.role}`,
|
|
authorId: p.userId,
|
|
minClearance: 0,
|
|
},
|
|
})
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'case.participant.add',
|
|
targetType: 'CaseParticipant',
|
|
targetId: created.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
caseNumber: target.caseNumber,
|
|
role: input.role,
|
|
userId: userId ?? undefined,
|
|
personnelFileId: personnelFileId ?? undefined,
|
|
},
|
|
})
|
|
|
|
return created
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Entfernt einen Beteiligten. Anders als ein Verlaufseintrag ist das keine
|
|
* Aussage über den Sachverhalt, sondern eine Zuständigkeit — sie darf
|
|
* verschwinden. Das Protokoll behält sie.
|
|
*/
|
|
export async function removeParticipant(
|
|
p: EffectivePermissions,
|
|
input: { caseNumber: string; participantId: string },
|
|
): Promise<void> {
|
|
const target = await loadVisibleCase(p, input.caseNumber)
|
|
if (!target) throw new AccessError('not-found')
|
|
must(canEditCase(p, target))
|
|
|
|
const teilnehmer = await db.caseParticipant.findFirst({
|
|
where: { id: input.participantId, caseId: target.id },
|
|
select: { id: true, role: true, userId: true, personnelFileId: true },
|
|
})
|
|
if (!teilnehmer) throw new AccessError('not-found')
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.caseParticipant.delete({ where: { id: teilnehmer.id } })
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'case.participant.remove',
|
|
targetType: 'CaseParticipant',
|
|
targetId: teilnehmer.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
caseNumber: target.caseNumber,
|
|
role: teilnehmer.role,
|
|
userId: teilnehmer.userId ?? undefined,
|
|
personnelFileId: teilnehmer.personnelFileId ?? undefined,
|
|
},
|
|
})
|
|
})
|
|
}
|