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:
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user