import { randomUUID } from 'node:crypto'; import { CharacterStatus, CreditKind, CreditSource, ForumClassification, InactiveReason, type Character, type Prisma, } from '@prisma/client'; import { prisma } from '../db.js'; import { badRequest, conflict, notFound } from '../errors.js'; import { getBalances, type Balances } from './credits.js'; import { recordAudit, type ActionContext } from './audit.js'; export interface CharacterResult { character: Character; balances: Balances; // Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird // trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht. overdrawn: boolean; } /// Diese Gründe lassen den CC unverändert bestehen - er kommt gratis zurück. const FREE_REASONS = [InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER] as const; /// Hier ist der CC entweder ersetzt oder entzogen: die Rückkehr kostet ein Guthaben. const PAID_REASONS = [InactiveReason.REWORKED, InactiveReason.NO_FREE_CLAIM] as const; // Für ein Redesign zählen nur die beiden Redesign-Töpfe. const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; // Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden. const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const; export type RedesignKind = (typeof REDESIGN_KINDS)[number]; export type ReviveKind = (typeof REVIVE_KINDS)[number]; function isOverdrawn(balances: Balances): boolean { return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0; } /** * Die Slot-Regel. * * Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei * Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns * dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen * zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC. * * Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC * braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen * einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt * noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der * einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht * wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts. * * Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen, * redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten. */ async function assertSlotsReichen( tx: Prisma.TransactionClient, playerId: number, was: string, ): Promise { const [eintraege, belegt] = await Promise.all([ tx.creditLedger.findMany({ where: { playerId, kind: CreditKind.CC }, select: { amount: true, source: true, reversalOf: { select: { source: true } } }, }), tx.characterMember.count({ where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } }, }), ]); // Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art // der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener // Platz von einem zurückgenommenen Paket unterscheiden. let gebuchtePlaetze = 0; for (const eintrag of eintraege) { const zweck = eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount; } if (belegt > gebuchtePlaetze) { throw conflict( 'NO_FREE_SLOT', `${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` + 'Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht ' + 'einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - ' + 'oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.', ); } } async function loadCharacter(id: number): Promise { const character = await prisma.character.findUnique({ where: { id } }); if (!character) throw notFound('CC'); return character; } /** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */ export async function createCharacter( playerId: number, input: { name: string; loreText?: string | undefined; note?: string | undefined }, ctx: ActionContext, ): Promise { const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }); if (!player) throw notFound('Spieler'); const character = await prisma.$transaction(async (tx) => { const created = await tx.character.create({ data: { playerId, name: input.name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, lineageId: randomUUID(), createdById: ctx.actorId, // Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit // läuft durchgehend über diese Tabelle, auch bei Einzel-CCs. members: { create: [{ playerId, createdById: ctx.actorId }] }, }, }); await tx.creditLedger.create({ data: { playerId, kind: CreditKind.CC, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: created.id, note: input.note ?? `Neuer CC: ${created.name}`, createdById: ctx.actorId, }, }); await assertSlotsReichen(tx, playerId, 'Ein neuer CC'); await recordAudit(tx, ctx, { action: 'character.create', targetType: 'character', targetId: created.id, details: { playerId, name: created.name }, }); return created; }); const balances = await getBalances(playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } /** * Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue * entsteht als eigener Datensatz in derselben Kette. Damit greift später * automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt. */ export async function redesignCharacter( characterId: number, input: { name: string; loreText?: string | undefined; useKind: RedesignKind; note?: string | undefined; }, ctx: ActionContext, ): Promise { const old = await loadCharacter(characterId); if (old.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); } if (!REDESIGN_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); } const result = await prisma.$transaction(async (tx) => { const predecessor = await tx.character.update({ where: { id: old.id }, data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED }, }); // Ein Gruppen-CC bleibt nach dem Redesign eine Gruppe: alle, die zum // Zeitpunkt des Redesigns dabei waren, sind es auch danach. const bisherige = await tx.characterMember.findMany({ where: { characterId: old.id, leftAt: null }, select: { playerId: true, paidWith: true }, }); const created = await tx.character.create({ data: { playerId: old.playerId, name: input.name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, lineageId: old.lineageId, // Zehnerschritte, damit ein später gefundenes Zwischenglied noch // dazwischen passt, ohne die ganze Kette anzufassen. chainPosition: old.chainPosition + 10, predecessorId: old.id, createdById: ctx.actorId, members: { create: bisherige.map((mitglied) => ({ playerId: mitglied.playerId, paidWith: mitglied.paidWith, createdById: ctx.actorId, })), }, }, }); await tx.creditLedger.create({ data: { playerId: old.playerId, kind: input.useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: created.id, note: input.note ?? `Redesign: ${old.name} -> ${created.name}`, createdById: ctx.actorId, }, }); await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign'); await recordAudit(tx, ctx, { action: 'character.redesign', targetType: 'character', targetId: created.id, details: { playerId: old.playerId, predecessorId: old.id, from: old.name, to: created.name, useKind: input.useKind, }, }); return { created, predecessor }; }); const balances = await getBalances(old.playerId); return { character: result.created, predecessor: result.predecessor, balances, overdrawn: isOverdrawn(balances), }; } /** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */ export async function deactivateCharacter( characterId: number, reason: InactiveReason, ctx: ActionContext, note?: string, ): Promise { const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.INACTIVE) { throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv'); } if (reason === InactiveReason.REWORKED) { throw badRequest( 'USE_REDESIGN', 'Ein Rework entsteht über die Redesign-Aktion, nicht über eine Deaktivierung', ); } if (reason === InactiveReason.DEAD && !note?.trim()) { throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht'); } const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, data: { status: CharacterStatus.INACTIVE, inactiveReason: reason, inactiveNote: note?.trim() || null, }, }); await recordAudit(tx, ctx, { action: 'character.deactivate', targetType: 'character', targetId: characterId, details: { reason, note: note ?? null, name: updated.name }, }); return updated; }); const balances = await getBalances(character.playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } /** * Gratis-Reaktivierung. Möglich, solange der CC unverändert weiterexistiert - * ob er wegen Spieler-Inaktivität geputzt oder die Einheit zeitweise entfernt * wurde, spielt keine Rolle. Ausgeschlossen sind ein Rework, ein Entzug ohne * Anspruch und der Tod im Rollenspiel. */ export async function reactivateFree( characterId: number, ctx: ActionContext, ): Promise { const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); } if (existing.inactiveReason === InactiveReason.DEAD) { throw conflict( 'IS_DEAD', 'Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr entscheidet ein Administrator.', ); } if (!FREE_REASONS.includes(existing.inactiveReason as (typeof FREE_REASONS)[number])) { throw conflict( 'NOT_FREE', 'Dieser CC hat keinen Anspruch auf kostenlose Rückkehr. Seine Reaktivierung kostet ein Guthaben.', ); } const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); await recordAudit(tx, ctx, { action: 'character.reactivate_free', targetType: 'character', targetId: characterId, details: { playerId: updated.playerId, name: updated.name }, }); return updated; }); const balances = await getBalances(character.playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } /** * Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original * existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein * Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches. */ export async function reactivatePaid( characterId: number, useKind: ReviveKind, ctx: ActionContext, note?: string, ): Promise { const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); } if (existing.inactiveReason === InactiveReason.DEAD) { throw conflict( 'IS_DEAD', 'Ein im Rollenspiel gestorbener CC kehrt nur durch einen Administrator zurück.', ); } if (!PAID_REASONS.includes(existing.inactiveReason as (typeof PAID_REASONS)[number])) { throw conflict( 'IS_FREE', 'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.', ); } if (!REVIVE_KINDS.includes(useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung'); } const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); await tx.creditLedger.create({ data: { playerId: updated.playerId, kind: useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: updated.id, note: note ?? `Reaktivierung: ${updated.name}`, createdById: ctx.actorId, }, }); await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); await recordAudit(tx, ctx, { action: 'character.reactivate_paid', targetType: 'character', targetId: characterId, details: { playerId: updated.playerId, name: updated.name, useKind, previousReason: existing.inactiveReason, }, }); return updated; }); const balances = await getBalances(character.playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } /** * Ändert die Angaben eines CCs: Name, Beschreibung und den Antrag, aus dem * er hervorging. * * Der Antrag wird über seine Thread-Nummer oder die volle Adresse angegeben; * ein leeres Feld löst die bestehende Verknüpfung. Kennt der Abgleich den * Thread noch nicht, wird er vorgemerkt - sonst stünde er beim nächsten Lauf * als offener Antrag da, obwohl er längst eingetragen ist. */ export async function updateCharacter( characterId: number, input: { name?: string; loreText?: string | null; threadRef?: string | null }, ctx: ActionContext, ): Promise { const vorher = await loadCharacter(characterId); const updated = await prisma.$transaction(async (tx) => { const geaendert = await tx.character.update({ where: { id: characterId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.loreText !== undefined ? { loreText: input.loreText } : {}), }, }); await recordAudit(tx, ctx, { action: 'character.update', targetType: 'character', targetId: characterId, details: { name: geaendert.name, vorher: vorher.name }, }); return geaendert; }); if (input.threadRef !== undefined) { await setCharacterThread(characterId, input.threadRef, ctx); } return updated; } /** * Hängt einen Forums-Antrag an einen CC oder löst ihn wieder. * * Angenommen wird die Thread-Nummer, der Pfad oder die volle Adresse - beim * Eintippen von Hand landet mal das eine, mal das andere im Feld. */ export async function setCharacterThread( characterId: number, ref: string | null, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); const { parseThreadId } = await import('../lib/forum.js'); const { linkThreadToCharacter } = await import('./forum.js'); const bisher = await prisma.forumThread.findFirst({ where: { linkedCharacterId: characterId }, select: { threadId: true }, }); const eingabe = ref?.trim() ?? ''; const threadId = eingabe.length === 0 ? null : parseThreadId(eingabe); if (eingabe.length > 0 && threadId === null) { throw badRequest( 'INVALID_THREAD', 'Das ist keine Thread-Nummer und keine Forums-Adresse', ); } if (bisher?.threadId === threadId) return; // Die alte Verknüpfung fällt weg. Der Thread bleibt als entschieden // vermerkt: er wurde ja bearbeitet, nur hängt er jetzt an keinem CC mehr. if (bisher) { await prisma.forumThread.update({ where: { threadId: bisher.threadId }, data: { linkedCharacterId: null }, }); } if (threadId === null) { await recordAudit(prisma, ctx, { action: 'character.thread_unlinked', targetType: 'character', targetId: characterId, details: { name: character.name, threadId: bisher?.threadId ?? null }, }); return; } const vorhanden = await prisma.forumThread.findUnique({ where: { threadId } }); if (vorhanden) { await prisma.forumThread.update({ where: { threadId }, data: { linkedCharacterId: characterId, needsClassification: false, classifiedById: ctx.actorId, classifiedAt: vorhanden.classifiedAt ?? new Date(), playerId: vorhanden.playerId ?? character.playerId, }, }); await recordAudit(prisma, ctx, { action: 'character.thread_linked', targetType: 'character', targetId: characterId, details: { name: character.name, threadId }, }); return; } // Unbekannter Thread: über denselben Weg vormerken wie bei einem von Hand // eingetragenen Antrag, damit er nicht als offen wieder auftaucht. await linkThreadToCharacter(threadId, characterId, ForumClassification.NEW_CC, ctx); } /** * Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen * werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger * kehrt in den aktiven Zustand zurück. */ export async function revertCharacter( characterId: number, reason: string, ctx: ActionContext, ): Promise<{ balances: Balances; restoredPredecessorId: number | null }> { const existing = await prisma.character.findUnique({ where: { id: characterId }, include: { successors: { select: { id: true } }, predecessor: true }, }); if (!existing) throw notFound('CC'); if (existing.successors.length > 0) { throw conflict( 'HAS_SUCCESSORS', 'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.', ); } const consumed = await prisma.creditLedger.findMany({ where: { characterId, source: CreditSource.CONSUME }, include: { reversedBy: { select: { id: true } } }, }); const open = consumed.filter((entry) => entry.reversedBy === null); const predecessor = existing.predecessor; const restorePredecessor = predecessor !== null && predecessor.status === CharacterStatus.INACTIVE && predecessor.inactiveReason === InactiveReason.REWORKED; await prisma.$transaction(async (tx) => { for (const entry of open) { await tx.creditLedger.create({ data: { playerId: entry.playerId, kind: entry.kind, amount: -entry.amount, source: CreditSource.REVERSAL, groupId: entry.groupId, note: `Rücknahme "${existing.name}": ${reason}`, reversalOfId: entry.id, createdById: ctx.actorId, }, }); } if (restorePredecessor && predecessor) { await tx.character.update({ where: { id: predecessor.id }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); } await tx.character.delete({ where: { id: characterId } }); await recordAudit(tx, ctx, { action: 'character.revert', targetType: 'character', targetId: characterId, details: { playerId: existing.playerId, name: existing.name, reason, reversedLedgerIds: open.map((e) => e.id), restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null, }, }); }); return { balances: await getBalances(existing.playerId), restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null, }; } /** * Die CCs eines Spielers. Maßgeblich ist die Mitgliedschaft, nicht wer den CC * angelegt hat - sonst fehlten Gruppen-CCs, denen der Spieler beigetreten ist. * * Mit dabei ist **jede** Mitgliedschaft, auch eine beendete. Wer aus einem * Gruppen-CC herausredesignt hat oder ausgetreten ist, war trotzdem darin - * und das gehört zu seiner Geschichte. Ob daraus ein Anspruch folgt, ist eine * andere Frage und steht am Grund der Mitgliedschaft; hier geht es darum, was * überhaupt zu sehen ist. */ export async function listCharacters(playerId: number) { return prisma.character.findMany({ where: { members: { some: { playerId } } }, orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }], include: { predecessor: { select: { id: true, name: true } }, successors: { select: { id: true, name: true } }, // Der verknüpfte Antrag macht den Namen zum Link ins Forum. forumThreads: { select: { threadId: true, title: true, url: true }, orderBy: { threadId: 'desc' }, }, links: { select: { id: true, label: true, url: true }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }, members: { orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], include: { player: { select: { id: true, displayName: true } }, successor: { select: { id: true, name: true } }, }, }, }, }); } /** * Bringt einen im Rollenspiel gestorbenen CC zurück. Anders als die übrigen * Wege ist das eine Ausnahmeentscheidung: sie bleibt Administratoren * vorbehalten, verlangt eine Begründung und hält beides im Protokoll fest. * * Ob dabei ein Guthaben fließt, entscheidet der Administrator - ein Tod ist * weder automatisch ein Rework noch ein Serverputz. */ export async function reviveDeadCharacter( characterId: number, input: { reason: string; useKind?: ReviveKind | undefined }, ctx: ActionContext, ): Promise { const existing = await loadCharacter(characterId); if (existing.status === CharacterStatus.ACTIVE) { throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv'); } if (existing.inactiveReason !== InactiveReason.DEAD) { throw conflict( 'NOT_DEAD', 'Dieser CC ist nicht als im Rollenspiel gestorben vermerkt - bitte den normalen Weg nutzen', ); } if (!input.reason.trim()) { throw badRequest('NEED_REASON', 'Für die Rückkehr eines toten CCs wird eine Begründung gebraucht'); } if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); } const character = await prisma.$transaction(async (tx) => { const updated = await tx.character.update({ where: { id: characterId }, data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }, }); if (input.useKind !== undefined) { await tx.creditLedger.create({ data: { playerId: updated.playerId, kind: input.useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: updated.id, note: `Rückkehr nach Tod: ${input.reason}`, createdById: ctx.actorId, }, }); } await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs'); await recordAudit(tx, ctx, { action: 'character.revive_dead', targetType: 'character', targetId: characterId, details: { playerId: updated.playerId, name: updated.name, reason: input.reason, useKind: input.useKind ?? null, previousNote: existing.inactiveNote, }, }); return updated; }); const balances = await getBalances(character.playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } /** * Stellt nachträglich um, woraus ein Redesign bezahlt wurde. * * Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich * manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der * beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben, * sondern wie jede Korrektur im Kontobuch: die alte Zeile wird * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas * geändert wurde, und wann. */ export async function changeRedesignKind( characterId: number, useKind: RedesignKind, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); if (!REDESIGN_KINDS.includes(useKind)) { throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); } // Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht // mehr; offen ist immer höchstens eine. const buchung = await prisma.creditLedger.findFirst({ where: { characterId, source: CreditSource.CONSUME, kind: { in: [...REDESIGN_KINDS] }, reversedBy: null, }, orderBy: { id: 'desc' }, }); if (!buchung) { throw conflict( 'NO_REDESIGN_BOOKING', 'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen', ); } if (buchung.kind === useKind) { throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht'); } const grund = useKind === CreditKind.REDESIGN_KULANZ ? 'Umgestellt auf Kulanz' : 'Umgestellt auf bezahltes Redesign'; await prisma.$transaction(async (tx) => { await tx.creditLedger.create({ data: { playerId: buchung.playerId, kind: buchung.kind, amount: -buchung.amount, source: CreditSource.REVERSAL, groupId: buchung.groupId, characterId, reversalOfId: buchung.id, note: `${grund}: ${character.name}`, createdById: ctx.actorId, occurredAt: buchung.occurredAt, }, }); await tx.creditLedger.create({ data: { playerId: buchung.playerId, kind: useKind, amount: buchung.amount, source: CreditSource.CONSUME, groupId: randomUUID(), characterId, note: buchung.note ?? `Redesign: ${character.name}`, createdById: ctx.actorId, // Bezahlt wurde damals, nicht heute. occurredAt: buchung.occurredAt, }, }); await recordAudit(tx, ctx, { action: 'character.redesign_kind_changed', targetType: 'character', targetId: characterId, details: { name: character.name, from: buchung.kind, to: useKind }, }); }); const balances = await getBalances(character.playerId); return { character, balances, overdrawn: isOverdrawn(balances) }; } // --- Ketten --- /** * Setzt die Verkettung einer Linie neu auf, nach der Reihenfolge der * Positionen. * * Die Kette steht doppelt in den Daten: als Position und als Verweis auf den * Vorgänger. Die Position lässt sich verschieben, ohne alles anzufassen; der * Vorgänger-Verweis ist das, woran der Rest der Anwendung hängt. Nach jedem * Umsortieren wird er deshalb aus den Positionen neu abgeleitet - dann können * beide nicht auseinanderlaufen. */ async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Promise { const glieder = await tx.character.findMany({ where: { lineageId }, orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], select: { id: true, predecessorId: true, status: true, inactiveReason: true }, }); let vorheriges: number | null = null; for (const [index, glied] of glieder.entries()) { const letztes = index === glieder.length - 1; const daten: Prisma.CharacterUpdateInput = {}; if (glied.predecessorId !== vorheriges) { daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } }; } // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine // Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein // und auch nicht "geputzt": es ist redesignt, Punkt. if (!letztes && glied.inactiveReason !== InactiveReason.REWORKED) { daten.status = CharacterStatus.INACTIVE; daten.inactiveReason = InactiveReason.REWORKED; daten.inactiveNote = null; } // Umgekehrt: Wer ans Ende rückt, ist von niemandem mehr ersetzt worden. // Die Markierung wäre dann schlicht falsch - sie stammt von einem // Nachfolger, der inzwischen woanders steht. if (letztes && glied.inactiveReason === InactiveReason.REWORKED) { daten.status = CharacterStatus.ACTIVE; daten.inactiveReason = null; daten.inactiveNote = null; } if (Object.keys(daten).length > 0) { await tx.character.update({ where: { id: glied.id }, data: daten }); } vorheriges = glied.id; } } /** * Verschiebt einen CC innerhalb seiner Kette oder in eine andere. * * Gedacht für das Nachtragen von Lücken: ein Redesign, das erst Jahre später * auftaucht, gehört mitten in eine bestehende Kette - und nicht ans Ende, * bloß weil es zuletzt eingetragen wurde. Am Status ändert das Verschieben * nichts; wer aktiv war, bleibt aktiv. */ export async function moveInChain( characterId: number, input: { lineageId: string; afterId?: number | null }, ctx: ActionContext, ): Promise<{ lineageId: string }> { const character = await loadCharacter(characterId); // Die Ziel-Linie muss dem Spieler gehören, sonst wanderten CCs zwischen // Spielern - dafür gibt es hier keinen Weg und keinen Grund. const zielGlieder = await prisma.character.findMany({ where: { lineageId: input.lineageId }, orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], select: { id: true, playerId: true, chainPosition: true }, }); if (zielGlieder.length > 0 && zielGlieder.some((g) => g.playerId !== character.playerId)) { throw conflict('FOREIGN_CHAIN', 'Diese Kette gehört einem anderen Spieler'); } const vorgaenger = input.afterId === undefined || input.afterId === null ? null : zielGlieder.find((g) => g.id === input.afterId); if (input.afterId != null && !vorgaenger) { throw badRequest('NOT_IN_CHAIN', 'Der genannte Vorgänger liegt nicht in dieser Kette'); } if (input.afterId === characterId) { throw badRequest('SELF_REFERENCE', 'Ein CC kann nicht hinter sich selbst stehen'); } const alteLinie = character.lineageId; await prisma.$transaction(async (tx) => { // Erst Platz schaffen: alles hinter der Einfügestelle rückt auf. const ab = vorgaenger ? vorgaenger.chainPosition + 1 : 0; await tx.character.updateMany({ where: { lineageId: input.lineageId, chainPosition: { gte: ab }, id: { not: characterId }, }, data: { chainPosition: { increment: 10 } }, }); await tx.character.update({ where: { id: characterId }, data: { lineageId: input.lineageId, chainPosition: ab }, }); await verketteNeu(tx, input.lineageId); if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie); // Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv // machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie // jeder andere. await assertSlotsReichen(tx, character.playerId, 'Dieses Verschieben'); await recordAudit(tx, ctx, { action: 'character.moved_in_chain', targetType: 'character', targetId: characterId, details: { name: character.name, fromLineage: alteLinie, toLineage: input.lineageId, afterId: input.afterId ?? null, }, }); }); return { lineageId: input.lineageId }; } /** * Löst einen CC aus seiner Kette und stellt ihn allein - für den Fall, dass * sich herausstellt, dass er gar nicht dorthin gehörte. */ export async function detachFromChain( characterId: number, ctx: ActionContext, ): Promise<{ lineageId: string }> { const character = await loadCharacter(characterId); const neueLinie = randomUUID(); await prisma.$transaction(async (tx) => { // Allein in einer neuen Linie ist er zugleich Anfang und Ende. Eine // Redesign-Markierung stammte von einem Nachfolger, der jetzt woanders // steht - sie wäre nur noch falsch. await tx.character.update({ where: { id: characterId }, data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null, ...(character.inactiveReason === InactiveReason.REWORKED ? { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null } : {}), }, }); await verketteNeu(tx, character.lineageId); await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen'); await recordAudit(tx, ctx, { action: 'character.detached', targetType: 'character', targetId: characterId, details: { name: character.name, fromLineage: character.lineageId }, }); }); return { lineageId: neueLinie }; } // --- Verweise an einem CC --- /// Nur diese Schemata. Ein "javascript:"-Link in einer Oberfläche, die /// mehrere Leute bedienen, wäre ein offenes Scheunentor. const ERLAUBTE_SCHEMATA = ['http:', 'https:']; function pruefeUrl(roh: string): string { const wert = roh.trim(); let parsed: URL; try { parsed = new URL(wert); } catch { throw badRequest('INVALID_URL', 'Das ist keine gültige Adresse - bitte mit http:// oder https://'); } if (!ERLAUBTE_SCHEMATA.includes(parsed.protocol)) { throw badRequest('INVALID_URL', 'Erlaubt sind nur Adressen mit http:// oder https://'); } return parsed.toString(); } /** * Hängt einen Verweis an einen CC: das Steam-Addon des Erstellers, ein * Modell, eine Absprache. Der Name wird in der Liste zum Link, damit dort * steht, worum es geht, statt einer nackten Adresse. */ export async function addLink( characterId: number, input: { label: string; url: string }, ctx: ActionContext, ) { const character = await loadCharacter(characterId); const label = input.label.trim(); if (!label) throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht'); const created = await prisma.characterLink.create({ data: { characterId, label, url: pruefeUrl(input.url), createdById: ctx.actorId }, }); await recordAudit(prisma, ctx, { action: 'character.link_added', targetType: 'character', targetId: characterId, details: { name: character.name, label, url: created.url }, }); return created; } export async function updateLink( linkId: number, input: { label?: string | undefined; url?: string | undefined }, ctx: ActionContext, ) { const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } }); if (!vorhanden) throw notFound('Verweis'); const label = input.label?.trim(); if (input.label !== undefined && !label) { throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht'); } const updated = await prisma.characterLink.update({ where: { id: linkId }, data: { ...(label ? { label } : {}), ...(input.url !== undefined ? { url: pruefeUrl(input.url) } : {}), }, }); await recordAudit(prisma, ctx, { action: 'character.link_updated', targetType: 'character', targetId: vorhanden.characterId, details: { label: updated.label, url: updated.url }, }); return updated; } export async function removeLink(linkId: number, ctx: ActionContext): Promise { const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } }); if (!vorhanden) throw notFound('Verweis'); await prisma.characterLink.delete({ where: { id: linkId } }); await recordAudit(prisma, ctx, { action: 'character.link_removed', targetType: 'character', targetId: vorhanden.characterId, details: { label: vorhanden.label, url: vorhanden.url }, }); } // --- Gruppen-CCs --- /// Ein Platz in einem CC kostet ein CC-Guthaben oder ein Redesign - je nachdem, /// worüber der Spieler hinzukommt. const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; export type JoinKind = (typeof JOIN_KINDS)[number]; /// Beitritte, die auf einem Redesign beruhen und deshalb einen CC kosten. const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]; /** * Prüft den CC, den ein Spieler für seinen Platz aufgibt. * * Über ein Redesign gibt es keinen zusätzlichen CC, sondern einen anderen: * ohne einen eigenen CC als Grundlage gibt es nichts zu ersetzen. Aktiv muss * dieser nicht sein - auch ein längst inaktiver CC lässt sich redesignen. * Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger. */ async function ladeAufgegebenenCC( input: { useKind: JoinKind; predecessorId?: number | undefined }, playerId: number, characterId: number, ): Promise { if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) { if (input.predecessorId !== undefined) { throw badRequest( 'NO_PREDECESSOR_NEEDED', 'Ein Platz aus dem CC-Guthaben ersetzt keinen bestehenden CC', ); } return null; } if (input.predecessorId === undefined) { throw badRequest( 'NEED_PREDECESSOR', 'Ein Beitritt über ein Redesign ersetzt einen eigenen CC - bitte angeben, welchen', ); } if (input.predecessorId === characterId) { throw conflict('SAME_CHARACTER', 'Ein CC kann nicht für sich selbst aufgegeben werden'); } const vorgaenger = await prisma.character.findUnique({ where: { id: input.predecessorId }, include: { members: { where: { playerId, leftAt: null }, select: { id: true } } }, }); if (!vorgaenger) throw notFound('Aufgegebener CC'); // Aufgeben kann nur, wem der CC gehört. Bei einem aktiven CC zählt die // Mitgliedschaft, bei einem inaktiven der Eintrag als Inhaber - dort ist // die Mitgliedschaft beim Ausscheiden beendet worden. const gehoertIhm = vorgaenger.members.length > 0 || (vorgaenger.status !== CharacterStatus.ACTIVE && vorgaenger.playerId === playerId); if (!gehoertIhm) { throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht'); } return vorgaenger; } /** * Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für * einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder * ein Redesign, entscheidet der Nutzer - beide Wege kommen vor. */ export async function addMember( characterId: number, playerId: number, input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined }, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); if (character.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten'); } if (!JOIN_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt'); } const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }); if (!player) throw notFound('Spieler'); const vorhanden = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, }); if (vorhanden && vorhanden.leftAt === null) { throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits'); } // Ein Redesign ersetzt einen CC, es kommt nie einer dazu. Wer über ein // Redesign beitritt, gibt dafür einen eigenen CC auf - genau wie bei jedem // anderen Redesign. Nur ein CC-Guthaben kauft einen zusätzlichen Platz. const vorgaenger = await ladeAufgegebenenCC(input, playerId, characterId); await prisma.$transaction(async (tx) => { if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) { await tx.character.update({ where: { id: vorgaenger.id }, data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED }, }); // Der Spieler nutzt den aufgegebenen CC nicht mehr; bei einem Gruppen-CC // laufen die übrigen Mitglieder dort unberührt weiter. await tx.characterMember.updateMany({ where: { characterId: vorgaenger.id, playerId, leftAt: null }, data: { leftAt: new Date() }, }); } if (vorhanden) { // Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die // Zugehörigkeit nicht doppelt in der Liste steht. await tx.characterMember.update({ where: { id: vorhanden.id }, data: { leftAt: null, joinedAt: new Date(), paidWith: input.useKind, predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, }, }); } else { await tx.characterMember.create({ data: { characterId, playerId, paidWith: input.useKind, predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, }, }); } await tx.creditLedger.create({ data: { playerId, kind: input.useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId, note: input.note ?? (vorgaenger ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` : `Beitritt zu "${character.name}"`), createdById: ctx.actorId, }, }); await assertSlotsReichen(tx, playerId, 'Der Beitritt'); await recordAudit(tx, ctx, { action: 'character.member_added', targetType: 'character', targetId: characterId, details: { playerId, name: character.name, useKind: input.useKind, predecessorId: vorgaenger?.id ?? null, predecessorName: vorgaenger?.name ?? null, }, }); }); const [balances, memberCount] = await Promise.all([ getBalances(playerId), prisma.characterMember.count({ where: { characterId, leftAt: null } }), ]); return { character, balances, overdrawn: isOverdrawn(balances), memberCount }; } /** * Redesignt den Anteil eines Spielers an einem Gruppen-CC. * * Er steigt damit aus und bekommt einen eigenen CC an dessen Stelle; die * Gruppe läuft unberührt weiter. Für ihn ist es ein Redesign wie jedes * andere: ein Platz wird getauscht, keiner kommt hinzu. * * Ist er der letzte Nutzer, gibt es keine Gruppe mehr, die weiterlaufen * könnte - dann ist es das gewöhnliche Redesign des ganzen CCs, und der läuft * über redesignCharacter(). Ein Gruppen-Redesign, bei dem alle mitwandern, * ebenfalls: das ist die Sache des ganzen CCs, nicht die eines Mitglieds. */ export async function redesignMembership( characterId: number, playerId: number, input: { name: string; loreText?: string | undefined; useKind: RedesignKind; note?: string | undefined; }, ctx: ActionContext, ): Promise { const gruppe = await loadCharacter(characterId); if (gruppe.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); } if (!REDESIGN_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); } const name = input.name.trim(); if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht'); const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, }); if (!mitglied || mitglied.leftAt !== null) { throw notFound('Mitgliedschaft'); } const verbleibend = await prisma.characterMember.count({ where: { characterId, leftAt: null }, }); if (verbleibend <= 1) { throw conflict( 'LAST_MEMBER', 'Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.', ); } const neuerCC = await prisma.$transaction(async (tx) => { const erstellt = await tx.character.create({ data: { playerId, name, loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, // Eine eigene Linie: der Gruppen-CC lebt weiter, hier beginnt ein // neuer Strang. Woher er kommt, steht an der Mitgliedschaft. predecessorId: gruppe.id, createdById: ctx.actorId, members: { create: [{ playerId, paidWith: input.useKind, createdById: ctx.actorId }] }, }, }); // Kein Grund für eine Rückkehr: sein Platz wurde getauscht, nicht // entzogen. Wohin er gegangen ist, steht im Nachfolger - und genau daran // hängt auch, dass der Gruppen-CC nicht länger als seiner gilt. await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: new Date(), successorId: erstellt.id, note: input.note ?? `Anteil redesignt zu "${erstellt.name}"`, }, }); await tx.creditLedger.create({ data: { playerId, kind: input.useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId: erstellt.id, note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, createdById: ctx.actorId, }, }); await assertSlotsReichen(tx, playerId, 'Dieses Redesign'); await recordAudit(tx, ctx, { action: 'character.membership_redesigned', targetType: 'character', targetId: erstellt.id, details: { playerId, from: gruppe.name, fromId: gruppe.id, to: erstellt.name, useKind: input.useKind, remaining: verbleibend - 1, }, }); return erstellt; }); const balances = await getBalances(playerId); return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true }; } /** * Setzt eine einzelne Mitgliedschaft inaktiv - der CC selbst bleibt, wie er * ist. Bei einem Gruppen-CC kann genau ein Spieler wegfallen, ohne dass die * anderen etwas davon merken: einer stirbt im Rollenspiel, einer wird * geputzt, die übrigen spielen weiter. * * Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC - gratis, * bezahlt oder nur durch einen Administrator. */ export async function deactivateMember( characterId: number, playerId: number, reason: InactiveReason, ctx: ActionContext, note?: string, ): Promise<{ memberCount: number }> { if (reason === InactiveReason.REWORKED) { throw badRequest( 'USE_REDESIGN', 'Ein Rework entsteht über das Redesign des Anteils, nicht über eine Deaktivierung', ); } if (reason === InactiveReason.DEAD && !note?.trim()) { throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht'); } const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, include: { character: { select: { name: true } } }, }); if (!mitglied || mitglied.leftAt !== null) { throw notFound('Mitgliedschaft'); } const verbleibend = await prisma.characterMember.count({ where: { characterId, leftAt: null }, }); if (verbleibend <= 1) { throw conflict( 'LAST_MEMBER', 'Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.', ); } await prisma.$transaction(async (tx) => { await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: new Date(), inactiveReason: reason, inactiveNote: note?.trim() || null, }, }); await recordAudit(tx, ctx, { action: 'character.member_deactivated', targetType: 'character', targetId: characterId, details: { playerId, name: mitglied.character.name, reason, note: note ?? null }, }); }); return { memberCount: verbleibend - 1 }; } /** * Holt eine einzelne Mitgliedschaft zurück. Die Regeln sind dieselben wie bei * einem eigenen CC: was unverändert weiterbesteht, kommt gratis zurück; was * ersetzt oder entzogen wurde, kostet; und über einen im Rollenspiel * Gestorbenen entscheidet ein Administrator. */ export async function reactivateMember( characterId: number, playerId: number, input: { useKind?: ReviveKind | undefined; reason?: string | undefined }, ctx: ActionContext, ): Promise<{ memberCount: number }> { const gruppe = await loadCharacter(characterId); if (gruppe.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Dieser CC ist selbst inaktiv - erst ihn zurückholen'); } const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, }); if (!mitglied) throw notFound('Mitgliedschaft'); if (mitglied.leftAt === null) { throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits'); } const grund = mitglied.inactiveReason; // Dass hierfür ein Administrator nötig ist, sichert die Route ab - so wie // bei einem im Rollenspiel gestorbenen CC. Hier steht nur, was der Vorgang // selbst verlangt. if (grund === InactiveReason.DEAD && !input.reason?.trim()) { throw badRequest( 'NEED_REASON', 'Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht', ); } // Ein Austritt ohne Grund war freiwillig: der Platz ist weg und muss neu // bezahlt werden. Dafür gibt es den gewöhnlichen Beitritt. if (grund === null) { throw conflict( 'WAS_VOLUNTARY', 'Dieser Spieler ist ausgetreten, nicht ausgefallen. Eine Rückkehr ist ein neuer Beitritt und kostet ein Guthaben.', ); } const kostenpflichtig = grund === InactiveReason.REWORKED || grund === InactiveReason.NO_FREE_CLAIM; if (kostenpflichtig && input.useKind === undefined) { throw badRequest('NEED_KIND', 'Diese Rückkehr kostet ein Guthaben - bitte angeben, welches'); } if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); } await prisma.$transaction(async (tx) => { await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: null, inactiveReason: null, inactiveNote: null }, }); if (input.useKind !== undefined) { await tx.creditLedger.create({ data: { playerId, kind: input.useKind, amount: -1, source: CreditSource.CONSUME, groupId: randomUUID(), characterId, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, createdById: ctx.actorId, }, }); } await assertSlotsReichen(tx, playerId, 'Die Rückkehr'); await recordAudit(tx, ctx, { action: 'character.member_reactivated', targetType: 'character', targetId: characterId, details: { playerId, name: gruppe.name, previousReason: grund, useKind: input.useKind ?? null, reason: input.reason ?? null, }, }); }); const memberCount = await prisma.characterMember.count({ where: { characterId, leftAt: null }, }); return { memberCount }; } /** * Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht - * wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die * Rücknahme der Buchung der richtige Weg, nicht der Austritt. */ export async function removeMember( characterId: number, playerId: number, note: string | undefined, ctx: ActionContext, ): Promise<{ memberCount: number }> { const mitglied = await prisma.characterMember.findUnique({ where: { characterId_playerId: { characterId, playerId } }, include: { character: { select: { name: true } } }, }); if (!mitglied || mitglied.leftAt !== null) { throw notFound('Mitgliedschaft'); } const verbleibend = await prisma.characterMember.count({ where: { characterId, leftAt: null }, }); if (verbleibend <= 1) { throw conflict( 'LAST_MEMBER', 'Das ist der letzte Nutzer dieses CCs. Statt ihn auszutragen, den CC deaktivieren.', ); } await prisma.$transaction(async (tx) => { await tx.characterMember.update({ where: { id: mitglied.id }, data: { leftAt: new Date(), note: note ?? mitglied.note }, }); await recordAudit(tx, ctx, { action: 'character.member_removed', targetType: 'character', targetId: characterId, details: { playerId, name: mitglied.character.name, note: note ?? null }, }); }); return { memberCount: verbleibend - 1 }; } /** Alle, die einen CC nutzen oder genutzt haben. */ export async function listMembers(characterId: number) { return prisma.characterMember.findMany({ where: { characterId }, orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], include: { player: { select: { id: true, displayName: true, steamId: true } }, successor: { select: { id: true, name: true } }, predecessor: { select: { id: true, name: true } }, }, }); }