Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import { ImportStatus, type Prisma } from '@prisma/client';
|
|
import { prisma } from '../db.js';
|
|
import { env } from '../env.js';
|
|
import { badRequest, conflict } from '../errors.js';
|
|
import {
|
|
classifyPackage,
|
|
fetchProfile,
|
|
resetRequestPace,
|
|
type ScrapedProfile,
|
|
} from '../lib/gextension.js';
|
|
import { PLANS } from './imports.js';
|
|
import { recordAudit, type ActionContext } from './audit.js';
|
|
|
|
/**
|
|
* Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je
|
|
* Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine
|
|
* älteren Käufe unter der alten Kennung stehen.
|
|
*/
|
|
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
|
|
const [players, aliases] = await Promise.all([
|
|
prisma.player.findMany({
|
|
where: { steamId: { not: null } },
|
|
select: { id: true, steamId: true },
|
|
orderBy: { id: 'asc' },
|
|
}),
|
|
prisma.playerAlias.findMany({
|
|
where: { type: 'STEAM_ID' },
|
|
select: { playerId: true, value: true },
|
|
}),
|
|
]);
|
|
|
|
const alle = [
|
|
...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })),
|
|
...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })),
|
|
];
|
|
|
|
// Dieselbe Kennung nicht zweimal abrufen.
|
|
const gesehen = new Set<string>();
|
|
return alle.filter((eintrag) => {
|
|
if (gesehen.has(eintrag.steamId)) return false;
|
|
gesehen.add(eintrag.steamId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
|
|
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
|
|
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
|
|
*/
|
|
export async function steamIdsOf(playerId: number): Promise<string[]> {
|
|
const [player, aliases] = await Promise.all([
|
|
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
|
|
prisma.playerAlias.findMany({
|
|
where: { playerId, type: 'STEAM_ID' },
|
|
select: { value: true },
|
|
orderBy: { id: 'asc' },
|
|
}),
|
|
]);
|
|
|
|
const alle = [
|
|
...(player?.steamId ? [player.steamId] : []),
|
|
...aliases.map((alias) => alias.value),
|
|
];
|
|
return [...new Set(alle)];
|
|
}
|
|
|
|
export interface ScrapeSummary {
|
|
importId: number | null;
|
|
scannedPlayers: number;
|
|
failedPlayers: { steamId: string; reason: string }[];
|
|
newRows: number;
|
|
duplicates: number;
|
|
/// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen.
|
|
awaitingConfirmation: number;
|
|
unclear: number;
|
|
usageCounters: number;
|
|
}
|
|
|
|
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
|
|
// in die Quere kommen.
|
|
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
|
|
|
export interface ScrapeState {
|
|
running: boolean;
|
|
startedAt: string | null;
|
|
finishedAt: string | null;
|
|
/// Wessen Profil gerade gelesen wird.
|
|
currentPlayer: string | null;
|
|
playersRead: number;
|
|
totalPlayers: number;
|
|
log: string[];
|
|
summary: ScrapeSummary | null;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* Ein Abgleich über alle Spieler geht über dutzende Profile, zwischen denen
|
|
* bewusst gewartet wird - das überlebt keine HTTP-Anfrage. Deshalb läuft er
|
|
* im Hintergrund, und die Oberfläche fragt den Stand ab.
|
|
*/
|
|
const scrapeState: ScrapeState = {
|
|
running: false,
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
currentPlayer: null,
|
|
playersRead: 0,
|
|
totalPlayers: 0,
|
|
log: [],
|
|
summary: null,
|
|
error: null,
|
|
};
|
|
|
|
const MAX_LOG_LINES = 200;
|
|
|
|
function protokoll(zeile: string): void {
|
|
scrapeState.log.push(zeile);
|
|
if (scrapeState.log.length > MAX_LOG_LINES) scrapeState.log.shift();
|
|
}
|
|
|
|
export function getScrapeState(): ScrapeState {
|
|
return { ...scrapeState };
|
|
}
|
|
|
|
/**
|
|
* Stößt einen Abgleich an und kehrt sofort zurück.
|
|
*
|
|
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
|
|
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scrapePlayers selbst.
|
|
* Zwei Läufe nebeneinander würden dieselben Profile doppelt abrufen und den
|
|
* fremden Server ohne Not belasten.
|
|
*/
|
|
export function startScrape(
|
|
targets: { playerId: number; steamId: string }[],
|
|
ctx: ActionContext,
|
|
): ScrapeState {
|
|
if (scrapeState.running) {
|
|
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
|
|
}
|
|
if (targets.length === 0) {
|
|
throw badRequest('NO_TARGETS', 'Es gibt niemanden abzugleichen');
|
|
}
|
|
|
|
void scrapePlayers(targets, ctx).catch(() => {
|
|
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
|
|
});
|
|
|
|
return getScrapeState();
|
|
}
|
|
|
|
/**
|
|
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
|
|
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
|
|
* automatisch gebucht.
|
|
*/
|
|
export async function scrapePlayers(
|
|
targets: { playerId: number; steamId: string }[],
|
|
ctx: ActionContext,
|
|
): Promise<ScrapeSummary> {
|
|
if (scrapeState.running) {
|
|
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
|
|
}
|
|
|
|
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
|
|
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
|
|
scrapeState.running = true;
|
|
scrapeState.startedAt = new Date().toISOString();
|
|
scrapeState.finishedAt = null;
|
|
scrapeState.currentPlayer = null;
|
|
scrapeState.playersRead = 0;
|
|
scrapeState.totalPlayers = targets.length;
|
|
scrapeState.log = [];
|
|
scrapeState.summary = null;
|
|
scrapeState.error = null;
|
|
|
|
try {
|
|
return await scrapeIntern(targets, ctx);
|
|
} catch (error) {
|
|
scrapeState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
|
|
throw error;
|
|
} finally {
|
|
scrapeState.running = false;
|
|
scrapeState.currentPlayer = null;
|
|
scrapeState.finishedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
|
|
async function scrapeIntern(
|
|
targets: { playerId: number; steamId: string }[],
|
|
ctx: ActionContext,
|
|
): Promise<ScrapeSummary> {
|
|
const summary: ScrapeSummary = {
|
|
importId: null,
|
|
scannedPlayers: 0,
|
|
failedPlayers: [],
|
|
newRows: 0,
|
|
duplicates: 0,
|
|
awaitingConfirmation: 0,
|
|
unclear: 0,
|
|
usageCounters: 0,
|
|
};
|
|
|
|
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
|
|
let lineNo = 0;
|
|
|
|
// Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst,
|
|
// wenn GExtension es verlangt.
|
|
resetRequestPace();
|
|
|
|
// Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne
|
|
// diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren
|
|
// Import, und beim Buchen kollidieren sie miteinander.
|
|
const pending = await prisma.importRow.findMany({
|
|
where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } },
|
|
select: { externalId: true },
|
|
});
|
|
const alreadyPending = new Set(pending.map((row) => row.externalId as string));
|
|
|
|
// Die Namen einmal holen, damit das Protokoll lesbar ist statt SteamIDs
|
|
// aneinanderzureihen.
|
|
const namen = new Map(
|
|
(
|
|
await prisma.player.findMany({
|
|
where: { id: { in: [...new Set(targets.map((t) => t.playerId))] } },
|
|
select: { id: true, displayName: true },
|
|
})
|
|
).map((p) => [p.id, p.displayName]),
|
|
);
|
|
|
|
for (const target of targets) {
|
|
const name = namen.get(target.playerId) ?? target.steamId;
|
|
scrapeState.currentPlayer = name;
|
|
|
|
let profile: ScrapedProfile;
|
|
try {
|
|
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
|
|
summary.scannedPlayers += 1;
|
|
} catch (error) {
|
|
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
summary.failedPlayers.push({ steamId: target.steamId, reason: grund });
|
|
scrapeState.playersRead += 1;
|
|
protokoll(`${name}: nicht abrufbar (${grund})`);
|
|
continue;
|
|
}
|
|
|
|
for (const pkg of profile.packages) {
|
|
const classification = classifyPackage(pkg);
|
|
const externalId = externalIdFor(pkg.packageBoughtId);
|
|
|
|
if (classification.kind === 'usage_counter') {
|
|
await prisma.player.update({
|
|
where: { id: target.playerId },
|
|
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
|
|
});
|
|
summary.usageCounters += 1;
|
|
continue;
|
|
}
|
|
|
|
if (classification.kind === 'unclear') {
|
|
await prisma.unmatchedPackage.upsert({
|
|
where: { externalId },
|
|
create: {
|
|
externalId,
|
|
playerId: target.playerId,
|
|
steamId: profile.steamId64,
|
|
rawTitle: pkg.packageName,
|
|
rawTag: pkg.serverBundle,
|
|
amount: pkg.amount || null,
|
|
purchasedAt: pkg.purchasedAt,
|
|
},
|
|
update: {},
|
|
});
|
|
summary.unclear += 1;
|
|
continue;
|
|
}
|
|
|
|
if (classification.kind === 'ignore') continue;
|
|
|
|
const alreadyBooked = await prisma.importedOrder.findUnique({
|
|
where: { externalId },
|
|
select: { id: true },
|
|
});
|
|
if (alreadyBooked) {
|
|
summary.duplicates += 1;
|
|
continue;
|
|
}
|
|
|
|
if (alreadyPending.has(externalId)) {
|
|
summary.awaitingConfirmation += 1;
|
|
continue;
|
|
}
|
|
alreadyPending.add(externalId);
|
|
|
|
lineNo += 1;
|
|
rows.push({
|
|
lineNo,
|
|
externalId,
|
|
playerKey: profile.steamId64,
|
|
playerName: profile.nickname,
|
|
productName: pkg.packageName,
|
|
purchasedAt: pkg.purchasedAt,
|
|
target: classification.target,
|
|
resolvedPlayerId: target.playerId,
|
|
plan: PLANS.BOOK,
|
|
raw: {
|
|
package_name: pkg.packageName,
|
|
serverbundle_name: pkg.serverBundle,
|
|
amount: pkg.amount,
|
|
gateway: pkg.gateway,
|
|
} as Prisma.InputJsonValue,
|
|
});
|
|
summary.newRows += 1;
|
|
}
|
|
|
|
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
|
|
await prisma.player.update({
|
|
where: { id: target.playerId },
|
|
data: { gextensionSyncedAt: new Date() },
|
|
});
|
|
|
|
scrapeState.playersRead += 1;
|
|
protokoll(
|
|
`${name}: ${profile.packages.length} ${
|
|
profile.packages.length === 1 ? 'Eintrag' : 'Einträge'
|
|
} gelesen`,
|
|
);
|
|
}
|
|
|
|
scrapeState.currentPlayer = null;
|
|
protokoll(
|
|
summary.newRows > 0
|
|
? `${summary.newRows} neue Pakete warten auf die Bestätigung`
|
|
: 'Keine neuen Pakete',
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
scrapeState.summary = summary;
|
|
|
|
await recordAudit(prisma, ctx, {
|
|
action: 'scrape.run',
|
|
targetType: 'import',
|
|
targetId: summary.importId,
|
|
details: {
|
|
scannedPlayers: summary.scannedPlayers,
|
|
newRows: summary.newRows,
|
|
duplicates: summary.duplicates,
|
|
awaitingConfirmation: summary.awaitingConfirmation,
|
|
unclear: summary.unclear,
|
|
failed: summary.failedPlayers.length,
|
|
},
|
|
});
|
|
|
|
return summary;
|
|
}
|
|
|
|
// --- Handprüfung der unklaren Einträge ---
|
|
|
|
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
|
|
|
export async function listUnmatched(onlyOpen = true) {
|
|
return prisma.unmatchedPackage.findMany({
|
|
where: onlyOpen ? { resolved: false } : {},
|
|
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
|
|
include: { player: { select: { id: true, displayName: true } } },
|
|
});
|
|
}
|
|
|
|
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
|
|
export async function unmatchedOverview() {
|
|
const open = await prisma.unmatchedPackage.findMany({
|
|
where: { resolved: false },
|
|
select: { playerId: true, player: { select: { displayName: true } } },
|
|
});
|
|
|
|
const byPlayer = new Map<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()] };
|
|
}
|
|
|
|
/**
|
|
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
|
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
|
* ein Redesign mitbringt.
|
|
*/
|
|
export async function resolveUnmatched(
|
|
id: number,
|
|
resolution: Resolution,
|
|
ctx: ActionContext,
|
|
): Promise<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 },
|
|
});
|
|
}
|