Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
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,
|
|
},
|
|
})
|
|
})
|
|
}
|