Files
CC-Manager/server/src/services/players.ts
T
Harlekin c242e73c38 GExtension-Abgleich mit Handpruefung fuer unklare Pakete
Die Profilseiten sind ohne Login abrufbar und betten die Kaeufe als JSON ins
HTML ein. Gelesen wird dieses JSON statt der gerenderten Tabelle: das ist
unempfindlich gegen Layout-Aenderungen und liefert zusaetzlich Kaufdatum,
Betrag und den Storno-Status. Ein Headless-Browser ist nicht noetig.

Der Abgleich bucht nichts von sich aus. Eindeutige Treffer landen als
regulaerer Import in der bestehenden Vorschau, die per package_bought_id
dedupliziert. Titel, die nach CC klingen, sich aber keiner Guthaben-Art
sicher zuordnen lassen, gehen in eine Handpruefung statt geraten zu werden -
sichtbar als Hinweis auf der Uebersicht und als Box beim Spieler.

Beachtet werden dabei: stornierte Kaeufe werden uebersprungen, der Web-
Bereich enthaelt nie CC-Pakete, und ein Redesign-Titel zaehlt als Redesign,
auch wenn Custom Character darin vorkommt. Letzteres war auch in den
Standard-Mappings des Datei-Imports falsch herum einsortiert.

Der Lauf geht bewusst nur ueber bereits erfasste Spieler und haelt eine
Sekunde Abstand zwischen den Anfragen, da die Seite fremde Infrastruktur ist.

Voraussetzung dafuer ist, dass SteamIDs auf eine Schreibweise gebracht
werden: die Legacy-Form, die Steam3-Form und die 64-Bit-Zahl bezeichnen
dieselbe Person, wurden bisher aber als verschiedene Spieler gefuehrt.
Gespeichert wird jetzt immer die 64-Bit-Form, Eingabe und Suche akzeptieren
alle drei.
2026-09-18 15:42:31 +02:00

222 lines
7.2 KiB
TypeScript

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<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 = 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<PlayerInput>, 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<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) => {
// 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,
},
});
});
}