Compare commits

...
2 Commits
Author SHA1 Message Date
Harlekin c242e73c38 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.
2026-09-18 15:42:31 +02:00
Harlekin bc86e2df26 Datenminimierung: Rohdaten, Protokoll und Loeschung
Drei Befunde aus einer Durchsicht der gespeicherten personenbezogenen Daten:

Der Import legte bisher jede Spalte der hochgeladenen Datei dauerhaft ab,
auch Mailadressen oder Rechnungsdaten, die nie zugeordnet wurden. Nach dem
Buchen oder Verwerfen bleiben jetzt nur noch die gemappten Spalten stehen.

Ein fehlgeschlagener Login protokollierte den eingetippten Benutzernamen.
Wer sein Passwort ins Namensfeld tippt, hatte es damit im Klartext im
Audit-Log. Der Name wird nur noch gespeichert, wenn das Konto existiert.

Das Loeschen eines Spielers liess Spuren in den Import-Zeilen und in den
Details frueherer Audit-Eintraege zurueck. Beides wird jetzt mit abgeraeumt;
die Vorgaenge bleiben als Eintrag bestehen, nur ohne Inhalt.

Dazu eine Aufbewahrungsfrist fuer das Audit-Log (Standard 12 Monate, ueber
AUDIT_RETENTION_DAYS einstellbar). Die Guthaben-Historie ist davon nicht
betroffen - sie bleibt die Quelle der Wahrheit fuer bezahlte Ansprueche.
2026-09-18 15:22:02 +02:00
22 changed files with 1416 additions and 28 deletions
@@ -62,10 +62,31 @@ CREATE TABLE "Player" (
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"gextensionUsage" INTEGER,
"gextensionSyncedAt" TIMESTAMP(3),
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UnmatchedPackage" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"playerId" INTEGER,
"steamId" TEXT NOT NULL,
"rawTitle" TEXT NOT NULL,
"rawTag" TEXT NOT NULL,
"amount" TEXT,
"purchasedAt" TIMESTAMP(3),
"scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolved" BOOLEAN NOT NULL DEFAULT false,
"resolvedAs" TEXT,
"resolvedById" INTEGER,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CreditLedger" (
"id" SERIAL NOT NULL,
@@ -195,6 +216,15 @@ CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
-- CreateIndex
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId");
-- CreateIndex
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
@@ -246,6 +276,9 @@ CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+31
View File
@@ -96,13 +96,44 @@ model Player {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich,
/// wird nie automatisch mit dem Ledger verrechnet.
gextensionUsage Int?
gextensionSyncedAt DateTime?
ledger CreditLedger[]
characters Character[]
orders ImportedOrder[]
unmatched UnmatchedPackage[]
@@index([displayName])
}
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht,
/// sondern im CC-Manager von Hand entschieden.
model UnmatchedPackage {
id Int @id @default(autoincrement())
externalId String @unique
playerId Int?
player Player? @relation(fields: [playerId], references: [id], onDelete: Cascade)
steamId String
rawTitle String
rawTag String
amount String?
purchasedAt DateTime?
scrapedAt DateTime @default(now())
resolved Boolean @default(false)
/// CC_PACKAGE | REDESIGN_PACKAGE | REDESIGN_KULANZ | IGNORE
resolvedAs String?
resolvedById Int?
resolvedAt DateTime?
@@index([resolved])
@@index([playerId])
}
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
/// Der offene Stand eines Topfes ist die Summe der amounts je kind.
model CreditLedger {
+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 {
+4
View File
@@ -27,6 +27,10 @@ const schema = z.object({
.optional()
.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'),
});
+9 -2
View File
@@ -1,10 +1,11 @@
import { buildApp } from './app.js';
import { bootstrap } from './bootstrap.js';
import { cleanupExpiredSessions } from './auth/session.js';
import { cleanupOldAuditEntries } from './services/audit.js';
import { prisma } from './db.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> {
const app = await buildApp();
@@ -27,7 +28,13 @@ async function main(): Promise<void> {
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
})
.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();
const shutdown = async (signal: string): Promise<void> => {
+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);
}
}
+61
View File
@@ -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);
}
+10 -2
View File
@@ -96,11 +96,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const passwordOk = await verifyPassword(digest, body.password);
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',
targetType: 'user',
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');
}
+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 };
});
}
+18 -3
View File
@@ -1,10 +1,12 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
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;
actorName: string;
actorName: string | null;
ip?: string | undefined;
}
@@ -25,7 +27,7 @@ export async function recordAudit(
await client.auditLog.create({
data: {
actorUserId: ctx.actorId,
actorName: ctx.actorName,
actorName: ctx.actorName ?? null,
action: entry.action,
targetType: entry.targetType,
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;
}
+58 -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',
@@ -163,6 +164,45 @@ function parseDate(value: string | undefined): Date | null {
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
* 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>,
}));
// 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({
@@ -237,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) {
@@ -343,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);
@@ -402,6 +451,9 @@ export async function commitImport(importId: number, ctx: ActionContext) {
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;
}
@@ -417,6 +469,9 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
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, {
action: 'import.discard',
targetType: 'import',
+71 -7
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) => {
@@ -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> {
const existing = await prisma.player.findUnique({
where: { id: playerId },
@@ -140,15 +182,37 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
if (!existing) throw notFound('Spieler');
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 recordAudit(tx, ctx, {
action: 'player.delete',
targetType: 'player',
targetId: playerId,
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
// nicht die geloeschten Daten konservieren.
details: {
displayName: existing.displayName,
steamId: existing.steamId,
removedCharacters: existing._count.characters,
removedLedgerRows: existing._count.ledger,
},
+256
View File
@@ -0,0 +1,256 @@
import { ImportStatus, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest } from '../errors.js';
import {
classifyPackage,
fetchProfile,
type ScrapedProfile,
} from '../lib/gextension.js';
import { PLANS } from './imports.js';
import { recordAudit, type ActionContext } from './audit.js';
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
const players = await prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
});
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
unclear: number;
usageCounters: number;
}
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
// in die Quere kommen.
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
/**
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
* automatisch gebucht.
*/
export async function scrapePlayers(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
const summary: ScrapeSummary = {
importId: null,
scannedPlayers: 0,
failedPlayers: [],
newRows: 0,
duplicates: 0,
unclear: 0,
usageCounters: 0,
};
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
let lineNo = 0;
for (const target of targets) {
let profile: ScrapedProfile;
try {
profile = await fetchProfile(target.steamId);
summary.scannedPlayers += 1;
} catch (error) {
summary.failedPlayers.push({
steamId: target.steamId,
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
});
continue;
}
for (const pkg of profile.packages) {
const classification = classifyPackage(pkg);
const externalId = externalIdFor(pkg.packageBoughtId);
if (classification.kind === 'usage_counter') {
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
});
summary.usageCounters += 1;
continue;
}
if (classification.kind === 'unclear') {
await prisma.unmatchedPackage.upsert({
where: { externalId },
create: {
externalId,
playerId: target.playerId,
steamId: profile.steamId64,
rawTitle: pkg.packageName,
rawTag: pkg.serverBundle,
amount: pkg.amount || null,
purchasedAt: pkg.purchasedAt,
},
update: {},
});
summary.unclear += 1;
continue;
}
if (classification.kind === 'ignore') continue;
const alreadyBooked = await prisma.importedOrder.findUnique({
where: { externalId },
select: { id: true },
});
if (alreadyBooked) {
summary.duplicates += 1;
continue;
}
lineNo += 1;
rows.push({
lineNo,
externalId,
playerKey: profile.steamId64,
playerName: profile.nickname,
productName: pkg.packageName,
purchasedAt: pkg.purchasedAt,
target: classification.target,
resolvedPlayerId: target.playerId,
plan: PLANS.BOOK,
raw: {
package_name: pkg.packageName,
serverbundle_name: pkg.serverBundle,
amount: pkg.amount,
gateway: pkg.gateway,
} as Prisma.InputJsonValue,
});
summary.newRows += 1;
}
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionSyncedAt: new Date() },
});
}
if (rows.length > 0) {
const created = await prisma.import.create({
data: {
filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`,
status: ImportStatus.PENDING,
rowCount: rows.length,
importedById: ctx.actorId,
mapping: { source: 'gextension' } as Prisma.InputJsonValue,
rows: { createMany: { data: rows } },
},
});
summary.importId = created.id;
}
await recordAudit(prisma, ctx, {
action: 'scrape.run',
targetType: 'import',
targetId: summary.importId,
details: {
scannedPlayers: summary.scannedPlayers,
newRows: summary.newRows,
duplicates: summary.duplicates,
unclear: summary.unclear,
failed: summary.failedPlayers.length,
},
});
return summary;
}
// --- Handpruefung der unklaren Eintraege ---
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
export async function listUnmatched(onlyOpen = true) {
return prisma.unmatchedPackage.findMany({
where: onlyOpen ? { resolved: false } : {},
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
include: { player: { select: { id: true, displayName: true } } },
});
}
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
export async function unmatchedOverview() {
const open = await prisma.unmatchedPackage.findMany({
where: { resolved: false },
select: { playerId: true, player: { select: { displayName: true } } },
});
const byPlayer = new Map<number, { playerId: number; displayName: string; count: number }>();
for (const entry of open) {
if (entry.playerId === null) continue;
const current = byPlayer.get(entry.playerId);
if (current) {
current.count += 1;
} else {
byPlayer.set(entry.playerId, {
playerId: entry.playerId,
displayName: entry.player?.displayName ?? 'Unbekannt',
count: 1,
});
}
}
return { total: open.length, players: [...byPlayer.values()] };
}
/**
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
* ein Redesign mitbringt.
*/
export async function resolveUnmatched(
id: number,
resolution: Resolution,
ctx: ActionContext,
): Promise<void> {
const entry = await prisma.unmatchedPackage.findUnique({ where: { id } });
if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden');
if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden');
if (resolution !== 'IGNORE') {
if (entry.playerId === null) {
throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet');
}
const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js');
const note = `GExtension: ${entry.rawTitle}`;
if (resolution === 'CC_PACKAGE') {
await bookCcPackage(entry.playerId, ctx, { note });
} else if (resolution === 'REDESIGN_PACKAGE') {
await bookRedesignPackage(entry.playerId, ctx, { note });
} else {
await bookKulanz(entry.playerId, ctx, { note });
}
}
await prisma.unmatchedPackage.update({
where: { id },
data: {
resolved: true,
resolvedAs: resolution,
resolvedById: ctx.actorId,
resolvedAt: new Date(),
},
});
await recordAudit(prisma, ctx, {
action: 'scrape.resolve',
targetType: 'unmatched_package',
targetId: id,
details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId },
});
}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<!-- Nachbau der Struktur einer GExtension-Profilseite mit erfundenen Daten.
Die echte Seite bettet die Kaeufe genauso als donations_data-Zuweisungen ein. -->
<html lang="de">
<head><title>Profil</title></head>
<body>
<div class="box"><h3>ALLGEMEIN</h3>
<table><tr><td>Nickname</td><td>Testspieler</td></tr>
<tr><td>SteamID64</td><td>76561198000000999</td></tr></table>
</div>
<div class="box"><h3>Pakete</h3><div id="packages"></div></div>
<script>
var donations_data = [];
donations_data[9001] = {"id":"","package_bought_id":9001,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:54","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9002] = {"id":"","package_bought_id":9002,"amount":"15.00 EUR","package":10,"package_name":"Custom Character Redesign","steamid64_buyer":"","nick_buyer":"","date":"07.10.2025 10:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9003] = {"id":"","package_bought_id":9003,"amount":"0.00 EUR","package":11,"package_name":"Custom Charakter-Beantragung","steamid64_buyer":"","nick_buyer":"","date":"08.10.2025 11:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9004] = {"id":"","package_bought_id":9004,"amount":"0.00 EUR","package":70,"package_name":"FG-Tasse | Merch","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:53","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9005] = {"id":"","package_bought_id":9005,"amount":"49.67 EUR","package":80,"package_name":"Ablassbrief (01.04.2026)","steamid64_buyer":"","nick_buyer":"","date":"01.04.2026 09:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"Web","serverbundle":0,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9006] = {"id":"","package_bought_id":9006,"amount":"0.00 EUR","package":12,"package_name":"CC-Paket oder Redesign genutzt | 1 Mal","steamid64_buyer":"","nick_buyer":"","date":"09.10.2025 12:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9007] = {"id":"","package_bought_id":9007,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"10.10.2025 13:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":1,"status":0};
donations_data[9008] = {"id":"","package_bought_id":9008,"amount":"0.00 EUR","package":99,"package_name":"CC Sonderaktion Winter","steamid64_buyer":"","nick_buyer":"","date":"11.10.2025 14:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
</script>
</body>
</html>
+183
View File
@@ -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 } }));
});
});
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { classifyPackage, parseProfile, type ScrapedPackage } from '../src/lib/gextension.js';
import { looksLikeSteamId, normalizeSteamId, toLegacySteamId } from '../src/lib/steamid.js';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = readFileSync(join(here, 'fixtures', 'profil.html'), 'utf8');
function pkg(overrides: Partial<ScrapedPackage>): ScrapedPackage {
return {
packageBoughtId: 1,
packageName: 'Custom Character',
serverBundle: 'SCP-RP',
purchasedAt: null,
amount: '0.00 EUR',
gateway: '',
revoked: false,
...overrides,
};
}
describe('Profil auslesen', () => {
const profile = parseProfile(FIXTURE, '76561198000000999');
it('findet alle Pakete', () => {
assert.equal(profile.packages.length, 8);
});
it('liest Titel, Bereich und Kaufdatum', () => {
const cc = profile.packages.find((p) => p.packageBoughtId === 9001);
assert.ok(cc);
assert.equal(cc.packageName, 'Custom Character');
assert.equal(cc.serverBundle, 'SCP-RP');
assert.equal(cc.purchasedAt?.getFullYear(), 2025);
assert.equal(cc.purchasedAt?.getMonth(), 9, 'Oktober');
assert.equal(cc.purchasedAt?.getDate(), 6);
});
it('erkennt stornierte Kaeufe', () => {
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
assert.equal(revoked?.revoked, true);
});
it('liest den Nicknamen aus der Allgemein-Box', () => {
assert.equal(profile.nickname, 'Testspieler');
});
it('kommt mit kaputtem HTML klar, ohne alles zu verlieren', () => {
const broken = FIXTURE.replace(
'donations_data[9004] = {"id"',
'donations_data[9004] = {kaputt"id"',
);
const result = parseProfile(broken, '76561198000000999');
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg');
});
});
describe('Pakete zuordnen', () => {
it('erkennt ein CC-Paket', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('erkennt die Schreibweise mit K', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Charakter-Beantragung' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
kind: 'package',
target: 'REDESIGN_PACKAGE',
});
});
it('liest den Verbrauchszaehler samt Anzahl', () => {
const result = classifyPackage(
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
);
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
});
it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => {
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
});
it('ignoriert alles aus dem Web-Bereich', () => {
const result = classifyPackage(
pkg({ packageName: 'Ablassbrief (01.04.2026)', serverBundle: 'Web' }),
);
assert.equal(result.kind, 'ignore');
});
it('bucht ein storniertes Paket nicht', () => {
const result = classifyPackage(pkg({ packageName: 'Custom Character', revoked: true }));
assert.equal(result.kind, 'ignore');
});
it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => {
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
});
});
describe('SteamID-Schreibweisen', () => {
// Dieselbe (erfundene) Kennung in drei Formaten.
const legacy = 'STEAM_0:1:19867635';
const modern = '[U:1:39735271]';
const sixtyFour = '76561198000000999';
it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => {
assert.equal(normalizeSteamId(legacy), sixtyFour);
assert.equal(normalizeSteamId(modern), sixtyFour);
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
});
it('rechnet zurueck in die Legacy-Form', () => {
assert.equal(toLegacySteamId(sixtyFour), legacy);
});
it('beherrscht auch das ungerade Bit', () => {
const odd = 'STEAM_0:1:12345678';
const asNumber = normalizeSteamId(odd);
assert.ok(asNumber);
assert.equal(toLegacySteamId(asNumber), odd);
});
it('weist Unsinn ab', () => {
assert.equal(normalizeSteamId('Darkbrine44'), null);
assert.equal(normalizeSteamId(''), null);
assert.equal(normalizeSteamId('12345'), null);
assert.equal(looksLikeSteamId('Darkbrine44'), false);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import type { Resolution, UnmatchedPackage } from '../lib/types';
const CHOICES: { value: Resolution; label: string; hint: string }[] = [
{ value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' },
{ value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' },
{ value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' },
{ value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' },
];
/**
* Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden.
* Sie werden bewusst nie automatisch gebucht, sondern hier entschieden.
*/
export default function UnmatchedBox({
items,
canResolve,
onResolved,
}: {
items: UnmatchedPackage[];
canResolve: boolean;
onResolved: () => void;
}) {
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
if (items.length === 0) return null;
const resolve = async (id: number, resolution: Resolution) => {
setBusyId(id);
setError(null);
try {
await api(`/api/scrape/unmatched/${id}/resolve`, {
method: 'POST',
body: { resolution },
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden');
} finally {
setBusyId(null);
}
};
return (
<section className="block block-flag">
<h2>Ungeklaerte Eintraege ({items.length})</h2>
<Notice kind="warn">
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
sie hier einordnest.
</Notice>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
<ul className="cc-list">
{items.map((item) => (
<li key={item.id} className="cc-item">
<div>
<strong>{item.rawTitle}</strong>
<span className="badge">{item.rawTag}</span>
<p className="muted small">
{item.amount && `${item.amount} · `}
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
</p>
</div>
{canResolve && (
<div className="cc-actions">
{CHOICES.map((choice) => (
<button
key={choice.value}
type="button"
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
title={choice.hint}
disabled={busyId !== null}
onClick={() => void resolve(item.id, choice.value)}
>
{choice.label}
</button>
))}
</div>
)}
</li>
))}
</ul>
</section>
);
}
+35
View File
@@ -43,6 +43,40 @@ export interface Player {
forumName: string | null;
notes: string | null;
createdAt: string;
gextensionUsage: number | null;
gextensionSyncedAt: string | null;
}
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
export interface UnmatchedPackage {
id: number;
externalId: string;
playerId: number | null;
steamId: string;
rawTitle: string;
rawTag: string;
amount: string | null;
purchasedAt: string | null;
scrapedAt: string;
resolved: boolean;
resolvedAs: string | null;
player: { id: number; displayName: string } | null;
}
export interface UnmatchedOverview {
total: number;
players: { playerId: number; displayName: string; count: number }[];
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
unclear: number;
usageCounters: number;
}
export interface CharacterRef {
@@ -87,6 +121,7 @@ export interface PlayerDetail {
characters: Character[];
ledger: LedgerEntry[];
ledgerTotal: number;
unmatched: UnmatchedPackage[];
}
export interface AdminUser {
+70 -2
View File
@@ -4,7 +4,7 @@ import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import { useSession } from '../lib/session';
import type { PlayerSummary } from '../lib/types';
import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types';
interface SearchResult {
total: number;
@@ -26,6 +26,9 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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) => {
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.
useEffect(() => {
const controller = new AbortController();
@@ -56,12 +90,19 @@ export default function Dashboard() {
<div className="page">
<div className="page-head">
<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') && (
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
Spieler anlegen
</button>
)}
</div>
</div>
<input
className="search"
@@ -73,6 +114,24 @@ export default function Dashboard() {
/>
{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">
<table className="table">
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
/>
<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>
<input
+35
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -87,6 +88,21 @@ export default function PlayerDetail() {
<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 && (
<section className="action-bar">
<button
@@ -140,6 +156,25 @@ export default function PlayerDetail() {
>
Neuer CC
</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>
)}
+16
View File
@@ -817,3 +817,19 @@ textarea {
padding: 1.25rem 1rem 3rem;
}
}
/* --- GExtension-Abgleich --- */
.head-actions {
display: flex;
gap: 0.6rem;
flex-shrink: 0;
}
.block-flag {
border-color: var(--warn);
}
.usage-hint {
margin: -0.5rem 0 1.25rem;
}