Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten
Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam. Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil dabei Kulanz entstehen kann. Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen. Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
This commit is contained in:
+11
-1
@@ -89,8 +89,18 @@ export function parseBoard(html: string): ForumThread[] {
|
||||
return threads;
|
||||
}
|
||||
|
||||
/** Höchste Seitenzahl der Blätterleiste, mindestens 1. */
|
||||
/**
|
||||
* Anzahl der Seiten eines Boards.
|
||||
*
|
||||
* WoltLab führt die Gesamtzahl in einem eigenen Element, nicht in den Links:
|
||||
* die Blätterleiste zeigt nur die Nachbarseiten. Wer allein die pageNo-Links
|
||||
* auswertet, hält ein Board mit 25 Seiten für zweiseitig.
|
||||
*/
|
||||
export function parsePageCount(html: string): number {
|
||||
const element = /<woltlab-core-pagination[^>]*\scount="(\d+)"/.exec(html);
|
||||
if (element) return Math.max(1, Number(element[1]));
|
||||
|
||||
// Ältere Fassungen ohne das Element: wenigstens die Links auswerten.
|
||||
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
|
||||
return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
|
||||
// Kulanz ist eine Entscheidung des Teams, kein erfasster Kauf - deshalb
|
||||
// bleibt sie Administratoren vorbehalten.
|
||||
app.post('/api/players/:id/credits/kulanz', { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(noteBody, request.body ?? {});
|
||||
return bookKulanz(id, contextOf(request), { note: body.note });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ForumBoard, ForumClassification } from '@prisma/client';
|
||||
import { requireAdmin, requireAuth } from '../auth/guard.js';
|
||||
import { requireAuth, requireEditor } from '../auth/guard.js';
|
||||
import {
|
||||
assignPlayer,
|
||||
classifyThread,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/forum/scan', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => {
|
||||
return scanForum(contextOf(request));
|
||||
});
|
||||
|
||||
@@ -40,13 +40,13 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { items: await listBoardChangeNotices() };
|
||||
});
|
||||
|
||||
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
await dismissBoardChangeNotice(id, contextOf(request));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/forum/threads/:id/player', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/forum/threads/:id/player', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({ playerId: z.number().int().positive() }),
|
||||
@@ -56,7 +56,7 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/forum/threads/:id/classify', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/forum/threads/:id/classify', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '../auth/guard.js';
|
||||
import { requireEditor } from '../auth/guard.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
applyMapping,
|
||||
@@ -24,7 +24,7 @@ const mappingBody = z.object({
|
||||
|
||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
||||
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
app.post('/api/imports', { preHandler: requireEditor }, async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
||||
|
||||
@@ -41,16 +41,16 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
|
||||
app.get('/api/imports', { preHandler: requireEditor }, async () => {
|
||||
return { items: await listImports() };
|
||||
});
|
||||
|
||||
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
|
||||
app.get('/api/imports/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
return getImport(id);
|
||||
});
|
||||
|
||||
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/imports/:id/mapping', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(mappingBody, request.body);
|
||||
const summary = await applyMapping(id, body, contextOf(request));
|
||||
@@ -58,12 +58,12 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { summary, import: record };
|
||||
});
|
||||
|
||||
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/imports/:id/commit', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
return commitImport(id, contextOf(request));
|
||||
});
|
||||
|
||||
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
await discardImport(id, contextOf(request));
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { CreditKind } from '@prisma/client';
|
||||
import { AliasType } from '@prisma/client';
|
||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||
import { listLedger } from '../services/credits.js';
|
||||
import {
|
||||
addAlias,
|
||||
createPlayer,
|
||||
deletePlayer,
|
||||
getPlayerDetail,
|
||||
removeAlias,
|
||||
searchPlayers,
|
||||
updatePlayer,
|
||||
} from '../services/players.js';
|
||||
@@ -63,6 +66,29 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
return updatePlayer(id, body, contextOf(request));
|
||||
});
|
||||
|
||||
// Frühere Steam- und Forumskonten eines Spielers.
|
||||
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
type: z.nativeEnum(AliasType),
|
||||
value: z.string().trim().min(1).max(120),
|
||||
note: z.string().trim().max(200).optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
|
||||
const created = await addAlias(id, body.type, body.value, body.note, contextOf(request));
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.delete('/api/players/aliases/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
await removeAlias(id, contextOf(request));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
await deletePlayer(id, contextOf(request));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin, requireAuth } from '../auth/guard.js';
|
||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||
import { notFound } from '../errors.js';
|
||||
import { prisma } from '../db.js';
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||
* Profile auf Vorrat einsammeln.
|
||||
*/
|
||||
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/scrape/run', { preHandler: requireEditor }, async (request) => {
|
||||
const body = parseWith(
|
||||
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
|
||||
request.body ?? {},
|
||||
@@ -33,7 +33,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
|
||||
app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (request) => {
|
||||
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
|
||||
const player = await prisma.player.findUnique({
|
||||
|
||||
@@ -14,7 +14,9 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
|
||||
];
|
||||
|
||||
const MIN_REQUEST_GAP_MS = 1000;
|
||||
const MAX_PAGES_PER_BOARD = 20;
|
||||
// Das Archiv reicht Jahre zurück; bei einer Sekunde Abstand dauert ein voller
|
||||
// Lauf entsprechend. Die Grenze ist eine Notbremse, keine Sparmaßnahme.
|
||||
const MAX_PAGES_PER_BOARD = 60;
|
||||
let lastRequestAt = 0;
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
@@ -87,12 +89,25 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
failedBoards: [],
|
||||
};
|
||||
|
||||
// Forumsnamen einmal laden statt je Thread zu suchen.
|
||||
const players = await prisma.player.findMany({
|
||||
where: { forumName: { not: null } },
|
||||
select: { id: true, forumName: true },
|
||||
});
|
||||
const byForumName = new Map(players.map((p) => [(p.forumName as string).toLowerCase(), p.id]));
|
||||
// Forumsnamen einmal laden statt je Thread zu suchen - die aktuellen und
|
||||
// frühere Konten, damit ein Namenswechsel die Zuordnung nicht abreißen lässt.
|
||||
const [players, aliases] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
where: { forumName: { not: null } },
|
||||
select: { id: true, forumName: true },
|
||||
}),
|
||||
prisma.playerAlias.findMany({
|
||||
where: { type: 'FORUM_NAME' },
|
||||
select: { playerId: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const byForumName = new Map<string, number>();
|
||||
for (const alias of aliases) byForumName.set(alias.value.toLowerCase(), alias.playerId);
|
||||
// Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht.
|
||||
for (const player of players) {
|
||||
byForumName.set((player.forumName as string).toLowerCase(), player.id);
|
||||
}
|
||||
|
||||
for (const { board, path } of BOARDS) {
|
||||
let threads: ForumThread[] = [];
|
||||
@@ -310,6 +325,9 @@ export async function classifyThread(
|
||||
|
||||
let characterId: number | null = null;
|
||||
|
||||
// Eine Entscheidung kann Kulanz verbrauchen. Das Vergeben von Kulanz ist
|
||||
// Admins vorbehalten, das Verbrauchen nicht - hier wird also nur gebucht,
|
||||
// was der Spieler bereits hat.
|
||||
if (input.classification === ForumClassification.REACTIVATION) {
|
||||
if (input.characterId === undefined) {
|
||||
throw badRequest('NO_CHARACTER', 'Bitte angeben, welcher CC zurückkehrt');
|
||||
|
||||
@@ -238,14 +238,25 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
||||
})),
|
||||
];
|
||||
|
||||
const players = await prisma.player.findMany({
|
||||
where: {
|
||||
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||
},
|
||||
select: { id: true, steamId: true, displayName: true },
|
||||
});
|
||||
const [players, aliases] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
where: {
|
||||
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||
},
|
||||
select: { id: true, steamId: true, displayName: true },
|
||||
}),
|
||||
// Frühere Steam-Konten zählen mit.
|
||||
prisma.playerAlias.findMany({
|
||||
where: { type: 'STEAM_ID', value: { in: playerKeys } },
|
||||
select: { playerId: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
|
||||
const bySteamId = new Map<string, number>();
|
||||
for (const alias of aliases) bySteamId.set(alias.value, alias.playerId);
|
||||
for (const player of players) {
|
||||
if (player.steamId) bySteamId.set(player.steamId, player.id);
|
||||
}
|
||||
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
||||
|
||||
const orderIds = mapping.orderId
|
||||
|
||||
@@ -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 { AliasType } from '@prisma/client';
|
||||
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
||||
|
||||
export interface PlayerInput {
|
||||
@@ -67,7 +68,7 @@ export async function getPlayerDetail(playerId: number) {
|
||||
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
|
||||
const [balances, characters, ledger, unmatched] = await Promise.all([
|
||||
const [balances, characters, ledger, unmatched, aliases] = await Promise.all([
|
||||
getBalances(playerId),
|
||||
listCharacters(playerId),
|
||||
listLedger({ playerId, take: 200 }),
|
||||
@@ -76,6 +77,10 @@ export async function getPlayerDetail(playerId: number) {
|
||||
orderBy: { scrapedAt: 'desc' },
|
||||
include: { player: { select: { id: true, displayName: true } } },
|
||||
}),
|
||||
prisma.playerAlias.findMany({
|
||||
where: { playerId },
|
||||
orderBy: [{ type: 'asc' }, { value: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -85,6 +90,7 @@ export async function getPlayerDetail(playerId: number) {
|
||||
ledger: ledger.items,
|
||||
ledgerTotal: ledger.total,
|
||||
unmatched,
|
||||
aliases,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,3 +225,76 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe
|
||||
* Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht.
|
||||
*/
|
||||
export async function addAlias(
|
||||
playerId: number,
|
||||
type: AliasType,
|
||||
rawValue: string,
|
||||
note: string | undefined,
|
||||
ctx: ActionContext,
|
||||
) {
|
||||
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
|
||||
let value = rawValue.trim();
|
||||
if (type === AliasType.STEAM_ID) {
|
||||
const normalized = normalizeSteamId(value);
|
||||
if (!normalized) {
|
||||
throw conflict('INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus`);
|
||||
}
|
||||
value = normalized;
|
||||
|
||||
if (value === player.steamId) {
|
||||
throw conflict('IS_PRIMARY', 'Das ist bereits die hinterlegte SteamID dieses Spielers');
|
||||
}
|
||||
} else if (value.toLowerCase() === player.forumName?.toLowerCase()) {
|
||||
throw conflict('IS_PRIMARY', 'Das ist bereits der hinterlegte Forum-Name dieses Spielers');
|
||||
}
|
||||
|
||||
if (value.length === 0) throw conflict('EMPTY', 'Der Wert darf nicht leer sein');
|
||||
|
||||
const vorhanden = await prisma.playerAlias.findUnique({
|
||||
where: { type_value: { type, value } },
|
||||
include: { player: { select: { displayName: true } } },
|
||||
});
|
||||
if (vorhanden) {
|
||||
throw conflict(
|
||||
'ALIAS_TAKEN',
|
||||
vorhanden.playerId === playerId
|
||||
? 'Dieses Konto ist hier schon eingetragen'
|
||||
: `Dieses Konto gehört bereits zu "${vorhanden.player.displayName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.playerAlias.create({
|
||||
data: { playerId, type, value, note: note?.trim() || null },
|
||||
});
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'player.alias_added',
|
||||
targetType: 'player',
|
||||
targetId: playerId,
|
||||
details: { type, value, note: note ?? null },
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function removeAlias(aliasId: number, ctx: ActionContext): Promise<void> {
|
||||
const alias = await prisma.playerAlias.findUnique({ where: { id: aliasId } });
|
||||
if (!alias) throw notFound('Eintrag');
|
||||
|
||||
await prisma.playerAlias.delete({ where: { id: aliasId } });
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'player.alias_removed',
|
||||
targetType: 'player',
|
||||
targetId: alias.playerId,
|
||||
details: { type: alias.type, value: alias.value },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,15 +10,36 @@ import {
|
||||
import { PLANS } from './imports.js';
|
||||
import { recordAudit, type ActionContext } from './audit.js';
|
||||
|
||||
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
|
||||
/**
|
||||
* 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 = await prisma.player.findMany({
|
||||
where: { steamId: { not: null } },
|
||||
select: { id: true, steamId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ScrapeSummary {
|
||||
|
||||
Reference in New Issue
Block a user