From f3e31cdd17982c2c408e904ad33f9424f50c23bb Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:37:31 +0200 Subject: [PATCH] Fachlogik fuer Guthaben, CC-Lebenszyklus und Import Die beiden zentralen Regeln stehen jeweils an genau einer Stelle: Ein CC-Paket erzeugt immer CC +1 und REDESIGN_PAID +1. Die Zeilen dafuer baut buildPackageRows, das sowohl die manuelle Eingabe als auch der Import nutzen - so koennen beide Wege nicht auseinanderlaufen. Ein CC kommt nur dann gratis zurueck, wenn er wegen Spieler-Inaktivitaet entfernt wurde. Ein reworkter CC existiert im Original nicht mehr und kostet deshalb ein Redesign- oder CC-Guthaben. Ueberzogene Toepfe werden zugelassen statt blockiert; die Aktion meldet das ueber ein overdrawn-Flag zurueck. Storniert wird per Gegenbuchung, nie durch Loeschen. Der Import berechnet den Plan vorab und dedupliziert ueber die Bestell-ID, sowohl gegen bereits gebuchte als auch innerhalb einer Datei. --- server/src/services/audit.ts | 36 +++ server/src/services/characters.ts | 427 +++++++++++++++++++++++++++++ server/src/services/credits.ts | 325 ++++++++++++++++++++++ server/src/services/imports.ts | 434 ++++++++++++++++++++++++++++++ server/src/services/players.ts | 157 +++++++++++ 5 files changed, 1379 insertions(+) create mode 100644 server/src/services/audit.ts create mode 100644 server/src/services/characters.ts create mode 100644 server/src/services/credits.ts create mode 100644 server/src/services/imports.ts create mode 100644 server/src/services/players.ts diff --git a/server/src/services/audit.ts b/server/src/services/audit.ts new file mode 100644 index 0000000..190fc66 --- /dev/null +++ b/server/src/services/audit.ts @@ -0,0 +1,36 @@ +import type { Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; + +export interface ActionContext { + // null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login. + actorId: number | null; + actorName: string; + ip?: string | undefined; +} + +export interface AuditEntry { + action: string; + targetType: string; + targetId?: string | number | null; + details?: Prisma.InputJsonValue; +} + +type Client = Pick; + +export async function recordAudit( + client: Client, + ctx: ActionContext, + entry: AuditEntry, +): Promise { + await client.auditLog.create({ + data: { + actorUserId: ctx.actorId, + actorName: ctx.actorName, + action: entry.action, + targetType: entry.targetType, + targetId: entry.targetId == null ? null : String(entry.targetId), + details: entry.details ?? undefined, + ip: ctx.ip ?? null, + }, + }); +} diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts new file mode 100644 index 0000000..c7bb3fc --- /dev/null +++ b/server/src/services/characters.ts @@ -0,0 +1,427 @@ +import { randomUUID } from 'node:crypto'; +import { + CharacterStatus, + CreditKind, + CreditSource, + InactiveReason, + type Character, +} 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 ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht. + overdrawn: boolean; +} + +// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe. +const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; +// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich 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; +} + +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 dafuer 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, + }, + }); + + 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 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 spaeter + * automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt. + */ +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 }, + }); + + const created = await tx.character.create({ + data: { + playerId: old.playerId, + name: input.name, + loreText: input.loreText ?? null, + status: CharacterStatus.ACTIVE, + lineageId: old.lineageId, + predecessorId: old.id, + 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 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 Inaktivitaet. */ +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 ueber die Redesign-Aktion, nicht ueber eine Deaktivierung', + ); + } + + const character = await prisma.$transaction(async (tx) => { + const updated = await tx.character.update({ + where: { id: characterId }, + data: { status: CharacterStatus.INACTIVE, inactiveReason: reason }, + }); + + 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. Greift nur, wenn der CC ausschliesslich wegen + * Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert + * weiter und kostet deshalb kein Guthaben. + */ +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.PLAYER_INACTIVITY) { + throw conflict( + 'NOT_FREE', + 'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.', + ); + } + + const character = await prisma.$transaction(async (tx) => { + const updated = await tx.character.update({ + where: { id: characterId }, + data: { status: CharacterStatus.ACTIVE, inactiveReason: null }, + }); + + 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 fuer CCs, die im Original nicht mehr existieren + * (reworkt oder aus sonstigem Grund inaktiv). 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.PLAYER_INACTIVITY) { + throw conflict( + 'IS_FREE', + 'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch', + ); + } + if (!REVIVE_KINDS.includes(useKind)) { + throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung'); + } + + const character = await prisma.$transaction(async (tx) => { + const updated = await tx.character.update({ + where: { id: characterId }, + data: { status: CharacterStatus.ACTIVE, inactiveReason: 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 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) }; +} + +export async function updateCharacter( + characterId: number, + input: { name?: string; loreText?: string | null }, + ctx: ActionContext, +): Promise { + await loadCharacter(characterId); + + return prisma.$transaction(async (tx) => { + const updated = 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: updated.name }, + }); + + return updated; + }); +} + +/** + * Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen + * werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger + * kehrt in den aktiven Zustand zurueck. + */ +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 juengsten CC zuruecknehmen.', + ); + } + + 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: `Ruecknahme "${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 }, + }); + } + + 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, + }; +} + +export async function listCharacters(playerId: number) { + return prisma.character.findMany({ + where: { playerId }, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + include: { + predecessor: { select: { id: true, name: true } }, + successors: { select: { id: true, name: true } }, + }, + }); +} diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts new file mode 100644 index 0000000..16ed729 --- /dev/null +++ b/server/src/services/credits.ts @@ -0,0 +1,325 @@ +import { randomUUID } from 'node:crypto'; +import { CreditKind, CreditSource, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { badRequest, conflict, notFound } from '../errors.js'; +import { recordAudit, type ActionContext } from './audit.js'; + +export interface Balances { + CC: number; + REDESIGN_PAID: number; + REDESIGN_KULANZ: number; + // Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen. + redesignTotal: number; +} + +const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 }; + +function assemble(sums: Partial>): Balances { + const cc = sums.CC ?? 0; + const paid = sums.REDESIGN_PAID ?? 0; + const kulanz = sums.REDESIGN_KULANZ ?? 0; + return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz }; +} + +export async function getBalances(playerId: number): Promise { + const rows = await prisma.creditLedger.groupBy({ + by: ['kind'], + where: { playerId }, + _sum: { amount: true }, + }); + + const sums: Partial> = {}; + for (const row of rows) sums[row.kind] = row._sum.amount ?? 0; + return assemble(sums); +} + +export async function getBalancesForMany(playerIds: number[]): Promise> { + const result = new Map(); + if (playerIds.length === 0) return result; + + const rows = await prisma.creditLedger.groupBy({ + by: ['playerId', 'kind'], + where: { playerId: { in: playerIds } }, + _sum: { amount: true }, + }); + + const grouped = new Map>>(); + for (const row of rows) { + const entry = grouped.get(row.playerId) ?? {}; + entry[row.kind] = row._sum.amount ?? 0; + grouped.set(row.playerId, entry); + } + + for (const id of playerIds) { + const sums = grouped.get(id); + result.set(id, sums ? assemble(sums) : { ...EMPTY }); + } + return result; +} + +async function requirePlayer(playerId: number): Promise { + const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }); + if (!exists) throw notFound('Spieler'); +} + +export interface PackageRowParams { + playerId: number; + groupId: string; + note?: string | null; + importId?: number | null; + actorId: number | null; +} + +/** + * Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket + * immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle + * Eingabe und Import nicht auseinanderlaufen koennen. + */ +export function buildPackageRows( + target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE', + params: PackageRowParams, +): Prisma.CreditLedgerCreateManyInput[] { + const base = { + playerId: params.playerId, + groupId: params.groupId, + note: params.note ?? null, + importId: params.importId ?? null, + createdById: params.actorId, + }; + + if (target === 'REDESIGN_PACKAGE') { + return [ + { ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE }, + ]; + } + + return [ + { ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE }, + { ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE }, + ]; +} + +/** + * Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes + * Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie + * gemeinsam zurueckholt. + */ +export async function bookCcPackage( + playerId: number, + ctx: ActionContext, + options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {}, +): Promise<{ groupId: string; balances: Balances }> { + await requirePlayer(playerId); + const groupId = options.groupId ?? randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.createMany({ + data: buildPackageRows('CC_PACKAGE', { + playerId, + groupId, + note: options.note ?? null, + importId: options.importId ?? null, + actorId: ctx.actorId, + }), + }); + + await recordAudit(tx, ctx, { + action: 'credit.cc_package', + targetType: 'player', + targetId: playerId, + details: { groupId, note: options.note ?? null, importId: options.importId ?? null }, + }); + }); + + return { groupId, balances: await getBalances(playerId) }; +} + +export async function bookRedesignPackage( + playerId: number, + ctx: ActionContext, + options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {}, +): Promise<{ groupId: string; balances: Balances }> { + await requirePlayer(playerId); + const groupId = options.groupId ?? randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.createMany({ + data: buildPackageRows('REDESIGN_PACKAGE', { + playerId, + groupId, + note: options.note ?? null, + importId: options.importId ?? null, + actorId: ctx.actorId, + }), + }); + + await recordAudit(tx, ctx, { + action: 'credit.redesign_package', + targetType: 'player', + targetId: playerId, + details: { groupId, note: options.note ?? null, importId: options.importId ?? null }, + }); + }); + + return { groupId, balances: await getBalances(playerId) }; +} + +export async function bookKulanz( + playerId: number, + ctx: ActionContext, + options: { note?: string | undefined } = {}, +): Promise<{ groupId: string; balances: Balances }> { + await requirePlayer(playerId); + const groupId = randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.create({ + data: { + playerId, + kind: CreditKind.REDESIGN_KULANZ, + amount: 1, + source: CreditSource.KULANZ, + groupId, + note: options.note ?? null, + createdById: ctx.actorId, + }, + }); + + await recordAudit(tx, ctx, { + action: 'credit.kulanz', + targetType: 'player', + targetId: playerId, + details: { groupId, note: options.note ?? null }, + }); + }); + + return { groupId, balances: await getBalances(playerId) }; +} + +/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */ +export async function bookAdjustment( + playerId: number, + kind: CreditKind, + amount: number, + note: string, + ctx: ActionContext, +): Promise<{ groupId: string; balances: Balances }> { + await requirePlayer(playerId); + if (!Number.isInteger(amount) || amount === 0) { + throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein'); + } + + const groupId = randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.create({ + data: { + playerId, + kind, + amount, + source: CreditSource.ADJUSTMENT, + groupId, + note, + createdById: ctx.actorId, + }, + }); + + await recordAudit(tx, ctx, { + action: 'credit.adjustment', + targetType: 'player', + targetId: playerId, + details: { groupId, kind, amount, note }, + }); + }); + + return { groupId, balances: await getBalances(playerId) }; +} + +/** + * Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie + * bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden + * ueber die zugehoerige CC-Aktion zurueckgenommen. + */ +export async function reverseGroup( + ledgerId: number, + reason: string, + ctx: ActionContext, +): Promise<{ balances: Balances; reversed: number }> { + const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } }); + if (!anchor) throw notFound('Buchung'); + + if (anchor.source === CreditSource.CONSUME) { + throw conflict( + 'CONSUME_NOT_REVERSIBLE', + 'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert', + ); + } + if (anchor.source === CreditSource.REVERSAL) { + throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden'); + } + + const entries = await prisma.creditLedger.findMany({ + where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } }, + include: { reversedBy: { select: { id: true } } }, + }); + + const open = entries.filter((e) => e.reversedBy === null); + if (open.length === 0) { + throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert'); + } + + 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: reason, + reversalOfId: entry.id, + createdById: ctx.actorId, + }, + }); + } + + await recordAudit(tx, ctx, { + action: 'credit.reverse', + targetType: 'player', + targetId: anchor.playerId, + details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason }, + }); + }); + + return { balances: await getBalances(anchor.playerId), reversed: open.length }; +} + +export interface LedgerFilter { + playerId: number; + kind?: CreditKind; + take?: number; + skip?: number; +} + +export async function listLedger(filter: LedgerFilter) { + const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId }; + if (filter.kind) where.kind = filter.kind; + + const [items, total] = await Promise.all([ + prisma.creditLedger.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: filter.take ?? 100, + skip: filter.skip ?? 0, + include: { + createdBy: { select: { username: true } }, + character: { select: { id: true, name: true } }, + reversedBy: { select: { id: true } }, + }, + }), + prisma.creditLedger.count({ where }), + ]); + + return { items, total }; +} diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts new file mode 100644 index 0000000..f82635d --- /dev/null +++ b/server/src/services/imports.ts @@ -0,0 +1,434 @@ +import { randomUUID } from 'node:crypto'; +import { parse } from 'csv-parse/sync'; +import { ImportStatus, ProductTarget, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { badRequest, conflict, notFound } from '../errors.js'; +import { buildPackageRows } from './credits.js'; +import { recordAudit, type ActionContext } from './audit.js'; + +export const PLANS = { + BOOK: 'BOOK', + CREATE_PLAYER_AND_BOOK: 'CREATE_PLAYER_AND_BOOK', + SKIP_DUPLICATE: 'SKIP_DUPLICATE', + SKIP_IGNORED: 'SKIP_IGNORED', + SKIP_UNMAPPED: 'SKIP_UNMAPPED', + SKIP_NO_PLAYER: 'SKIP_NO_PLAYER', +} as const; + +export type Plan = (typeof PLANS)[keyof typeof PLANS]; + +export interface ColumnMapping { + playerKey: string; + playerName?: string | undefined; + product: string; + orderId?: string | undefined; + purchasedAt?: string | undefined; + createMissingPlayers: boolean; +} + +export interface ParsedFile { + columns: string[]; + rows: Record[]; +} + +const MAX_ROWS = 20000; + +// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma. +function detectDelimiter(sample: string): string { + const firstLine = sample.split(/\r?\n/, 1)[0] ?? ''; + const candidates = [',', ';', '\t', '|']; + let best = ','; + let bestCount = 0; + for (const candidate of candidates) { + const count = firstLine.split(candidate).length - 1; + if (count > bestCount) { + best = candidate; + bestCount = count; + } + } + return best; +} + +function normalizeRow(row: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (value === null || value === undefined) { + out[key] = ''; + } else if (typeof value === 'object') { + out[key] = JSON.stringify(value); + } else { + out[key] = String(value).trim(); + } + } + return out; +} + +export function parseUpload(filename: string, content: Buffer): ParsedFile { + const text = content.toString('utf8').replace(/^/, ''); + if (text.trim().length === 0) throw badRequest('EMPTY_FILE', 'Die Datei ist leer'); + + const isJson = filename.toLowerCase().endsWith('.json') || /^\s*[[{]/.test(text); + let rows: Record[]; + + if (isJson) { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden'); + } + // Manche Exporte verpacken die Liste in ein Huellobjekt. + const list = Array.isArray(data) + ? data + : typeof data === 'object' && data !== null + ? Object.values(data).find((v): v is unknown[] => Array.isArray(v)) + : undefined; + + if (!Array.isArray(list)) { + throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen'); + } + rows = list + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map(normalizeRow); + } else { + try { + const parsed = parse(text, { + columns: true, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + delimiter: detectDelimiter(text), + bom: true, + }) as Record[]; + rows = parsed.map(normalizeRow); + } catch { + throw badRequest('INVALID_CSV', 'Die CSV-Datei konnte nicht gelesen werden'); + } + } + + if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen'); + if (rows.length > MAX_ROWS) { + throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`); + } + + const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]; + return { columns, rows }; +} + +export async function createImport( + filename: string, + parsed: ParsedFile, + ctx: ActionContext, +): Promise { + const created = await prisma.import.create({ + data: { + filename, + status: ImportStatus.PENDING, + rowCount: parsed.rows.length, + importedById: ctx.actorId, + rows: { + create: parsed.rows.map((raw, index) => ({ + lineNo: index + 1, + plan: PLANS.SKIP_UNMAPPED, + raw: raw as Prisma.InputJsonValue, + })), + }, + }, + }); + + await recordAudit(prisma, ctx, { + action: 'import.upload', + targetType: 'import', + targetId: created.id, + details: { filename, rowCount: parsed.rows.length }, + }); + + return created.id; +} + +function matchTarget( + productName: string, + mappings: { matchPattern: string; target: ProductTarget }[], +): ProductTarget | null { + const haystack = productName.toLowerCase(); + for (const mapping of mappings) { + if (haystack.includes(mapping.matchPattern.toLowerCase())) return mapping.target; + } + return null; +} + +function parseDate(value: string | undefined): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der + * Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt. + */ +export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) { + const record = await prisma.import.findUnique({ + where: { id: importId }, + include: { rows: { orderBy: { lineNo: 'asc' } } }, + }); + if (!record) throw notFound('Import'); + if (record.status !== ImportStatus.PENDING) { + throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen'); + } + + const productMappings = await prisma.productMapping.findMany({ + where: { isActive: true }, + orderBy: [{ priority: 'asc' }, { id: 'asc' }], + select: { matchPattern: true, target: true }, + }); + + const rawRows = record.rows.map((row) => ({ + id: row.id, + lineNo: row.lineNo, + raw: row.raw as Record, + })); + + const playerKeys = [ + ...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)), + ]; + + const players = await prisma.player.findMany({ + where: { + OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }], + }, + select: { id: true, steamId: true, displayName: true }, + }); + + const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id])); + const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id])); + + const orderIds = mapping.orderId + ? [...new Set(rawRows.map((r) => (r.raw[mapping.orderId as string] ?? '').trim()).filter(Boolean))] + : []; + const alreadyBooked = new Set( + orderIds.length > 0 + ? ( + await prisma.importedOrder.findMany({ + where: { externalId: { in: orderIds } }, + select: { externalId: true }, + }) + ).map((o) => o.externalId) + : [], + ); + + // Faengt Dubletten innerhalb derselben Datei ab. + const seenInFile = new Set(); + const updates: Prisma.PrismaPromise[] = []; + const summary: Record = { + BOOK: 0, + CREATE_PLAYER_AND_BOOK: 0, + SKIP_DUPLICATE: 0, + SKIP_IGNORED: 0, + SKIP_UNMAPPED: 0, + SKIP_NO_PLAYER: 0, + }; + + for (const row of rawRows) { + const playerKey = (row.raw[mapping.playerKey] ?? '').trim(); + const playerName = mapping.playerName ? (row.raw[mapping.playerName] ?? '').trim() : ''; + const productName = (row.raw[mapping.product] ?? '').trim(); + const externalId = mapping.orderId ? (row.raw[mapping.orderId] ?? '').trim() : ''; + const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined); + + const target = productName ? matchTarget(productName, productMappings) : null; + const resolvedPlayerId = + bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null; + + let plan: Plan; + if (target === null) { + plan = PLANS.SKIP_UNMAPPED; + } else if (target === ProductTarget.IGNORE) { + plan = PLANS.SKIP_IGNORED; + } else if (externalId && (alreadyBooked.has(externalId) || seenInFile.has(externalId))) { + plan = PLANS.SKIP_DUPLICATE; + } else if (resolvedPlayerId !== null) { + plan = PLANS.BOOK; + } else if (mapping.createMissingPlayers && (playerKey || playerName)) { + plan = PLANS.CREATE_PLAYER_AND_BOOK; + } else { + plan = PLANS.SKIP_NO_PLAYER; + } + + if (externalId && (plan === PLANS.BOOK || plan === PLANS.CREATE_PLAYER_AND_BOOK)) { + seenInFile.add(externalId); + } + summary[plan] += 1; + + updates.push( + prisma.importRow.update({ + where: { id: row.id }, + data: { + playerKey: playerKey || null, + playerName: playerName || null, + productName: productName || null, + externalId: externalId || null, + purchasedAt, + target, + resolvedPlayerId, + plan, + }, + }), + ); + } + + await prisma.$transaction([ + ...updates, + prisma.import.update({ + where: { id: importId }, + data: { mapping: mapping as unknown as Prisma.InputJsonValue }, + }), + ]); + + await recordAudit(prisma, ctx, { + action: 'import.preview', + targetType: 'import', + targetId: importId, + details: { mapping: mapping as unknown as Prisma.InputJsonValue, summary }, + }); + + return summary; +} + +export async function getImport(importId: number, includeRows = true) { + const record = await prisma.import.findUnique({ + where: { id: importId }, + include: { + importedBy: { select: { username: true } }, + ...(includeRows ? { rows: { orderBy: { lineNo: 'asc' as const } } } : {}), + }, + }); + if (!record) throw notFound('Import'); + return record; +} + +/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */ +export async function commitImport(importId: number, ctx: ActionContext) { + const record = await prisma.import.findUnique({ + where: { id: importId }, + include: { rows: { orderBy: { lineNo: 'asc' } } }, + }); + if (!record) throw notFound('Import'); + if (record.status !== ImportStatus.PENDING) { + throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen'); + } + + const bookable = record.rows.filter( + (row) => row.plan === PLANS.BOOK || row.plan === PLANS.CREATE_PLAYER_AND_BOOK, + ); + if (bookable.length === 0) { + throw conflict('NOTHING_TO_BOOK', 'Es gibt keine Zeilen zum Buchen'); + } + + const result = await prisma.$transaction(async (tx) => { + // Mehrere Zeilen koennen denselben neuen Spieler betreffen. + const createdPlayers = new Map(); + let booked = 0; + + for (const row of bookable) { + const key = (row.playerKey ?? '').trim(); + let playerId = row.resolvedPlayerId; + + if (playerId === null) { + const cacheKey = key.toLowerCase(); + const cached = createdPlayers.get(cacheKey); + if (cached !== undefined) { + playerId = cached; + } else { + const created = await tx.player.create({ + data: { + displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`, + steamId: key || null, + }, + }); + createdPlayers.set(cacheKey, created.id); + playerId = created.id; + } + } + + const target = row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE'; + const groupId = randomUUID(); + + await tx.creditLedger.createMany({ + data: buildPackageRows(target, { + playerId, + groupId, + note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`, + importId: record.id, + actorId: ctx.actorId, + }), + }); + + if (row.externalId) { + await tx.importedOrder.create({ + data: { + externalId: row.externalId, + importId: record.id, + playerId, + target: row.target ?? ProductTarget.CC_PACKAGE, + groupId, + }, + }); + } + + booked += 1; + } + + const updated = await tx.import.update({ + where: { id: importId }, + data: { + status: ImportStatus.COMMITTED, + committedAt: new Date(), + bookedCount: booked, + }, + }); + + await recordAudit(tx, ctx, { + action: 'import.commit', + targetType: 'import', + targetId: importId, + details: { + filename: record.filename, + booked, + createdPlayers: createdPlayers.size, + }, + }); + + return { booked, createdPlayers: createdPlayers.size, import: updated }; + }); + + return result; +} + +export async function discardImport(importId: number, ctx: ActionContext): Promise { + const record = await prisma.import.findUnique({ where: { id: importId } }); + if (!record) throw notFound('Import'); + if (record.status === ImportStatus.COMMITTED) { + throw conflict('ALREADY_COMMITTED', 'Ein gebuchter Import kann nicht verworfen werden'); + } + + await prisma.import.update({ + where: { id: importId }, + data: { status: ImportStatus.DISCARDED }, + }); + + await recordAudit(prisma, ctx, { + action: 'import.discard', + targetType: 'import', + targetId: importId, + details: { filename: record.filename }, + }); +} + +export async function listImports(take = 50) { + return prisma.import.findMany({ + orderBy: { createdAt: 'desc' }, + take, + include: { importedBy: { select: { username: true } } }, + }); +} diff --git a/server/src/services/players.ts b/server/src/services/players.ts new file mode 100644 index 0000000..8da850c --- /dev/null +++ b/server/src/services/players.ts @@ -0,0 +1,157 @@ +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'; + +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(); + const where: Prisma.PlayerWhereInput = + trimmed.length === 0 + ? {} + : { + OR: [ + { displayName: { contains: trimmed, mode: 'insensitive' } }, + { steamId: { contains: 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] = await Promise.all([ + getBalances(playerId), + listCharacters(playerId), + listLedger({ playerId, take: 200 }), + ]); + + return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total }; +} + +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 = input.steamId?.trim() || null; + 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 : input.steamId?.trim() || null; + 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; + }); +} + +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) => { + await tx.player.delete({ where: { id: playerId } }); + + await recordAudit(tx, ctx, { + action: 'player.delete', + targetType: 'player', + targetId: playerId, + details: { + displayName: existing.displayName, + steamId: existing.steamId, + removedCharacters: existing._count.characters, + removedLedgerRows: existing._count.ledger, + }, + }); + }); +}