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 { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js'; export interface PlayerInput { displayName: string; steamId?: string | null; forumName?: string | null; notes?: string | null; } export async function searchPlayers(query: string, take = 50, skip = 0) { 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 = 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, 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'); const [balances, characters, ledger, unmatched] = 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 } } }, }), ]); return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total, unmatched, }; } /** * Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe * Spieler zwei Datensaetze 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); return 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, }, }); await recordAudit(tx, ctx, { action: 'player.create', targetType: 'player', targetId: created.id, details: { displayName: created.displayName, steamId: created.steamId }, }); 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); return 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; }); } /** * Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den * Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt, * damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt. * Die Audit-Eintraege 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: {}, }, }); // Fruehere Audit-Eintraege 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 Loeschung belegen, // nicht die geloeschten Daten konservieren. details: { removedCharacters: existing._count.characters, removedLedgerRows: existing._count.ledger, }, }); }); }