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
+2
View File
@@ -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<FastifyInstance> {
await app.register(characterRoutes);
await app.register(importRoutes);
await app.register(adminRoutes);
await app.register(scraperRoutes);
if (existsSync(webRoot)) {
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
// 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 {
+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 { 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<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 = [
...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);
+41 -5
View File
@@ -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<void> {
@@ -79,7 +115,7 @@ async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<vo
}
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = input.steamId?.trim() || null;
const steamId = canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId);
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 } });
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) => {
+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 },
});
}