import { ImportStatus, type Prisma } from '@prisma/client'; import { prisma } from '../db.js'; import { env } from '../env.js'; import { badRequest } from '../errors.js'; import { classifyPackage, fetchProfile, resetRequestPace, type ScrapedProfile, } from '../lib/gextension.js'; import { PLANS } from './imports.js'; import { recordAudit, type ActionContext } from './audit.js'; /** * Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je * Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine * älteren Käufe unter der alten Kennung stehen. */ export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> { const [players, aliases] = await Promise.all([ prisma.player.findMany({ where: { steamId: { not: null } }, select: { id: true, steamId: true }, orderBy: { id: 'asc' }, }), prisma.playerAlias.findMany({ where: { type: 'STEAM_ID' }, select: { playerId: true, value: true }, }), ]); const alle = [ ...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })), ...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })), ]; // Dieselbe Kennung nicht zweimal abrufen. const gesehen = new Set(); return alle.filter((eintrag) => { if (gesehen.has(eintrag.steamId)) return false; gesehen.add(eintrag.steamId); return true; }); } /** * Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle * früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe * unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar. */ export async function steamIdsOf(playerId: number): Promise { const [player, aliases] = await Promise.all([ prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }), prisma.playerAlias.findMany({ where: { playerId, type: 'STEAM_ID' }, select: { value: true }, orderBy: { id: 'asc' }, }), ]); const alle = [ ...(player?.steamId ? [player.steamId] : []), ...aliases.map((alias) => alias.value), ]; return [...new Set(alle)]; } export interface ScrapeSummary { importId: number | null; scannedPlayers: number; failedPlayers: { steamId: string; reason: string }[]; newRows: number; duplicates: number; /// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen. awaitingConfirmation: 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 übergebenen Spieler und legt daraus einen Import an, * der wie ein Datei-Import in der Vorschau bestätigt 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, awaitingConfirmation: 0, unclear: 0, usageCounters: 0, }; const rows: Prisma.ImportRowCreateManyImportInput[] = []; let lineNo = 0; // Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst, // wenn GExtension es verlangt. resetRequestPace(); // Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne // diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren // Import, und beim Buchen kollidieren sie miteinander. const pending = await prisma.importRow.findMany({ where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } }, select: { externalId: true }, }); const alreadyPending = new Set(pending.map((row) => row.externalId as string)); for (const target of targets) { let profile: ScrapedProfile; try { profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL }); 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; } if (alreadyPending.has(externalId)) { summary.awaitingConfirmation += 1; continue; } alreadyPending.add(externalId); 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, awaitingConfirmation: summary.awaitingConfirmation, unclear: summary.unclear, failed: summary.failedPlayers.length, }, }); return summary; } // --- Handprüfung der unklaren Einträge --- 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 Einträgen, für 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()] }; } /** * Löst 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 }, }); }