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.
This commit is contained in:
2026-09-18 11:37:31 +02:00
parent 3d2b87297e
commit f3e31cdd17
5 changed files with 1379 additions and 0 deletions
+36
View File
@@ -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<typeof prisma, 'auditLog'>;
export async function recordAudit(
client: Client,
ctx: ActionContext,
entry: AuditEntry,
): Promise<void> {
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,
},
});
}
+427
View File
@@ -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<Character> {
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<CharacterResult> {
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<CharacterResult & { predecessor: Character }> {
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<CharacterResult> {
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<CharacterResult> {
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<CharacterResult> {
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<Character> {
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 } },
},
});
}
+325
View File
@@ -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<Record<CreditKind, number>>): 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<Balances> {
const rows = await prisma.creditLedger.groupBy({
by: ['kind'],
where: { playerId },
_sum: { amount: true },
});
const sums: Partial<Record<CreditKind, number>> = {};
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
return assemble(sums);
}
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
const result = new Map<number, Balances>();
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<number, Partial<Record<CreditKind, number>>>();
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<void> {
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 };
}
+434
View File
@@ -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<string, string>[];
}
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<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
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<string, string>[];
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<string, unknown> => 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<string, unknown>[];
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<number> {
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<string, string>,
}));
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<string>();
const updates: Prisma.PrismaPromise<unknown>[] = [];
const summary: Record<Plan, number> = {
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<string, number>();
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<void> {
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 } } },
});
}
+157
View File
@@ -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<void> {
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<PlayerInput>, 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<void> {
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,
},
});
});
}