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.
This commit is contained in:
2026-09-18 15:42:31 +02:00
parent bc86e2df26
commit c242e73c38
16 changed files with 1053 additions and 16 deletions
@@ -62,10 +62,31 @@ CREATE TABLE "Player" (
"notes" TEXT, "notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
"gextensionUsage" INTEGER,
"gextensionSyncedAt" TIMESTAMP(3),
CONSTRAINT "Player_pkey" PRIMARY KEY ("id") CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "UnmatchedPackage" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"playerId" INTEGER,
"steamId" TEXT NOT NULL,
"rawTitle" TEXT NOT NULL,
"rawTag" TEXT NOT NULL,
"amount" TEXT,
"purchasedAt" TIMESTAMP(3),
"scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolved" BOOLEAN NOT NULL DEFAULT false,
"resolvedAs" TEXT,
"resolvedById" INTEGER,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable -- CreateTable
CREATE TABLE "CreditLedger" ( CREATE TABLE "CreditLedger" (
"id" SERIAL NOT NULL, "id" SERIAL NOT NULL,
@@ -195,6 +216,15 @@ CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
-- CreateIndex -- CreateIndex
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName"); CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId"); CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
@@ -246,6 +276,9 @@ CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+31
View File
@@ -96,13 +96,44 @@ model Player {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich,
/// wird nie automatisch mit dem Ledger verrechnet.
gextensionUsage Int?
gextensionSyncedAt DateTime?
ledger CreditLedger[] ledger CreditLedger[]
characters Character[] characters Character[]
orders ImportedOrder[] orders ImportedOrder[]
unmatched UnmatchedPackage[]
@@index([displayName]) @@index([displayName])
} }
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht,
/// sondern im CC-Manager von Hand entschieden.
model UnmatchedPackage {
id Int @id @default(autoincrement())
externalId String @unique
playerId Int?
player Player? @relation(fields: [playerId], references: [id], onDelete: Cascade)
steamId String
rawTitle String
rawTag String
amount String?
purchasedAt DateTime?
scrapedAt DateTime @default(now())
resolved Boolean @default(false)
/// CC_PACKAGE | REDESIGN_PACKAGE | REDESIGN_KULANZ | IGNORE
resolvedAs String?
resolvedById Int?
resolvedAt DateTime?
@@index([resolved])
@@index([playerId])
}
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch). /// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
/// Der offene Stand eines Topfes ist die Summe der amounts je kind. /// Der offene Stand eines Topfes ist die Summe der amounts je kind.
model CreditLedger { model CreditLedger {
+2
View File
@@ -17,6 +17,7 @@ import { creditRoutes } from './routes/credits.routes.js';
import { characterRoutes } from './routes/characters.routes.js'; import { characterRoutes } from './routes/characters.routes.js';
import { importRoutes } from './routes/imports.routes.js'; import { importRoutes } from './routes/imports.routes.js';
import { adminRoutes } from './routes/admin.routes.js'; import { adminRoutes } from './routes/admin.routes.js';
import { scraperRoutes } from './routes/scraper.routes.js';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
// Im Container liegt das gebaute Frontend neben dem kompilierten Server. // Im Container liegt das gebaute Frontend neben dem kompilierten Server.
@@ -121,6 +122,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(characterRoutes); await app.register(characterRoutes);
await app.register(importRoutes); await app.register(importRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
await app.register(scraperRoutes);
if (existsSync(webRoot)) { if (existsSync(webRoot)) {
await app.register(fastifyStatic, { await app.register(fastifyStatic, {
+5 -1
View File
@@ -6,11 +6,15 @@ import { hashPassword } from './auth/password.js';
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich // Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
// anpassbar, sobald die echten Produktnamen bekannt sind. // anpassbar, sobald die echten Produktnamen bekannt sind.
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide
// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht
// das Redesign-Muster vorn.
const DEFAULT_MAPPINGS = [ const DEFAULT_MAPPINGS = [
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 }, { matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 }, { matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 }, { matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 }, { matchPattern: 'custom charakter', target: ProductTarget.CC_PACKAGE, priority: 13 },
]; ];
export interface BootstrapResult { export interface BootstrapResult {
+179
View File
@@ -0,0 +1,179 @@
import { ProductTarget } from '@prisma/client';
import { badRequest } from '../errors.js';
import { normalizeSteamId } from './steamid.js';
/**
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
* Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
* gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag
* und den Storno-Status.
*/
export interface ScrapedPackage {
packageBoughtId: number;
packageName: string;
serverBundle: string;
purchasedAt: Date | null;
amount: string;
gateway: string;
revoked: boolean;
}
export interface ScrapedProfile {
steamId64: string;
nickname: string | null;
packages: ScrapedPackage[];
}
const DONATION_ENTRY = /donations_data\[(\d+)\]\s*=\s*(\{.*?\});/gs;
// "06.10.2025 20:54"
const GERMAN_DATE = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?$/;
function parseGermanDate(value: unknown): Date | null {
if (typeof value !== 'string') return null;
const match = GERMAN_DATE.exec(value.trim());
if (!match) return null;
const [, day, month, year, hour, minute] = match;
const parsed = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour ?? 0),
Number(minute ?? 0),
);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function parseProfile(html: string, steamId64: string): ScrapedProfile {
const packages: ScrapedPackage[] = [];
for (const match of html.matchAll(DONATION_ENTRY)) {
const raw = match[2];
if (!raw) continue;
let entry: Record<string, unknown>;
try {
entry = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Einzelne kaputte Zeile darf nicht den ganzen Lauf kippen.
continue;
}
const name = typeof entry.package_name === 'string' ? entry.package_name.trim() : '';
if (!name) continue;
packages.push({
packageBoughtId: Number(entry.package_bought_id ?? match[1]),
packageName: name,
serverBundle: typeof entry.serverbundle_name === 'string' ? entry.serverbundle_name : '',
purchasedAt: parseGermanDate(entry.date),
amount: typeof entry.amount === 'string' ? entry.amount : '',
gateway: typeof entry.gateway === 'string' ? entry.gateway : '',
revoked: entry.revoked === 1 || entry.revoked === true || entry.status === 0,
});
}
const nickname = extractNickname(html);
return { steamId64, nickname, packages };
}
function extractNickname(html: string): string | null {
const match = /Nickname\s*<\/td>\s*<td[^>]*>([^<]+)</i.exec(html);
return match?.[1]?.trim() || null;
}
export type Classification =
| { kind: 'package'; target: ProductTarget }
| { kind: 'usage_counter'; times: number }
| { kind: 'unclear' }
| { kind: 'ignore' };
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
const RELEVANT_BUNDLE = 'scp-rp';
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
// zu werden.
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
const USAGE_COUNTER = /cc[\s-]*paket.*genutzt/i;
const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
/**
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
* Redesign zaehlen, nicht als CC-Paket.
*/
export function classifyPackage(pkg: ScrapedPackage): Classification {
if (pkg.revoked) return { kind: 'ignore' };
if (pkg.serverBundle.toLowerCase() !== RELEVANT_BUNDLE) return { kind: 'ignore' };
const title = pkg.packageName;
if (USAGE_COUNTER.test(title)) {
const times = USAGE_TIMES.exec(title);
return { kind: 'usage_counter', times: times ? Number(times[1]) : 1 };
}
if (/redesign/i.test(title)) {
return { kind: 'package', target: ProductTarget.REDESIGN_PACKAGE };
}
if (/custom\s*charac?k?ter/i.test(title)) {
return { kind: 'package', target: ProductTarget.CC_PACKAGE };
}
// Klingt nach CC, passt aber in kein Muster: lieber nachfragen als raten.
if (CC_HINT.test(title)) return { kind: 'unclear' };
return { kind: 'ignore' };
}
const PROFILE_URL = 'https://gextension.senseigaming.de/index.php?t=user&id=';
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
const MIN_REQUEST_GAP_MS = 1000;
let lastRequestAt = 0;
async function throttle(): Promise<void> {
const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now();
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
lastRequestAt = Date.now();
}
export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise<ScrapedProfile> {
const steamId64 = normalizeSteamId(steamId);
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
await throttle();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${PROFILE_URL}${steamId64}`, {
signal: controller.signal,
headers: {
// Ehrliche Kennzeichnung statt Browser-Tarnung.
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)',
accept: 'text/html',
},
});
if (!response.ok) {
throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`);
}
return parseProfile(await response.text(), steamId64);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils');
}
throw error;
} finally {
clearTimeout(timer);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin, requireAuth } from '../auth/guard.js';
import { notFound } from '../errors.js';
import { prisma } from '../db.js';
import {
knownSteamIds,
listUnmatched,
resolveUnmatched,
scrapePlayers,
unmatchedOverview,
} from '../services/scraper.js';
import { contextOf, idParam, parseWith } from './helpers.js';
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
/**
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
* bekannten: der Abgleich soll vorhandene Konten ergaenzen, nicht fremde
* Profile auf Vorrat einsammeln.
*/
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
const body = parseWith(
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
request.body ?? {},
);
const all = await knownSteamIds();
const targets = body.playerIds
? all.filter((entry) => body.playerIds?.includes(entry.playerId))
: all;
return scrapePlayers(targets, contextOf(request));
});
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const player = await prisma.player.findUnique({
where: { id },
select: { id: true, steamId: true },
});
if (!player) throw notFound('Spieler');
if (!player.steamId) {
throw notFound('SteamID des Spielers');
}
return scrapePlayers([{ playerId: player.id, steamId: player.steamId }], contextOf(request));
});
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({ all: z.enum(['true', 'false']).default('false') }),
request.query,
);
return { items: await listUnmatched(query.all === 'false') };
});
app.get('/api/scrape/unmatched/overview', { preHandler: requireAuth }, async () => {
return unmatchedOverview();
});
app.post('/api/scrape/unmatched/:id/resolve', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
resolution: z.enum(['CC_PACKAGE', 'REDESIGN_PACKAGE', 'REDESIGN_KULANZ', 'IGNORE']),
}),
request.body,
);
await resolveUnmatched(id, body.resolution, contextOf(request));
return { ok: true };
});
}
+13 -3
View File
@@ -5,6 +5,7 @@ import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js'; import { badRequest, conflict, notFound } from '../errors.js';
import { buildPackageRows } from './credits.js'; import { buildPackageRows } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
import { normalizeSteamId } from '../lib/steamid.js';
export const PLANS = { export const PLANS = {
BOOK: 'BOOK', BOOK: 'BOOK',
@@ -228,8 +229,13 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
raw: row.raw as Record<string, string>, raw: row.raw as Record<string, string>,
})); }));
// Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt.
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
const playerKeys = [ const playerKeys = [
...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)), ...new Set(rawKeys.flatMap((key) => {
const normalized = normalizeSteamId(key);
return normalized ? [key, normalized] : [key];
})),
]; ];
const players = await prisma.player.findMany({ const players = await prisma.player.findMany({
@@ -276,8 +282,12 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined); const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
const target = productName ? matchTarget(productName, productMappings) : null; const target = productName ? matchTarget(productName, productMappings) : null;
const canonicalKey = normalizeSteamId(playerKey);
const resolvedPlayerId = const resolvedPlayerId =
bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null; (canonicalKey ? bySteamId.get(canonicalKey) : undefined) ??
bySteamId.get(playerKey) ??
byName.get(playerKey.toLowerCase()) ??
null;
let plan: Plan; let plan: Plan;
if (target === null) { if (target === null) {
@@ -382,7 +392,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
const created = await tx.player.create({ const created = await tx.player.create({
data: { data: {
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`, displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
steamId: key || null, steamId: normalizeSteamId(key),
}, },
}); });
createdPlayers.set(cacheKey, created.id); createdPlayers.set(cacheKey, created.id);
+41 -5
View File
@@ -4,6 +4,7 @@ import { conflict, notFound } from '../errors.js';
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js'; import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
import { listCharacters } from './characters.js'; import { listCharacters } from './characters.js';
import { recordAudit, type ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
export interface PlayerInput { export interface PlayerInput {
displayName: string; displayName: string;
@@ -14,13 +15,17 @@ export interface PlayerInput {
export async function searchPlayers(query: string, take = 50, skip = 0) { export async function searchPlayers(query: string, take = 50, skip = 0) {
const trimmed = query.trim(); 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 = const where: Prisma.PlayerWhereInput =
trimmed.length === 0 trimmed.length === 0
? {} ? {}
: { : {
OR: [ OR: [
{ displayName: { contains: trimmed, mode: 'insensitive' } }, { displayName: { contains: trimmed, mode: 'insensitive' } },
{ steamId: { contains: trimmed, mode: 'insensitive' } }, { steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } },
{ forumName: { contains: trimmed, mode: 'insensitive' } }, { forumName: { contains: trimmed, mode: 'insensitive' } },
], ],
}; };
@@ -62,13 +67,44 @@ export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } }); const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler'); if (!player) throw notFound('Spieler');
const [balances, characters, ledger] = await Promise.all([ const [balances, characters, ledger, unmatched] = await Promise.all([
getBalances(playerId), getBalances(playerId),
listCharacters(playerId), listCharacters(playerId),
listLedger({ playerId, take: 200 }), 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 }; 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> { async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
@@ -79,7 +115,7 @@ async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<vo
} }
export async function createPlayer(input: PlayerInput, ctx: ActionContext) { export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = input.steamId?.trim() || null; const steamId = canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId); if (steamId) await assertSteamIdFree(steamId);
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
@@ -107,7 +143,7 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
const existing = await prisma.player.findUnique({ where: { id: playerId } }); const existing = await prisma.player.findUnique({ where: { id: playerId } });
if (!existing) throw notFound('Spieler'); if (!existing) throw notFound('Spieler');
const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null; const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId, playerId); if (steamId) await assertSteamIdFree(steamId, playerId);
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
+256
View File
@@ -0,0 +1,256 @@
import { ImportStatus, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest } from '../errors.js';
import {
classifyPackage,
fetchProfile,
type ScrapedProfile,
} from '../lib/gextension.js';
import { PLANS } from './imports.js';
import { recordAudit, type ActionContext } from './audit.js';
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
const players = await prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
});
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
unclear: number;
usageCounters: number;
}
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
// in die Quere kommen.
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
/**
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
* automatisch gebucht.
*/
export async function scrapePlayers(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
const summary: ScrapeSummary = {
importId: null,
scannedPlayers: 0,
failedPlayers: [],
newRows: 0,
duplicates: 0,
unclear: 0,
usageCounters: 0,
};
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
let lineNo = 0;
for (const target of targets) {
let profile: ScrapedProfile;
try {
profile = await fetchProfile(target.steamId);
summary.scannedPlayers += 1;
} catch (error) {
summary.failedPlayers.push({
steamId: target.steamId,
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
});
continue;
}
for (const pkg of profile.packages) {
const classification = classifyPackage(pkg);
const externalId = externalIdFor(pkg.packageBoughtId);
if (classification.kind === 'usage_counter') {
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
});
summary.usageCounters += 1;
continue;
}
if (classification.kind === 'unclear') {
await prisma.unmatchedPackage.upsert({
where: { externalId },
create: {
externalId,
playerId: target.playerId,
steamId: profile.steamId64,
rawTitle: pkg.packageName,
rawTag: pkg.serverBundle,
amount: pkg.amount || null,
purchasedAt: pkg.purchasedAt,
},
update: {},
});
summary.unclear += 1;
continue;
}
if (classification.kind === 'ignore') continue;
const alreadyBooked = await prisma.importedOrder.findUnique({
where: { externalId },
select: { id: true },
});
if (alreadyBooked) {
summary.duplicates += 1;
continue;
}
lineNo += 1;
rows.push({
lineNo,
externalId,
playerKey: profile.steamId64,
playerName: profile.nickname,
productName: pkg.packageName,
purchasedAt: pkg.purchasedAt,
target: classification.target,
resolvedPlayerId: target.playerId,
plan: PLANS.BOOK,
raw: {
package_name: pkg.packageName,
serverbundle_name: pkg.serverBundle,
amount: pkg.amount,
gateway: pkg.gateway,
} as Prisma.InputJsonValue,
});
summary.newRows += 1;
}
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionSyncedAt: new Date() },
});
}
if (rows.length > 0) {
const created = await prisma.import.create({
data: {
filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`,
status: ImportStatus.PENDING,
rowCount: rows.length,
importedById: ctx.actorId,
mapping: { source: 'gextension' } as Prisma.InputJsonValue,
rows: { createMany: { data: rows } },
},
});
summary.importId = created.id;
}
await recordAudit(prisma, ctx, {
action: 'scrape.run',
targetType: 'import',
targetId: summary.importId,
details: {
scannedPlayers: summary.scannedPlayers,
newRows: summary.newRows,
duplicates: summary.duplicates,
unclear: summary.unclear,
failed: summary.failedPlayers.length,
},
});
return summary;
}
// --- Handpruefung der unklaren Eintraege ---
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
export async function listUnmatched(onlyOpen = true) {
return prisma.unmatchedPackage.findMany({
where: onlyOpen ? { resolved: false } : {},
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
include: { player: { select: { id: true, displayName: true } } },
});
}
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
export async function unmatchedOverview() {
const open = await prisma.unmatchedPackage.findMany({
where: { resolved: false },
select: { playerId: true, player: { select: { displayName: true } } },
});
const byPlayer = new Map<number, { playerId: number; displayName: string; count: number }>();
for (const entry of open) {
if (entry.playerId === null) continue;
const current = byPlayer.get(entry.playerId);
if (current) {
current.count += 1;
} else {
byPlayer.set(entry.playerId, {
playerId: entry.playerId,
displayName: entry.player?.displayName ?? 'Unbekannt',
count: 1,
});
}
}
return { total: open.length, players: [...byPlayer.values()] };
}
/**
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
* ein Redesign mitbringt.
*/
export async function resolveUnmatched(
id: number,
resolution: Resolution,
ctx: ActionContext,
): Promise<void> {
const entry = await prisma.unmatchedPackage.findUnique({ where: { id } });
if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden');
if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden');
if (resolution !== 'IGNORE') {
if (entry.playerId === null) {
throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet');
}
const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js');
const note = `GExtension: ${entry.rawTitle}`;
if (resolution === 'CC_PACKAGE') {
await bookCcPackage(entry.playerId, ctx, { note });
} else if (resolution === 'REDESIGN_PACKAGE') {
await bookRedesignPackage(entry.playerId, ctx, { note });
} else {
await bookKulanz(entry.playerId, ctx, { note });
}
}
await prisma.unmatchedPackage.update({
where: { id },
data: {
resolved: true,
resolvedAs: resolution,
resolvedById: ctx.actorId,
resolvedAt: new Date(),
},
});
await recordAudit(prisma, ctx, {
action: 'scrape.resolve',
targetType: 'unmatched_package',
targetId: id,
details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId },
});
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<!-- Nachbau der Struktur einer GExtension-Profilseite mit erfundenen Daten.
Die echte Seite bettet die Kaeufe genauso als donations_data-Zuweisungen ein. -->
<html lang="de">
<head><title>Profil</title></head>
<body>
<div class="box"><h3>ALLGEMEIN</h3>
<table><tr><td>Nickname</td><td>Testspieler</td></tr>
<tr><td>SteamID64</td><td>76561198000000999</td></tr></table>
</div>
<div class="box"><h3>Pakete</h3><div id="packages"></div></div>
<script>
var donations_data = [];
donations_data[9001] = {"id":"","package_bought_id":9001,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:54","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9002] = {"id":"","package_bought_id":9002,"amount":"15.00 EUR","package":10,"package_name":"Custom Character Redesign","steamid64_buyer":"","nick_buyer":"","date":"07.10.2025 10:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9003] = {"id":"","package_bought_id":9003,"amount":"0.00 EUR","package":11,"package_name":"Custom Charakter-Beantragung","steamid64_buyer":"","nick_buyer":"","date":"08.10.2025 11:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9004] = {"id":"","package_bought_id":9004,"amount":"0.00 EUR","package":70,"package_name":"FG-Tasse | Merch","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:53","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9005] = {"id":"","package_bought_id":9005,"amount":"49.67 EUR","package":80,"package_name":"Ablassbrief (01.04.2026)","steamid64_buyer":"","nick_buyer":"","date":"01.04.2026 09:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"Web","serverbundle":0,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9006] = {"id":"","package_bought_id":9006,"amount":"0.00 EUR","package":12,"package_name":"CC-Paket oder Redesign genutzt | 1 Mal","steamid64_buyer":"","nick_buyer":"","date":"09.10.2025 12:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9007] = {"id":"","package_bought_id":9007,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"10.10.2025 13:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":1,"status":0};
donations_data[9008] = {"id":"","package_bought_id":9008,"amount":"0.00 EUR","package":99,"package_name":"CC Sonderaktion Winter","steamid64_buyer":"","nick_buyer":"","date":"11.10.2025 14:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
</script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { classifyPackage, parseProfile, type ScrapedPackage } from '../src/lib/gextension.js';
import { looksLikeSteamId, normalizeSteamId, toLegacySteamId } from '../src/lib/steamid.js';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = readFileSync(join(here, 'fixtures', 'profil.html'), 'utf8');
function pkg(overrides: Partial<ScrapedPackage>): ScrapedPackage {
return {
packageBoughtId: 1,
packageName: 'Custom Character',
serverBundle: 'SCP-RP',
purchasedAt: null,
amount: '0.00 EUR',
gateway: '',
revoked: false,
...overrides,
};
}
describe('Profil auslesen', () => {
const profile = parseProfile(FIXTURE, '76561198000000999');
it('findet alle Pakete', () => {
assert.equal(profile.packages.length, 8);
});
it('liest Titel, Bereich und Kaufdatum', () => {
const cc = profile.packages.find((p) => p.packageBoughtId === 9001);
assert.ok(cc);
assert.equal(cc.packageName, 'Custom Character');
assert.equal(cc.serverBundle, 'SCP-RP');
assert.equal(cc.purchasedAt?.getFullYear(), 2025);
assert.equal(cc.purchasedAt?.getMonth(), 9, 'Oktober');
assert.equal(cc.purchasedAt?.getDate(), 6);
});
it('erkennt stornierte Kaeufe', () => {
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
assert.equal(revoked?.revoked, true);
});
it('liest den Nicknamen aus der Allgemein-Box', () => {
assert.equal(profile.nickname, 'Testspieler');
});
it('kommt mit kaputtem HTML klar, ohne alles zu verlieren', () => {
const broken = FIXTURE.replace(
'donations_data[9004] = {"id"',
'donations_data[9004] = {kaputt"id"',
);
const result = parseProfile(broken, '76561198000000999');
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg');
});
});
describe('Pakete zuordnen', () => {
it('erkennt ein CC-Paket', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('erkennt die Schreibweise mit K', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Charakter-Beantragung' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
kind: 'package',
target: 'REDESIGN_PACKAGE',
});
});
it('liest den Verbrauchszaehler samt Anzahl', () => {
const result = classifyPackage(
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
);
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
});
it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => {
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
});
it('ignoriert alles aus dem Web-Bereich', () => {
const result = classifyPackage(
pkg({ packageName: 'Ablassbrief (01.04.2026)', serverBundle: 'Web' }),
);
assert.equal(result.kind, 'ignore');
});
it('bucht ein storniertes Paket nicht', () => {
const result = classifyPackage(pkg({ packageName: 'Custom Character', revoked: true }));
assert.equal(result.kind, 'ignore');
});
it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => {
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
});
});
describe('SteamID-Schreibweisen', () => {
// Dieselbe (erfundene) Kennung in drei Formaten.
const legacy = 'STEAM_0:1:19867635';
const modern = '[U:1:39735271]';
const sixtyFour = '76561198000000999';
it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => {
assert.equal(normalizeSteamId(legacy), sixtyFour);
assert.equal(normalizeSteamId(modern), sixtyFour);
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
});
it('rechnet zurueck in die Legacy-Form', () => {
assert.equal(toLegacySteamId(sixtyFour), legacy);
});
it('beherrscht auch das ungerade Bit', () => {
const odd = 'STEAM_0:1:12345678';
const asNumber = normalizeSteamId(odd);
assert.ok(asNumber);
assert.equal(toLegacySteamId(asNumber), odd);
});
it('weist Unsinn ab', () => {
assert.equal(normalizeSteamId('Darkbrine44'), null);
assert.equal(normalizeSteamId(''), null);
assert.equal(normalizeSteamId('12345'), null);
assert.equal(looksLikeSteamId('Darkbrine44'), false);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import type { Resolution, UnmatchedPackage } from '../lib/types';
const CHOICES: { value: Resolution; label: string; hint: string }[] = [
{ value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' },
{ value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' },
{ value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' },
{ value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' },
];
/**
* Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden.
* Sie werden bewusst nie automatisch gebucht, sondern hier entschieden.
*/
export default function UnmatchedBox({
items,
canResolve,
onResolved,
}: {
items: UnmatchedPackage[];
canResolve: boolean;
onResolved: () => void;
}) {
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
if (items.length === 0) return null;
const resolve = async (id: number, resolution: Resolution) => {
setBusyId(id);
setError(null);
try {
await api(`/api/scrape/unmatched/${id}/resolve`, {
method: 'POST',
body: { resolution },
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden');
} finally {
setBusyId(null);
}
};
return (
<section className="block block-flag">
<h2>Ungeklaerte Eintraege ({items.length})</h2>
<Notice kind="warn">
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
sie hier einordnest.
</Notice>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
<ul className="cc-list">
{items.map((item) => (
<li key={item.id} className="cc-item">
<div>
<strong>{item.rawTitle}</strong>
<span className="badge">{item.rawTag}</span>
<p className="muted small">
{item.amount && `${item.amount} · `}
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
</p>
</div>
{canResolve && (
<div className="cc-actions">
{CHOICES.map((choice) => (
<button
key={choice.value}
type="button"
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
title={choice.hint}
disabled={busyId !== null}
onClick={() => void resolve(item.id, choice.value)}
>
{choice.label}
</button>
))}
</div>
)}
</li>
))}
</ul>
</section>
);
}
+35
View File
@@ -43,6 +43,40 @@ export interface Player {
forumName: string | null; forumName: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
gextensionUsage: number | null;
gextensionSyncedAt: string | null;
}
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
export interface UnmatchedPackage {
id: number;
externalId: string;
playerId: number | null;
steamId: string;
rawTitle: string;
rawTag: string;
amount: string | null;
purchasedAt: string | null;
scrapedAt: string;
resolved: boolean;
resolvedAs: string | null;
player: { id: number; displayName: string } | null;
}
export interface UnmatchedOverview {
total: number;
players: { playerId: number; displayName: string; count: number }[];
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
unclear: number;
usageCounters: number;
} }
export interface CharacterRef { export interface CharacterRef {
@@ -87,6 +121,7 @@ export interface PlayerDetail {
characters: Character[]; characters: Character[];
ledger: LedgerEntry[]; ledger: LedgerEntry[];
ledgerTotal: number; ledgerTotal: number;
unmatched: UnmatchedPackage[];
} }
export interface AdminUser { export interface AdminUser {
+75 -7
View File
@@ -4,7 +4,7 @@ import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
import type { PlayerSummary } from '../lib/types'; import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types';
interface SearchResult { interface SearchResult {
total: number; total: number;
@@ -26,6 +26,9 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const load = useCallback(async (search: string, signal?: AbortSignal) => { const load = useCallback(async (search: string, signal?: AbortSignal) => {
setLoading(true); setLoading(true);
@@ -42,6 +45,37 @@ export default function Dashboard() {
} }
}, []); }, []);
const loadFlagged = useCallback(async () => {
try {
setFlagged(await api<UnmatchedOverview>('/api/scrape/unmatched/overview'));
} catch {
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren.
}
}, []);
useEffect(() => {
void loadFlagged();
}, [loadFlagged]);
const runSync = async () => {
setSyncing(true);
setSyncResult(null);
try {
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
const parts = [`${result.scannedPlayers} Profile geprueft`];
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`);
if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`);
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
setSyncResult(parts.join(', ') + '.');
await Promise.all([load(query), loadFlagged()]);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally {
setSyncing(false);
}
};
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest. // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -56,11 +90,18 @@ export default function Dashboard() {
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<h1>Spieler</h1> <h1>Spieler</h1>
{can('EDITOR') && ( <div className="head-actions">
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}> {can('ADMIN') && (
Spieler anlegen <button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
</button> {syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
)} </button>
)}
{can('EDITOR') && (
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
Spieler anlegen
</button>
)}
</div>
</div> </div>
<input <input
@@ -73,6 +114,24 @@ export default function Dashboard() {
/> />
{error && <Notice kind="error">{error}</Notice>} {error && <Notice kind="error">{error}</Notice>}
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
{flagged.total > 0 && (
<Notice kind="warn">
<strong>
{flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '}
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
</strong>{' '}
{flagged.players.map((entry, index) => (
<span key={entry.playerId}>
{index > 0 && ', '}
<Link to={`/players/${entry.playerId}`}>
{entry.displayName} ({entry.count})
</Link>
</span>
))}
</Notice>
)}
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
/> />
<label htmlFor="steamId">SteamID (optional)</label> <label htmlFor="steamId">SteamID (optional)</label>
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} /> <input
id="steamId"
value={steamId}
onChange={(event) => setSteamId(event.target.value)}
placeholder="76561198... oder STEAM_0:1:..."
/>
<p className="muted small">
Beide Schreibweisen sind moeglich, auch <code className="mono">[U:1:...]</code>.
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
</p>
<label htmlFor="forumName">Forum-Name (optional)</label> <label htmlFor="forumName">Forum-Name (optional)</label>
<input <input
+35
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards'; import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
@@ -87,6 +88,21 @@ export default function PlayerDetail() {
<BalanceCards balances={detail.balances} /> <BalanceCards balances={detail.balances} />
{detail.player.gextensionUsage !== null && (
<p className="muted small usage-hint">
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
{detail.player.gextensionSyncedAt &&
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
. Nur zum Abgleich — wird nicht automatisch verrechnet.
</p>
)}
<UnmatchedBox
items={detail.unmatched ?? []}
canResolve={can('ADMIN')}
onResolved={() => void load()}
/>
{editor && ( {editor && (
<section className="action-bar"> <section className="action-bar">
<button <button
@@ -140,6 +156,25 @@ export default function PlayerDetail() {
> >
Neuer CC Neuer CC
</button> </button>
{can('ADMIN') && detail.player.steamId && (
<button
type="button"
className="btn"
disabled={busy}
title="Pakete dieses Spielers von GExtension abrufen"
onClick={() =>
void run(async () => {
const result = await api<{ newRows: number; unclear: number }>(
`/api/scrape/player/${playerId}`,
{ method: 'POST', body: {} },
);
return result;
}, 'GExtension abgeglichen.')
}
>
GExtension abgleichen
</button>
)}
</section> </section>
)} )}
+16
View File
@@ -817,3 +817,19 @@ textarea {
padding: 1.25rem 1rem 3rem; padding: 1.25rem 1rem 3rem;
} }
} }
/* --- GExtension-Abgleich --- */
.head-actions {
display: flex;
gap: 0.6rem;
flex-shrink: 0;
}
.block-flag {
border-color: var(--warn);
}
.usage-hint {
margin: -0.5rem 0 1.25rem;
}