import type { Prisma } from '@afis/db' import { db } from '@/lib/db' import { AccessError } from '@/lib/errors' import { writeAudit } from '@/lib/audit/write' import { invalidateAll } from '@/lib/auth/effective' import type { EffectivePermissions } from '@/lib/auth/permissions' import { resolveAll, type LadderRung } from '@/lib/auth/ladders' /** * Verwaltung der Ranggefüge. * * Wer Leitern ändert, ändert die Rechte jedes Trägers - deshalb steht hinter * jeder Schreibfunktion ein Protokolleintrag (harte Regel 3) und `roles.manage` * als Recht. Die Rechnung selbst steht in lib/auth/ladders.ts; hier steht nur, * wie sie in die Datenbank kommt. */ /** Höchstzahl Sprossen je Leiter. Achtzehn Rollen sind der ganze Bestand. */ export const MAX_RUNGS = 30 const NAME_MAX = 80 const NOTE_MAX = 300 function darf(p: EffectivePermissions): void { if (!p.isAdmin && !p.permissions.has('roles.manage')) { throw new AccessError('no-permission') } } function pruefeName(name: string): string { const sauber = name.trim() if (sauber.length === 0 || sauber.length > NAME_MAX) { throw new AccessError('ladder-name-invalid') } return sauber } export type LadderRoleView = { discordRoleId: string label: string ladderRank: number isPresent: boolean /** Was in der eigenen Zeile steht. */ ownPermissions: string[] ownClearanceLevel: number /** Was durch die Leiter dazukommt. */ inheritedPermissions: string[] effectiveClearanceLevel: number effectiveGrantsMembership: boolean } export type LadderView = { id: string name: string note: string | null roles: LadderRoleView[] } /** * Alle Leitern mit ihren Sprossen, von unten nach oben. * * Die Rollen einer Leiter werden vollständig geladen, weil die Vererbung nur * mit allen Sprossen zu rechnen ist - es sind je Leiter eine Handvoll. */ export async function listLadders(p: EffectivePermissions): Promise { darf(p) const leitern = await db.roleLadder.findMany({ orderBy: { name: 'asc' }, include: { roles: { orderBy: [{ ladderRank: 'asc' }, { label: 'asc' }], select: { discordRoleId: true, label: true, clearanceLevel: true, permissions: true, grantsMembership: true, ladderId: true, ladderRank: true, isPresent: true, }, }, }, }) return leitern.map((leiter) => { const aufgeloest = resolveAll(leiter.roles as LadderRung[]) return { id: leiter.id, name: leiter.name, note: leiter.note, roles: leiter.roles.map((rolle) => { const ergebnis = aufgeloest.get(rolle.discordRoleId) return { discordRoleId: rolle.discordRoleId, label: rolle.label, ladderRank: rolle.ladderRank ?? 0, isPresent: rolle.isPresent, ownPermissions: rolle.permissions, ownClearanceLevel: rolle.clearanceLevel, inheritedPermissions: [...(ergebnis?.inherited ?? [])].sort(), effectiveClearanceLevel: ergebnis?.clearanceLevel ?? rolle.clearanceLevel, effectiveGrantsMembership: ergebnis?.grantsMembership ?? rolle.grantsMembership, } }), } }) } async function protokoll( client: Pick, p: EffectivePermissions, ladderId: string, meta: Prisma.InputJsonValue, ): Promise { await writeAudit(client, { actorId: p.userId, action: 'roles.update', targetType: 'RoleLadder', targetId: ladderId, clearanceAtTime: p.clearanceLevel, meta, }) } export async function createLadder( p: EffectivePermissions, input: { name: string; note?: string }, ): Promise { darf(p) const name = pruefeName(input.name) const note = input.note?.trim().slice(0, NOTE_MAX) || null const vorhanden = await db.roleLadder.findUnique({ where: { name }, select: { id: true } }) if (vorhanden) throw new AccessError('ladder-name-taken') const leiter = await db.$transaction(async (tx) => { const angelegt = await tx.roleLadder.create({ data: { name, note, createdById: p.userId }, select: { id: true }, }) await protokoll(tx, p, angelegt.id, { created: name }) return angelegt }) invalidateAll() return leiter.id } export async function updateLadder( p: EffectivePermissions, input: { id: string; name: string; note?: string }, ): Promise { darf(p) const name = pruefeName(input.name) const note = input.note?.trim().slice(0, NOTE_MAX) || null const vorhanden = await db.roleLadder.findUnique({ where: { name }, select: { id: true }, }) if (vorhanden && vorhanden.id !== input.id) throw new AccessError('ladder-name-taken') await db.$transaction(async (tx) => { const alt = await tx.roleLadder.findUnique({ where: { id: input.id }, select: { name: true }, }) if (!alt) throw new AccessError('not-found') await tx.roleLadder.update({ where: { id: input.id }, data: { name, note } }) await protokoll(tx, p, input.id, { renamedFrom: alt.name, renamedTo: name }) }) invalidateAll() } /** * Leiter auflösen. Die Rollen bleiben, sie stehen danach wieder für sich. * * Das ist der Grund für `ON DELETE SET NULL` statt eines Verbots: eine Leiter * loszuwerden darf nicht daran scheitern, dass Rollen darin stehen. Dass die * Träger dabei Rechte verlieren, ist gewollt und steht im Protokoll. */ export async function deleteLadder(p: EffectivePermissions, id: string): Promise { darf(p) await db.$transaction(async (tx) => { const leiter = await tx.roleLadder.findUnique({ where: { id }, select: { name: true, roles: { select: { discordRoleId: true } } }, }) if (!leiter) throw new AccessError('not-found') await tx.roleMapping.updateMany({ where: { ladderId: id }, data: { ladderId: null, ladderRank: null }, }) await protokoll(tx, p, id, { deleted: leiter.name, releasedRoles: leiter.roles.map((r) => r.discordRoleId), }) await tx.roleLadder.delete({ where: { id } }) }) invalidateAll() } /** * Die Sprossen einer Leiter neu setzen: Zugehörigkeit und Reihenfolge in einem * Zug, von unten nach oben. * * In **einem** Zug, weil jeder Zwischenzustand ein anderer Rechtestand wäre. * Rollen, die vorher in dieser Leiter standen und jetzt nicht mehr in der Liste * sind, fallen heraus - eine Rolle steht in höchstens einer Leiter. */ export async function setLadderRoles( p: EffectivePermissions, input: { id: string; roleIds: readonly string[] }, ): Promise { darf(p) const reihenfolge = [...new Set(input.roleIds)] if (reihenfolge.length > MAX_RUNGS) throw new AccessError('ladder-too-long') await db.$transaction(async (tx) => { const leiter = await tx.roleLadder.findUnique({ where: { id: input.id }, select: { name: true }, }) if (!leiter) throw new AccessError('not-found') if (reihenfolge.length > 0) { const bekannt = await tx.roleMapping.findMany({ where: { discordRoleId: { in: reihenfolge } }, select: { discordRoleId: true }, }) if (bekannt.length !== reihenfolge.length) throw new AccessError('not-found') } // Erst alles lösen, dann neu setzen. Sonst müsste die Reihenfolge der // Schreibvorgänge stimmen, damit keine Rolle zwischenzeitlich in zwei // Leitern steht. await tx.roleMapping.updateMany({ where: { ladderId: input.id }, data: { ladderId: null, ladderRank: null }, }) for (const [rang, discordRoleId] of reihenfolge.entries()) { await tx.roleMapping.update({ where: { discordRoleId }, data: { ladderId: input.id, ladderRank: rang }, }) } await protokoll(tx, p, input.id, { ladder: leiter.name, rungs: reihenfolge }) }) invalidateAll() }