import { db } from '@/lib/db' import { writeAudit } from '@/lib/audit' 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 { 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, }, }) }) }