Compare commits
2
Commits
f775b0a594
...
c242e73c38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c242e73c38 | ||
|
|
bc86e2df26 |
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const schema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||||
|
|
||||||
|
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
||||||
|
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||||
|
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||||
|
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -1,10 +1,11 @@
|
|||||||
import { buildApp } from './app.js';
|
import { buildApp } from './app.js';
|
||||||
import { bootstrap } from './bootstrap.js';
|
import { bootstrap } from './bootstrap.js';
|
||||||
import { cleanupExpiredSessions } from './auth/session.js';
|
import { cleanupExpiredSessions } from './auth/session.js';
|
||||||
|
import { cleanupOldAuditEntries } from './services/audit.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
|
||||||
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
@@ -27,7 +28,13 @@ async function main(): Promise<void> {
|
|||||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||||
})
|
})
|
||||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
||||||
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
||||||
|
cleanupOldAuditEntries()
|
||||||
|
.then((count) => {
|
||||||
|
if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`);
|
||||||
|
})
|
||||||
|
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen'));
|
||||||
|
}, CLEANUP_INTERVAL_MS);
|
||||||
cleanup.unref();
|
cleanup.unref();
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Steam kennt dieselbe Person in mehreren Schreibweisen. Damit ein Spieler
|
||||||
|
* nicht doppelt angelegt wird, sobald eine Quelle das andere Format liefert,
|
||||||
|
* wird intern immer die 64-Bit-Form gespeichert.
|
||||||
|
*
|
||||||
|
* STEAM_0:1:12345678 (Legacy, was Garry's Mod anzeigt)
|
||||||
|
* [U:1:24691357] (SteamID3)
|
||||||
|
* 76561198024691357 (SteamID64, die kanonische Form)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Offset zwischen Account-ID und SteamID64.
|
||||||
|
const BASE = 76561197960265728n;
|
||||||
|
|
||||||
|
const LEGACY = /^STEAM_[0-5]:([01]):(\d+)$/i;
|
||||||
|
const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
||||||
|
const STEAM64 = /^\d{17}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert
|
||||||
|
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||||
|
*/
|
||||||
|
export function normalizeSteamId(input: string): string | null {
|
||||||
|
const value = input.trim();
|
||||||
|
if (value.length === 0) return null;
|
||||||
|
|
||||||
|
const legacy = LEGACY.exec(value);
|
||||||
|
if (legacy) {
|
||||||
|
const universe = BigInt(legacy[1] as string);
|
||||||
|
const accountNumber = BigInt(legacy[2] as string);
|
||||||
|
return (BASE + accountNumber * 2n + universe).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modern = STEAM3.exec(value);
|
||||||
|
if (modern) {
|
||||||
|
return (BASE + BigInt(modern[1] as string)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STEAM64.test(value)) {
|
||||||
|
const numeric = BigInt(value);
|
||||||
|
// Alles unterhalb des Offsets waere keine Nutzer-ID.
|
||||||
|
return numeric > BASE ? numeric.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */
|
||||||
|
export function toLegacySteamId(steamId64: string): string | null {
|
||||||
|
if (!STEAM64.test(steamId64)) return null;
|
||||||
|
|
||||||
|
const numeric = BigInt(steamId64);
|
||||||
|
if (numeric <= BASE) return null;
|
||||||
|
|
||||||
|
const offset = numeric - BASE;
|
||||||
|
return `STEAM_0:${offset % 2n}:${offset / 2n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeSteamId(input: string): boolean {
|
||||||
|
const value = input.trim();
|
||||||
|
return LEGACY.test(value) || STEAM3.test(value) || STEAM64.test(value);
|
||||||
|
}
|
||||||
@@ -96,11 +96,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const passwordOk = await verifyPassword(digest, body.password);
|
const passwordOk = await verifyPassword(digest, body.password);
|
||||||
|
|
||||||
if (!user || !passwordOk || !user.isActive) {
|
if (!user || !passwordOk || !user.isActive) {
|
||||||
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
|
// Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich
|
||||||
|
// gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort
|
||||||
|
// im Klartext im Audit-Log.
|
||||||
|
await recordAudit(
|
||||||
|
prisma,
|
||||||
|
{ actorId: null, actorName: user ? user.username : null, ip: request.ip },
|
||||||
|
{
|
||||||
action: 'auth.login_failed',
|
action: 'auth.login_failed',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user?.id ?? null,
|
targetId: user?.id ?? null,
|
||||||
}).catch(() => undefined);
|
details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' },
|
||||||
|
},
|
||||||
|
).catch(() => undefined);
|
||||||
throw unauthorized('Benutzername oder Passwort ist falsch');
|
throw unauthorized('Benutzername oder Passwort ist falsch');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
export interface ActionContext {
|
export interface ActionContext {
|
||||||
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
|
// Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||||
|
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
||||||
actorId: number | null;
|
actorId: number | null;
|
||||||
actorName: string;
|
actorName: string | null;
|
||||||
ip?: string | undefined;
|
ip?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export async function recordAudit(
|
|||||||
await client.auditLog.create({
|
await client.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
actorUserId: ctx.actorId,
|
actorUserId: ctx.actorId,
|
||||||
actorName: ctx.actorName,
|
actorName: ctx.actorName ?? null,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
targetType: entry.targetType,
|
targetType: entry.targetType,
|
||||||
targetId: entry.targetId == null ? null : String(entry.targetId),
|
targetId: entry.targetId == null ? null : String(entry.targetId),
|
||||||
@@ -34,3 +36,16 @@ export async function recordAudit(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die
|
||||||
|
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer
|
||||||
|
* bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll.
|
||||||
|
*/
|
||||||
|
export async function cleanupOldAuditEntries(): Promise<number> {
|
||||||
|
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - env.AUDIT_RETENTION_DAYS * 24 * 3600 * 1000);
|
||||||
|
const result = await prisma.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -163,6 +164,45 @@ function parseDate(value: string | undefined): Date | null {
|
|||||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich
|
||||||
|
* zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse,
|
||||||
|
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft,
|
||||||
|
* sobald ein Import abgeschlossen oder verworfen ist.
|
||||||
|
*/
|
||||||
|
async function pruneRawRows(
|
||||||
|
client: Pick<typeof prisma, 'importRow'>,
|
||||||
|
importId: number,
|
||||||
|
mapping: ColumnMapping | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const keep = new Set(
|
||||||
|
[mapping?.playerKey, mapping?.playerName, mapping?.product, mapping?.orderId, mapping?.purchasedAt].filter(
|
||||||
|
(column): column is string => Boolean(column),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await client.importRow.findMany({
|
||||||
|
where: { importId },
|
||||||
|
select: { id: true, raw: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row.raw as Record<string, string> | null;
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const reduced: Record<string, string> = {};
|
||||||
|
for (const column of keep) {
|
||||||
|
const value = raw[column];
|
||||||
|
if (value !== undefined) reduced[column] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.importRow.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { raw: reduced as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
||||||
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
||||||
@@ -189,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({
|
||||||
@@ -237,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) {
|
||||||
@@ -343,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);
|
||||||
@@ -402,6 +451,9 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
return { booked, createdPlayers: createdPlayers.size, import: updated };
|
return { booked, createdPlayers: createdPlayers.size, import: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht.
|
||||||
|
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +469,9 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
|
|||||||
data: { status: ImportStatus.DISCARDED },
|
data: { status: ImportStatus.DISCARDED },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ein verworfener Import braucht die Rohdaten erst recht nicht mehr.
|
||||||
|
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
|
||||||
|
|
||||||
await recordAudit(prisma, ctx, {
|
await recordAudit(prisma, ctx, {
|
||||||
action: 'import.discard',
|
action: 'import.discard',
|
||||||
targetType: 'import',
|
targetType: 'import',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -132,6 +168,12 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den
|
||||||
|
* Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt,
|
||||||
|
* damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt.
|
||||||
|
* Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||||
|
*/
|
||||||
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||||
const existing = await prisma.player.findUnique({
|
const existing = await prisma.player.findUnique({
|
||||||
where: { id: playerId },
|
where: { id: playerId },
|
||||||
@@ -140,15 +182,37 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
|||||||
if (!existing) throw notFound('Spieler');
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Rohzeilen der Importe verlieren ihren Personenbezug.
|
||||||
|
await tx.importRow.updateMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ resolvedPlayerId: playerId },
|
||||||
|
...(existing.steamId ? [{ playerKey: existing.steamId }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
playerKey: null,
|
||||||
|
playerName: null,
|
||||||
|
resolvedPlayerId: null,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||||
|
await tx.auditLog.updateMany({
|
||||||
|
where: { targetType: 'player', targetId: String(playerId) },
|
||||||
|
data: { details: { redacted: true } },
|
||||||
|
});
|
||||||
|
|
||||||
await tx.player.delete({ where: { id: playerId } });
|
await tx.player.delete({ where: { id: playerId } });
|
||||||
|
|
||||||
await recordAudit(tx, ctx, {
|
await recordAudit(tx, ctx, {
|
||||||
action: 'player.delete',
|
action: 'player.delete',
|
||||||
targetType: 'player',
|
targetType: 'player',
|
||||||
targetId: playerId,
|
targetId: playerId,
|
||||||
|
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
|
||||||
|
// nicht die geloeschten Daten konservieren.
|
||||||
details: {
|
details: {
|
||||||
displayName: existing.displayName,
|
|
||||||
steamId: existing.steamId,
|
|
||||||
removedCharacters: existing._count.characters,
|
removedCharacters: existing._count.characters,
|
||||||
removedLedgerRows: existing._count.ledger,
|
removedLedgerRows: existing._count.ledger,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+24
@@ -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>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Imports = typeof import('../src/services/imports.js');
|
||||||
|
type Players = typeof import('../src/services/players.js');
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Audit = typeof import('../src/services/audit.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let imports: Imports;
|
||||||
|
let players: Players;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let audit: Audit;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht.
|
||||||
|
const CSV = [
|
||||||
|
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||||
|
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const MAPPING = {
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
createMissingPlayers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54333);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
imports = await import('../src/services/imports.js');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
audit = await import('../src/services/audit.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
|
||||||
|
await db.prisma.productMapping.create({
|
||||||
|
data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Datenminimierung beim Import', () => {
|
||||||
|
it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => {
|
||||||
|
const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const rawBefore = before.raw as Record<string, string>;
|
||||||
|
assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da');
|
||||||
|
|
||||||
|
await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const rawAfter = after.raw as Record<string, string>;
|
||||||
|
|
||||||
|
assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg');
|
||||||
|
assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg');
|
||||||
|
assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg');
|
||||||
|
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raeumt auch einen verworfenen Import auf', async () => {
|
||||||
|
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
await imports.discardImport(importId, ctx);
|
||||||
|
|
||||||
|
const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const raw = row.raw as Record<string, string>;
|
||||||
|
assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fehlgeschlagene Anmeldung', () => {
|
||||||
|
it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => {
|
||||||
|
// Genau der Fall: jemand tippt sein Passwort ins Namensfeld.
|
||||||
|
const versehen = 'MeinGeheimesPasswort123';
|
||||||
|
|
||||||
|
await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, {
|
||||||
|
action: 'auth.login_failed',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: null,
|
||||||
|
details: { reason: 'unknown_account' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = await db.prisma.auditLog.findMany({
|
||||||
|
where: { action: 'auth.login_failed' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
assert.notEqual(entry.actorName, versehen);
|
||||||
|
assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loeschung eines Spielers', () => {
|
||||||
|
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
||||||
|
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
||||||
|
const csv = [
|
||||||
|
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||||
|
'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop3.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
const player = await db.prisma.player.findUniqueOrThrow({
|
||||||
|
where: { steamId: '76561198000000501' },
|
||||||
|
});
|
||||||
|
await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx);
|
||||||
|
|
||||||
|
await players.deletePlayer(player.id, ctx);
|
||||||
|
|
||||||
|
const gone = await db.prisma.player.findUnique({ where: { id: player.id } });
|
||||||
|
assert.equal(gone, null, 'der Spieler ist weg');
|
||||||
|
|
||||||
|
const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } });
|
||||||
|
assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg');
|
||||||
|
|
||||||
|
const rows = await db.prisma.importRow.findMany({ where: { importId } });
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile');
|
||||||
|
assert.equal(row.resolvedPlayerId, null);
|
||||||
|
assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditEntries = await db.prisma.auditLog.findMany({
|
||||||
|
where: { targetType: 'player', targetId: String(player.id) },
|
||||||
|
});
|
||||||
|
assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar');
|
||||||
|
for (const entry of auditEntries) {
|
||||||
|
const details = JSON.stringify(entry.details ?? {});
|
||||||
|
assert.ok(
|
||||||
|
!details.includes('76561198000000501'),
|
||||||
|
`SteamID steckt noch in ${entry.action}: ${details}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aufbewahrung des Audit-Logs', () => {
|
||||||
|
it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => {
|
||||||
|
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
const veraltet = await db.prisma.auditLog.create({
|
||||||
|
data: { action: 'test.alt', targetType: 'test', createdAt: alt },
|
||||||
|
});
|
||||||
|
const frisch = await db.prisma.auditLog.create({
|
||||||
|
data: { action: 'test.frisch', targetType: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await audit.cleanupOldAuditEntries();
|
||||||
|
assert.ok(removed >= 1);
|
||||||
|
|
||||||
|
assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null);
|
||||||
|
assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,12 +90,19 @@ export default function Dashboard() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<h1>Spieler</h1>
|
<h1>Spieler</h1>
|
||||||
|
<div className="head-actions">
|
||||||
|
{can('ADMIN') && (
|
||||||
|
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
|
||||||
|
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{can('EDITOR') && (
|
{can('EDITOR') && (
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
Spieler anlegen
|
Spieler anlegen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className="search"
|
className="search"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user