import type { Prisma } from '@prisma/client'; import { prisma } from '../db.js'; import { conflict, notFound } from '../errors.js'; import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js'; import { listCharacters } from './characters.js'; import { recordAudit, type ActionContext } from './audit.js'; import { AliasType } from '@prisma/client'; import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js'; export interface PlayerInput { displayName: string; steamId?: string | null; forumName?: string | null; notes?: string | null; /// Anlegen, obwohl etwas nicht stimmt - siehe markProblem(). isProblem?: boolean; problemNote?: string | null; } export async function searchPlayers( query: string, take = 50, skip = 0, onlyProblems = false, ) { const trimmed = query.trim(); // Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die // 64-Bit-Form gespeichert ist. const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null; const where: Prisma.PlayerWhereInput = { ...(onlyProblems ? { isProblem: true } : {}), ...(trimmed.length === 0 ? {} : { OR: [ { displayName: { contains: trimmed, mode: 'insensitive' } }, { steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } }, { forumName: { contains: trimmed, mode: 'insensitive' } }, ], }), }; const [players, total] = await Promise.all([ prisma.player.findMany({ where, orderBy: { displayName: 'asc' }, take, skip, include: { _count: { select: { characters: true } } }, }), prisma.player.count({ where }), ]); const balances = await getBalancesForMany(players.map((p) => p.id)); const activeCounts = await prisma.character.groupBy({ by: ['playerId'], where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' }, _count: { _all: true }, }); const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all])); return { total, items: players.map((player) => ({ id: player.id, displayName: player.displayName, steamId: player.steamId, forumName: player.forumName, isProblem: player.isProblem, problemNote: player.problemNote, characterCount: player._count.characters, activeCharacterCount: activeByPlayer.get(player.id) ?? 0, balances: balances.get(player.id) as Balances, })), }; } export async function getPlayerDetail(playerId: number) { const player = await prisma.player.findUnique({ where: { id: playerId } }); if (!player) throw notFound('Spieler'); // Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an // einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln. const { listThreads } = await import('./forum.js'); const { listPendingRowsFor } = await import('./imports.js'); const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] = await Promise.all([ getBalances(playerId), listCharacters(playerId), listLedger({ playerId, take: 200 }), prisma.unmatchedPackage.findMany({ where: { playerId, resolved: false }, orderBy: { scrapedAt: 'desc' }, include: { player: { select: { id: true, displayName: true } } }, }), prisma.playerAlias.findMany({ where: { playerId }, orderBy: [{ type: 'asc' }, { value: 'asc' }], }), // Ältester zuerst: Anträge werden der Reihe nach abgearbeitet. listThreads({ playerId, order: 'oldest' }), listPendingRowsFor(playerId), ]); return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total, unmatched, aliases, threads, pendingRows, }; } /** * Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe * Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die * andere die 64-Bit-Zahl. */ function canonicalSteamId(input: string | null | undefined): string | null { const value = input?.trim(); if (!value) return null; const normalized = normalizeSteamId(value); if (!normalized) { throw conflict( 'INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus. Erlaubt sind STEAM_0:1:..., [U:1:...] oder die 17-stellige Zahl.`, ); } return normalized; } async function assertSteamIdFree(steamId: string, exceptId?: number): Promise { const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } }); if (existing && existing.id !== exceptId) { throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet'); } } export async function createPlayer(input: PlayerInput, ctx: ActionContext) { const steamId = canonicalSteamId(input.steamId); if (steamId) await assertSteamIdFree(steamId); const created = await prisma.$transaction(async (tx) => { const created = await tx.player.create({ data: { displayName: input.displayName.trim(), steamId, forumName: input.forumName?.trim() || null, notes: input.notes?.trim() || null, ...(input.isProblem ? { isProblem: true, problemNote: input.problemNote?.trim() || null, problemSince: new Date(), } : {}), }, }); await recordAudit(tx, ctx, { action: 'player.create', targetType: 'player', targetId: created.id, details: { displayName: created.displayName, steamId: created.steamId, isProblem: created.isProblem, }, }); return created; }); // Wartende Forums-Anträge unter diesem Namen gleich mit übernehmen, sonst // blieben sie bis zum nächsten vollen Abgleich in der Warteliste liegen. if (created.forumName) { const { claimThreadsForPlayer } = await import('./forum.js'); await claimThreadsForPlayer(created.id, [created.forumName]); } return created; } export async function updatePlayer(playerId: number, input: Partial, ctx: ActionContext) { const existing = await prisma.player.findUnique({ where: { id: playerId } }); if (!existing) throw notFound('Spieler'); const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId); if (steamId) await assertSteamIdFree(steamId, playerId); const updated = await prisma.$transaction(async (tx) => { const updated = await tx.player.update({ where: { id: playerId }, data: { ...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}), ...(steamId !== undefined ? { steamId } : {}), ...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}), ...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}), }, }); await recordAudit(tx, ctx, { action: 'player.update', targetType: 'player', targetId: playerId, details: { displayName: updated.displayName, steamId: updated.steamId }, }); return updated; }); if (updated.forumName && updated.forumName !== existing.forumName) { const { claimThreadsForPlayer } = await import('./forum.js'); await claimThreadsForPlayer(playerId, [updated.forumName]); } return updated; } /** * Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den * Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt, * damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt. * Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt. */ export async function deletePlayer(playerId: number, ctx: ActionContext): Promise { const existing = await prisma.player.findUnique({ where: { id: playerId }, include: { _count: { select: { characters: true, ledger: true } } }, }); if (!existing) throw notFound('Spieler'); await prisma.$transaction(async (tx) => { // Rohzeilen der Importe verlieren ihren Personenbezug. await tx.importRow.updateMany({ where: { OR: [ { resolvedPlayerId: playerId }, ...(existing.steamId ? [{ playerKey: existing.steamId }] : []), ], }, data: { playerKey: null, playerName: null, resolvedPlayerId: null, raw: {}, }, }); // Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt. await tx.auditLog.updateMany({ where: { targetType: 'player', targetId: String(playerId) }, data: { details: { redacted: true } }, }); await tx.player.delete({ where: { id: playerId } }); await recordAudit(tx, ctx, { action: 'player.delete', targetType: 'player', targetId: playerId, // Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen, // nicht die gelöschten Daten konservieren. details: { removedCharacters: existing._count.characters, removedLedgerRows: existing._count.ledger, }, }); }); } /** * Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe * Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht. */ /** * Markiert einen Spieler als Problemfall - oder hebt die Markierung auf. * * Gedacht für alles, was sich im Moment nicht sauber klären lässt: ein Antrag * ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche * Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht verloren * geht. Geklärt wird, wenn es darauf ankommt - kommt der Spieler nie wieder, * kommt es nie darauf an. * * Die Markierung hat bewusst **keine** Wirkung auf Guthaben oder CCs. Sie ist * eine Notiz mit Griff, kein Sperrvermerk. */ export async function markProblem( playerId: number, input: { isProblem: boolean; note?: string | null }, ctx: ActionContext, ) { const existing = await prisma.player.findUnique({ where: { id: playerId } }); if (!existing) throw notFound('Spieler'); const updated = await prisma.player.update({ where: { id: playerId }, data: input.isProblem ? { isProblem: true, problemNote: input.note?.trim() || null, // Ein bereits offener Fall behält seinen Anfang. problemSince: existing.problemSince ?? new Date(), } : { isProblem: false, problemNote: null, problemSince: null }, }); await recordAudit(prisma, ctx, { action: input.isProblem ? 'player.problem_marked' : 'player.problem_cleared', targetType: 'player', targetId: playerId, details: { displayName: updated.displayName, note: input.note?.trim() || null }, }); return updated; } export async function addAlias( playerId: number, type: AliasType, rawValue: string, note: string | undefined, ctx: ActionContext, ) { const player = await prisma.player.findUnique({ where: { id: playerId } }); if (!player) throw notFound('Spieler'); let value = rawValue.trim(); if (type === AliasType.STEAM_ID) { const normalized = normalizeSteamId(value); if (!normalized) { throw conflict('INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus`); } value = normalized; if (value === player.steamId) { throw conflict('IS_PRIMARY', 'Das ist bereits die hinterlegte SteamID dieses Spielers'); } } else if (value.toLowerCase() === player.forumName?.toLowerCase()) { throw conflict('IS_PRIMARY', 'Das ist bereits der hinterlegte Forum-Name dieses Spielers'); } if (value.length === 0) throw conflict('EMPTY', 'Der Wert darf nicht leer sein'); const vorhanden = await prisma.playerAlias.findUnique({ where: { type_value: { type, value } }, include: { player: { select: { displayName: true } } }, }); if (vorhanden) { throw conflict( 'ALIAS_TAKEN', vorhanden.playerId === playerId ? 'Dieses Konto ist hier schon eingetragen' : `Dieses Konto gehört bereits zu "${vorhanden.player.displayName}"`, ); } const created = await prisma.playerAlias.create({ data: { playerId, type, value, note: note?.trim() || null }, }); await recordAudit(prisma, ctx, { action: 'player.alias_added', targetType: 'player', targetId: playerId, details: { type, value, note: note ?? null }, }); if (type === AliasType.FORUM_NAME) { const { claimThreadsForPlayer } = await import('./forum.js'); await claimThreadsForPlayer(playerId, [value]); } return created; } export async function removeAlias(aliasId: number, ctx: ActionContext): Promise { const alias = await prisma.playerAlias.findUnique({ where: { id: aliasId } }); if (!alias) throw notFound('Eintrag'); await prisma.playerAlias.delete({ where: { id: aliasId } }); await recordAudit(prisma, ctx, { action: 'player.alias_removed', targetType: 'player', targetId: alias.playerId, details: { type: alias.type, value: alias.value }, }); }