diff --git a/server/prisma/migrations/20250101000000_init/migration.sql b/server/prisma/migrations/20250101000000_init/migration.sql index b681ba7..0af8c03 100644 --- a/server/prisma/migrations/20250101000000_init/migration.sql +++ b/server/prisma/migrations/20250101000000_init/migration.sql @@ -62,10 +62,31 @@ CREATE TABLE "Player" ( "notes" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, + "gextensionUsage" INTEGER, + "gextensionSyncedAt" TIMESTAMP(3), 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 CREATE TABLE "CreditLedger" ( "id" SERIAL NOT NULL, @@ -195,6 +216,15 @@ CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId"); -- CreateIndex 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 CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId"); @@ -246,6 +276,9 @@ CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId"); -- AddForeignKey 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 ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d3ca915..32c750b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -96,13 +96,44 @@ model Player { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich, + /// wird nie automatisch mit dem Ledger verrechnet. + gextensionUsage Int? + gextensionSyncedAt DateTime? + ledger CreditLedger[] characters Character[] orders ImportedOrder[] + unmatched UnmatchedPackage[] @@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). /// Der offene Stand eines Topfes ist die Summe der amounts je kind. model CreditLedger { diff --git a/server/src/app.ts b/server/src/app.ts index 204043d..a571ccc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import { creditRoutes } from './routes/credits.routes.js'; import { characterRoutes } from './routes/characters.routes.js'; import { importRoutes } from './routes/imports.routes.js'; import { adminRoutes } from './routes/admin.routes.js'; +import { scraperRoutes } from './routes/scraper.routes.js'; const here = dirname(fileURLToPath(import.meta.url)); // Im Container liegt das gebaute Frontend neben dem kompilierten Server. @@ -121,6 +122,7 @@ export async function buildApp(): Promise { await app.register(characterRoutes); await app.register(importRoutes); await app.register(adminRoutes); + await app.register(scraperRoutes); if (existsSync(webRoot)) { await app.register(fastifyStatic, { diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 267c818..1b1495b 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -6,11 +6,15 @@ import { hashPassword } from './auth/password.js'; // Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich // 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 = [ + { 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: 11 }, { 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 { diff --git a/server/src/lib/gextension.ts b/server/src/lib/gextension.ts new file mode 100644 index 0000000..09744d8 --- /dev/null +++ b/server/src/lib/gextension.ts @@ -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; + try { + entry = JSON.parse(raw) as Record; + } 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*]*>([^<]+) { + 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 { + 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); + } +} diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts new file mode 100644 index 0000000..4912de6 --- /dev/null +++ b/server/src/routes/scraper.routes.ts @@ -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 { + /** + * 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 }; + }); +} diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index 28224e0..252df19 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -5,6 +5,7 @@ import { prisma } from '../db.js'; import { badRequest, conflict, notFound } from '../errors.js'; import { buildPackageRows } from './credits.js'; import { recordAudit, type ActionContext } from './audit.js'; +import { normalizeSteamId } from '../lib/steamid.js'; export const PLANS = { BOOK: 'BOOK', @@ -228,8 +229,13 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx raw: row.raw as Record, })); + // 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 = [ - ...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({ @@ -276,8 +282,12 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined); const target = productName ? matchTarget(productName, productMappings) : null; + const canonicalKey = normalizeSteamId(playerKey); 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; if (target === null) { @@ -382,7 +392,7 @@ export async function commitImport(importId: number, ctx: ActionContext) { const created = await tx.player.create({ data: { displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`, - steamId: key || null, + steamId: normalizeSteamId(key), }, }); createdPlayers.set(cacheKey, created.id); diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 677adb5..030aebd 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -4,6 +4,7 @@ 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; @@ -14,13 +15,17 @@ export interface PlayerInput { 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: trimmed, mode: 'insensitive' } }, + { steamId: { contains: asSteamId ?? 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 } }); if (!player) throw notFound('Spieler'); - const [balances, characters, ledger] = await Promise.all([ + 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 }; + 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 { @@ -79,7 +115,7 @@ async function assertSteamIdFree(steamId: string, exceptId?: number): Promise { @@ -107,7 +143,7 @@ export async function updatePlayer(playerId: number, input: Partial const existing = await prisma.player.findUnique({ where: { id: playerId } }); 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); return prisma.$transaction(async (tx) => { diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts new file mode 100644 index 0000000..c6a90f8 --- /dev/null +++ b/server/src/services/scraper.ts @@ -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 { + 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(); + 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 { + 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 }, + }); +} diff --git a/server/test/fixtures/profil.html b/server/test/fixtures/profil.html new file mode 100644 index 0000000..56474c9 --- /dev/null +++ b/server/test/fixtures/profil.html @@ -0,0 +1,24 @@ + + + +Profil + +

ALLGEMEIN

+ +
NicknameTestspieler
SteamID6476561198000000999
+
+

Pakete

+ + + diff --git a/server/test/scraper.test.ts b/server/test/scraper.test.ts new file mode 100644 index 0000000..b85c049 --- /dev/null +++ b/server/test/scraper.test.ts @@ -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 { + 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); + }); +}); diff --git a/web/src/components/UnmatchedBox.tsx b/web/src/components/UnmatchedBox.tsx new file mode 100644 index 0000000..d83d961 --- /dev/null +++ b/web/src/components/UnmatchedBox.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+

Ungeklaerte Eintraege ({items.length})

+ + 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. + + + {error && setError(null)}>{error}} + +
    + {items.map((item) => ( +
  • +
    + {item.rawTitle} + {item.rawTag} +

    + {item.amount && `${item.amount} · `} + Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)} +

    +
    + {canResolve && ( +
    + {CHOICES.map((choice) => ( + + ))} +
    + )} +
  • + ))} +
+
+ ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index be93dfe..37c5953 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -43,6 +43,40 @@ export interface Player { forumName: string | null; notes: string | null; 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 { @@ -87,6 +121,7 @@ export interface PlayerDetail { characters: Character[]; ledger: LedgerEntry[]; ledgerTotal: number; + unmatched: UnmatchedPackage[]; } export interface AdminUser { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 7daf6b2..cad3b3e 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -4,7 +4,7 @@ import Modal from '../components/Modal'; import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; import { useSession } from '../lib/session'; -import type { PlayerSummary } from '../lib/types'; +import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types'; interface SearchResult { total: number; @@ -26,6 +26,9 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); + const [flagged, setFlagged] = useState({ total: 0, players: [] }); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); const load = useCallback(async (search: string, signal?: AbortSignal) => { setLoading(true); @@ -42,6 +45,37 @@ export default function Dashboard() { } }, []); + const loadFlagged = useCallback(async () => { + try { + setFlagged(await api('/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('/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. useEffect(() => { const controller = new AbortController(); @@ -56,11 +90,18 @@ export default function Dashboard() {

Spieler

- {can('EDITOR') && ( - - )} +
+ {can('ADMIN') && ( + + )} + {can('EDITOR') && ( + + )} +
{error && {error}} + {syncResult && setSyncResult(null)}>{syncResult}} + + {flagged.total > 0 && ( + + + {flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '} + {flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}: + {' '} + {flagged.players.map((entry, index) => ( + + {index > 0 && ', '} + + {entry.displayName} ({entry.count}) + + + ))} + + )}
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea /> - setSteamId(event.target.value)} /> + setSteamId(event.target.value)} + placeholder="76561198... oder STEAM_0:1:..." + /> +

+ Beide Schreibweisen sind moeglich, auch [U:1:...]. + Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt. +

+ {detail.player.gextensionUsage !== null && ( +

+ GExtension meldet {detail.player.gextensionUsage} Mal verbraucht + {detail.player.gextensionSyncedAt && + ` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`} + . Nur zum Abgleich — wird nicht automatisch verrechnet. +

+ )} + + void load()} + /> + {editor && (
+ {can('ADMIN') && detail.player.steamId && ( + + )}
)} diff --git a/web/src/styles.css b/web/src/styles.css index 58d6e47..2ed5fea 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -817,3 +817,19 @@ textarea { 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; +}