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.
396 lines
12 KiB
TypeScript
396 lines
12 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 { canWritePersonnel } from '@/lib/access/documentWrite'
|
|
import { AccessError, must } from '@/lib/errors'
|
|
import type { AssignmentRoleName } from '@/lib/personnel/terms'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
import type { Prisma } from '@afis/db'
|
|
|
|
/**
|
|
* Personalakten. Eine Akte zu einer Person, auch zu einer **ohne
|
|
* Discord-Konto**: Zeugen, ehemalige Mitarbeiter und Personen, über die
|
|
* lediglich Aktenlage besteht, brauchen einen Platz.
|
|
*
|
|
* Die Rückverweise sind der heikelste Teil. Siehe `loadBacklinks`.
|
|
*/
|
|
|
|
const KOPFDATEN = {
|
|
id: true,
|
|
userId: true,
|
|
displayName: true,
|
|
rank: true,
|
|
departmentId: true,
|
|
department: { select: { id: true, key: true, name: true } },
|
|
statusTags: true,
|
|
note: true,
|
|
classification: true,
|
|
createdById: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} satisfies Prisma.PersonnelFileSelect
|
|
|
|
export async function loadVisiblePersonnelFile(p: EffectivePermissions, id: string) {
|
|
return db.personnelFile.findFirst({
|
|
where: { AND: [visiblePersonnelWhere(p), { id }] },
|
|
select: KOPFDATEN,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Liste der Personalakten. Suchbegriff und Zählung laufen durch dieselbe
|
|
* Abfrage wie die Sichtbarkeit, damit sich aus einer Trefferzahl nichts über
|
|
* verborgene Akten ableiten lässt.
|
|
*/
|
|
export async function listPersonnelFiles(
|
|
p: EffectivePermissions,
|
|
options: { query?: string; statusTag?: string; take?: number } = {},
|
|
) {
|
|
const begriff = options.query?.trim()
|
|
const where: Prisma.PersonnelFileWhereInput = {
|
|
AND: [
|
|
visiblePersonnelWhere(p),
|
|
...(options.statusTag ? [{ statusTags: { has: options.statusTag } }] : []),
|
|
...(begriff
|
|
? [
|
|
{
|
|
OR: [
|
|
{ displayName: { contains: begriff, mode: 'insensitive' as const } },
|
|
{ rank: { contains: begriff, mode: 'insensitive' as const } },
|
|
{ department: { is: { name: { contains: begriff, mode: 'insensitive' as const } } } },
|
|
{ department: { is: { key: { contains: begriff, mode: 'insensitive' as const } } } },
|
|
],
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
}
|
|
|
|
const [files, total] = await Promise.all([
|
|
db.personnelFile.findMany({
|
|
where,
|
|
orderBy: [{ displayName: 'asc' }],
|
|
take: options.take ?? 50,
|
|
select: KOPFDATEN,
|
|
}),
|
|
db.personnelFile.count({ where }),
|
|
])
|
|
|
|
return { files, total }
|
|
}
|
|
|
|
export async function createPersonnelFile(
|
|
p: EffectivePermissions,
|
|
input: {
|
|
displayName: string
|
|
userId?: string | null
|
|
rank?: string | null
|
|
departmentId?: string | null
|
|
statusTags?: string[]
|
|
note?: string | null
|
|
classification: number
|
|
},
|
|
): Promise<{ id: string }> {
|
|
must(canWritePersonnel(p, input.classification))
|
|
|
|
const name = input.displayName.trim()
|
|
if (name.length < 2) throw new AccessError('name-required')
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const created = await tx.personnelFile.create({
|
|
data: {
|
|
displayName: name,
|
|
userId: input.userId || null,
|
|
rank: input.rank?.trim() || null,
|
|
departmentId: input.departmentId || null,
|
|
statusTags: await gueltigeStatusEtiketten(input.statusTags),
|
|
note: input.note?.trim() || null,
|
|
classification: input.classification,
|
|
createdById: p.userId,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'personnel.create',
|
|
targetType: 'PersonnelFile',
|
|
targetId: created.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: { displayName: name, classification: input.classification },
|
|
})
|
|
|
|
return created
|
|
})
|
|
}
|
|
|
|
/** Kopfdaten ändern. Die Klassifizierung läuft über `reclassifyPersonnelFile`. */
|
|
export async function updatePersonnelFile(
|
|
p: EffectivePermissions,
|
|
id: string,
|
|
input: {
|
|
displayName?: string
|
|
rank?: string | null
|
|
departmentId?: string | null
|
|
statusTags?: string[]
|
|
note?: string | null
|
|
},
|
|
): Promise<void> {
|
|
const target = await loadVisiblePersonnelFile(p, id)
|
|
if (!target) throw new AccessError('not-found')
|
|
must(canWritePersonnel(p, target.classification))
|
|
|
|
const name = input.displayName?.trim()
|
|
if (name !== undefined && name.length < 2) throw new AccessError('name-required')
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.personnelFile.update({
|
|
where: { id },
|
|
data: {
|
|
...(name !== undefined ? { displayName: name } : {}),
|
|
...(input.rank !== undefined ? { rank: input.rank?.trim() || null } : {}),
|
|
...(input.departmentId !== undefined
|
|
? { departmentId: input.departmentId || null }
|
|
: {}),
|
|
...(input.statusTags !== undefined
|
|
? { statusTags: await gueltigeStatusEtiketten(input.statusTags) }
|
|
: {}),
|
|
...(input.note !== undefined ? { note: input.note?.trim() || null } : {}),
|
|
},
|
|
})
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'personnel.update',
|
|
targetType: 'PersonnelFile',
|
|
targetId: id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: { fields: Object.keys(input) },
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Klassifizierung ändern. Wie bei der Fallakte ist die Herabstufung der
|
|
* heiklere Fall: sie macht die Akte für Leute sichtbar, die sie vorher nicht
|
|
* sehen durften. Sie braucht eine Begründung und steht im Protokoll.
|
|
*/
|
|
export async function reclassifyPersonnelFile(
|
|
p: EffectivePermissions,
|
|
id: string,
|
|
to: number,
|
|
reason?: string,
|
|
): Promise<void> {
|
|
const target = await loadVisiblePersonnelFile(p, id)
|
|
if (!target) throw new AccessError('not-found')
|
|
if (target.classification === to) return
|
|
if (to < 0 || to > 5) throw new AccessError('clearance-too-low')
|
|
must(canWritePersonnel(p, Math.max(to, target.classification)))
|
|
|
|
const lowering = to < target.classification
|
|
const begruendung = reason?.trim() ?? ''
|
|
if (lowering && begruendung.length < 10) throw new AccessError('reason-required')
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.personnelFile.update({ where: { id }, data: { classification: to } })
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'personnel.reclassify',
|
|
targetType: 'PersonnelFile',
|
|
targetId: id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
from: target.classification,
|
|
to,
|
|
lowering,
|
|
reason: begruendung || undefined,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Rückverweise: die Fälle, in denen diese Person vorkommt, nach Rolle getrennt.
|
|
*
|
|
* **Diese Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.**
|
|
* Das ist der häufigste Ort, an dem ein Aktensystem leckt: eine Personalakte
|
|
* der Stufe 1, auf der „3 Fälle" steht, verrät die Existenz von Ermittlungen,
|
|
* die der Leser nicht sehen darf. Deshalb steckt der Filter in der Abfrage und
|
|
* die Zählung entsteht aus dem gefilterten Ergebnis - nie aus einem eigenen
|
|
* `count` ohne Filter. Siehe docs/05-akten-und-vorlagen.md, Rückverweise.
|
|
*/
|
|
export async function loadBacklinks(
|
|
p: EffectivePermissions,
|
|
file: { id: string; userId: string | null },
|
|
) {
|
|
const wenDieAkteMeint: Prisma.CaseParticipantWhereInput[] = [{ personnelFileId: file.id }]
|
|
// Dieselbe Person kann über ihre Personalakte oder über ihr Discord-Konto
|
|
// eingetragen sein. Beides zählt, sonst fehlen Fälle.
|
|
if (file.userId) wenDieAkteMeint.push({ userId: file.userId })
|
|
|
|
const rows = await db.caseParticipant.findMany({
|
|
where: {
|
|
AND: [{ OR: wenDieAkteMeint }, { case: { is: visibleCaseWhere(p) } }],
|
|
},
|
|
select: {
|
|
role: true,
|
|
case: {
|
|
select: {
|
|
id: true,
|
|
caseNumber: true,
|
|
title: true,
|
|
status: true,
|
|
classification: true,
|
|
updatedAt: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 200,
|
|
})
|
|
|
|
// Eine Person kann in einem Fall mehrere Rollen haben; jede Rolle bekommt
|
|
// ihren Eintrag, der Fall steht dann in beiden Gruppen.
|
|
const nachRolle = new Map<string, typeof rows>()
|
|
for (const row of rows) {
|
|
const gruppe = nachRolle.get(row.role) ?? []
|
|
gruppe.push(row)
|
|
nachRolle.set(row.role, gruppe)
|
|
}
|
|
|
|
return {
|
|
total: rows.length,
|
|
byRole: [...nachRolle.entries()].map(([role, eintraege]) => ({ role, eintraege })),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filtert auf die Etiketten, die es wirklich gibt.
|
|
*
|
|
* Sonst könnte ein manipuliertes Formular einen erfundenen Status setzen, und
|
|
* die Liste im Adminbereich wäre nur noch eine Empfehlung. Die Reihenfolge
|
|
* kommt aus der Konfiguration, nicht aus der Eingabe: so steht auf jeder Akte
|
|
* dieselbe Abfolge.
|
|
*/
|
|
async function gueltigeStatusEtiketten(gewuenscht: string[] | undefined): Promise<string[]> {
|
|
if (!gewuenscht?.length) return []
|
|
const bekannt = await db.personnelStatusTag.findMany({
|
|
where: { key: { in: gewuenscht }, isActive: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
select: { key: true },
|
|
})
|
|
return bekannt.map((tag) => tag.key)
|
|
}
|
|
|
|
/** Die Etiketten zur Auswahl, für Formulare und Filter. */
|
|
export async function listStatusTags() {
|
|
return db.personnelStatusTag.findMany({
|
|
where: { isActive: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
select: { key: true, name: true, colorToken: true },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Die AfiS-Mitglieder, die an dieser Personalakte hängen.
|
|
*
|
|
* Wer hier steht, liest die Akte unabhängig von seiner Freigabestufe - das ist
|
|
* derselbe ausnahmsweise Zugang wie `CaseParticipant` bei der Fallakte.
|
|
*/
|
|
export async function listAssignments(p: EffectivePermissions, personnelFileId: string) {
|
|
return db.personnelAssignment.findMany({
|
|
where: {
|
|
AND: [{ personnelFileId }, { personnelFile: { is: visiblePersonnelWhere(p) } }],
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
select: {
|
|
id: true,
|
|
role: true,
|
|
createdAt: true,
|
|
user: { select: { id: true, displayName: true } },
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function addAssignment(
|
|
p: EffectivePermissions,
|
|
input: { personnelFileId: string; userId: string; role: AssignmentRoleName },
|
|
): Promise<{ id: string }> {
|
|
const target = await loadVisiblePersonnelFile(p, input.personnelFileId)
|
|
if (!target) throw new AccessError('not-found')
|
|
must(canWritePersonnel(p, target.classification))
|
|
|
|
const mitglied = await db.user.findUnique({
|
|
where: { id: input.userId },
|
|
select: { id: true },
|
|
})
|
|
if (!mitglied) throw new AccessError('not-found')
|
|
|
|
const vorhanden = await db.personnelAssignment.findFirst({
|
|
where: { personnelFileId: target.id, userId: input.userId, role: input.role },
|
|
select: { id: true },
|
|
})
|
|
if (vorhanden) return vorhanden
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const created = await tx.personnelAssignment.create({
|
|
data: {
|
|
personnelFileId: target.id,
|
|
userId: input.userId,
|
|
role: input.role,
|
|
addedById: p.userId,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'personnel.assign',
|
|
targetType: 'PersonnelAssignment',
|
|
targetId: created.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
personnelFileId: target.id,
|
|
displayName: target.displayName,
|
|
userId: input.userId,
|
|
role: input.role,
|
|
},
|
|
})
|
|
|
|
return created
|
|
})
|
|
}
|
|
|
|
export async function removeAssignment(
|
|
p: EffectivePermissions,
|
|
input: { personnelFileId: string; assignmentId: string },
|
|
): Promise<void> {
|
|
const target = await loadVisiblePersonnelFile(p, input.personnelFileId)
|
|
if (!target) throw new AccessError('not-found')
|
|
must(canWritePersonnel(p, target.classification))
|
|
|
|
const zuordnung = await db.personnelAssignment.findFirst({
|
|
where: { id: input.assignmentId, personnelFileId: target.id },
|
|
select: { id: true, userId: true, role: true },
|
|
})
|
|
if (!zuordnung) throw new AccessError('not-found')
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.personnelAssignment.delete({ where: { id: zuordnung.id } })
|
|
|
|
await writeAudit(tx, {
|
|
actorId: p.userId,
|
|
action: 'personnel.unassign',
|
|
targetType: 'PersonnelAssignment',
|
|
targetId: zuordnung.id,
|
|
clearanceAtTime: p.clearanceLevel,
|
|
meta: {
|
|
personnelFileId: target.id,
|
|
userId: zuordnung.userId,
|
|
role: zuordnung.role,
|
|
},
|
|
})
|
|
})
|
|
}
|