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:
@@ -188,7 +188,9 @@ CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder
|
|||||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
||||||
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||||
|
|
||||||
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler.
|
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler —
|
||||||
|
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
||||||
|
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche.
|
||||||
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
||||||
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
||||||
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
||||||
@@ -211,8 +213,15 @@ das Signal zu unzuverlässig.
|
|||||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
|
|
||||||
- **Viewer** — darf nur lesen.
|
- **Viewer** — darf nur lesen.
|
||||||
- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren.
|
- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||||
- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen.
|
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
||||||
|
über Anträge.
|
||||||
|
- **Admin** — zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
||||||
|
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
||||||
|
|
||||||
|
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
||||||
|
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
||||||
|
vorbehalten — ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
||||||
|
|
||||||
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
||||||
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Frühere Steam- und Forumskonten eines Spielers.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "AliasType" AS ENUM ('STEAM_ID', 'FORUM_NAME');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "PlayerAlias" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"type" "AliasType" NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PlayerAlias_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "PlayerAlias_type_value_key" ON "PlayerAlias"("type", "value");
|
||||||
|
CREATE INDEX IF NOT EXISTS "PlayerAlias_playerId_idx" ON "PlayerAlias"("playerId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "PlayerAlias" ADD CONSTRAINT "PlayerAlias_playerId_fkey"
|
||||||
|
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
@@ -61,6 +61,11 @@ enum ForumClassification {
|
|||||||
IGNORE
|
IGNORE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AliasType {
|
||||||
|
STEAM_ID
|
||||||
|
FORUM_NAME
|
||||||
|
}
|
||||||
|
|
||||||
enum ImportStatus {
|
enum ImportStatus {
|
||||||
PENDING
|
PENDING
|
||||||
COMMITTED
|
COMMITTED
|
||||||
@@ -121,6 +126,7 @@ model Player {
|
|||||||
orders ImportedOrder[]
|
orders ImportedOrder[]
|
||||||
unmatched UnmatchedPackage[]
|
unmatched UnmatchedPackage[]
|
||||||
forumThreads ForumThread[]
|
forumThreads ForumThread[]
|
||||||
|
aliases PlayerAlias[]
|
||||||
|
|
||||||
@@index([displayName])
|
@@index([displayName])
|
||||||
}
|
}
|
||||||
@@ -361,3 +367,20 @@ model ForumThreadBoard {
|
|||||||
|
|
||||||
@@index([threadId])
|
@@index([threadId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Frühere Steam- oder Forumskonten eines Spielers. Wer den Account wechselt,
|
||||||
|
/// soll seine Ansprüche behalten - Abgleich und Import prüfen deshalb neben
|
||||||
|
/// den Hauptangaben auch diese Einträge.
|
||||||
|
model PlayerAlias {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
playerId Int
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
type AliasType
|
||||||
|
value String
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// Dieselbe Kennung darf nicht zwei Spielern gehören.
|
||||||
|
@@unique([type, value])
|
||||||
|
@@index([playerId])
|
||||||
|
}
|
||||||
|
|||||||
+11
-1
@@ -89,8 +89,18 @@ export function parseBoard(html: string): ForumThread[] {
|
|||||||
return threads;
|
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 {
|
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]));
|
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
|
||||||
return numbers.length > 0 ? Math.max(1, ...numbers) : 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 { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(noteBody, request.body ?? {});
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
return bookKulanz(id, contextOf(request), { note: body.note });
|
return bookKulanz(id, contextOf(request), { note: body.note });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ForumBoard, ForumClassification } from '@prisma/client';
|
import { ForumBoard, ForumClassification } from '@prisma/client';
|
||||||
import { requireAdmin, requireAuth } from '../auth/guard.js';
|
import { requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
import {
|
import {
|
||||||
assignPlayer,
|
assignPlayer,
|
||||||
classifyThread,
|
classifyThread,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
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));
|
return scanForum(contextOf(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,13 +40,13 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { items: await listBoardChangeNotices() };
|
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);
|
const { id } = parseWith(idParam, request.params);
|
||||||
await dismissBoardChangeNotice(id, contextOf(request));
|
await dismissBoardChangeNotice(id, contextOf(request));
|
||||||
return { ok: true };
|
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 { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({ playerId: z.number().int().positive() }),
|
z.object({ playerId: z.number().int().positive() }),
|
||||||
@@ -56,7 +56,7 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { ok: true };
|
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 { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireAdmin } from '../auth/guard.js';
|
import { requireEditor } from '../auth/guard.js';
|
||||||
import { badRequest } from '../errors.js';
|
import { badRequest } from '../errors.js';
|
||||||
import {
|
import {
|
||||||
applyMapping,
|
applyMapping,
|
||||||
@@ -24,7 +24,7 @@ const mappingBody = z.object({
|
|||||||
|
|
||||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
// 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();
|
const file = await request.file();
|
||||||
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
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() };
|
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);
|
const { id } = parseWith(idParam, request.params);
|
||||||
return getImport(id);
|
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 { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(mappingBody, request.body);
|
const body = parseWith(mappingBody, request.body);
|
||||||
const summary = await applyMapping(id, body, contextOf(request));
|
const summary = await applyMapping(id, body, contextOf(request));
|
||||||
@@ -58,12 +58,12 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { summary, import: record };
|
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);
|
const { id } = parseWith(idParam, request.params);
|
||||||
return commitImport(id, contextOf(request));
|
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);
|
const { id } = parseWith(idParam, request.params);
|
||||||
await discardImport(id, contextOf(request));
|
await discardImport(id, contextOf(request));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CreditKind } from '@prisma/client';
|
import { CreditKind } from '@prisma/client';
|
||||||
|
import { AliasType } from '@prisma/client';
|
||||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
import { listLedger } from '../services/credits.js';
|
import { listLedger } from '../services/credits.js';
|
||||||
import {
|
import {
|
||||||
|
addAlias,
|
||||||
createPlayer,
|
createPlayer,
|
||||||
deletePlayer,
|
deletePlayer,
|
||||||
getPlayerDetail,
|
getPlayerDetail,
|
||||||
|
removeAlias,
|
||||||
searchPlayers,
|
searchPlayers,
|
||||||
updatePlayer,
|
updatePlayer,
|
||||||
} from '../services/players.js';
|
} from '../services/players.js';
|
||||||
@@ -63,6 +66,29 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return updatePlayer(id, body, contextOf(request));
|
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) => {
|
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
await deletePlayer(id, contextOf(request));
|
await deletePlayer(id, contextOf(request));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
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 { notFound } from '../errors.js';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||||
* Profile auf Vorrat einsammeln.
|
* 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(
|
const body = parseWith(
|
||||||
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
|
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
|
||||||
request.body ?? {},
|
request.body ?? {},
|
||||||
@@ -33,7 +33,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
|
/** 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 { id } = parseWith(idParam, request.params);
|
||||||
|
|
||||||
const player = await prisma.player.findUnique({
|
const player = await prisma.player.findUnique({
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MIN_REQUEST_GAP_MS = 1000;
|
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;
|
let lastRequestAt = 0;
|
||||||
|
|
||||||
async function throttle(): Promise<void> {
|
async function throttle(): Promise<void> {
|
||||||
@@ -87,12 +89,25 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
failedBoards: [],
|
failedBoards: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Forumsnamen einmal laden statt je Thread zu suchen.
|
// Forumsnamen einmal laden statt je Thread zu suchen - die aktuellen und
|
||||||
const players = await prisma.player.findMany({
|
// frühere Konten, damit ein Namenswechsel die Zuordnung nicht abreißen lässt.
|
||||||
where: { forumName: { not: null } },
|
const [players, aliases] = await Promise.all([
|
||||||
select: { id: true, forumName: true },
|
prisma.player.findMany({
|
||||||
});
|
where: { forumName: { not: null } },
|
||||||
const byForumName = new Map(players.map((p) => [(p.forumName as string).toLowerCase(), p.id]));
|
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) {
|
for (const { board, path } of BOARDS) {
|
||||||
let threads: ForumThread[] = [];
|
let threads: ForumThread[] = [];
|
||||||
@@ -310,6 +325,9 @@ export async function classifyThread(
|
|||||||
|
|
||||||
let characterId: number | null = null;
|
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.classification === ForumClassification.REACTIVATION) {
|
||||||
if (input.characterId === undefined) {
|
if (input.characterId === undefined) {
|
||||||
throw badRequest('NO_CHARACTER', 'Bitte angeben, welcher CC zurückkehrt');
|
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({
|
const [players, aliases] = await Promise.all([
|
||||||
where: {
|
prisma.player.findMany({
|
||||||
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
where: {
|
||||||
},
|
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||||
select: { id: true, steamId: true, displayName: true },
|
},
|
||||||
});
|
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 byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
||||||
|
|
||||||
const orderIds = mapping.orderId
|
const orderIds = mapping.orderId
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { conflict, notFound } from '../errors.js';
|
|||||||
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
||||||
import { listCharacters } from './characters.js';
|
import { listCharacters } from './characters.js';
|
||||||
import { recordAudit, type ActionContext } from './audit.js';
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
import { AliasType } from '@prisma/client';
|
||||||
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
||||||
|
|
||||||
export interface PlayerInput {
|
export interface PlayerInput {
|
||||||
@@ -67,7 +68,7 @@ export async function getPlayerDetail(playerId: number) {
|
|||||||
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
if (!player) throw notFound('Spieler');
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
const [balances, characters, ledger, unmatched] = await Promise.all([
|
const [balances, characters, ledger, unmatched, aliases] = await Promise.all([
|
||||||
getBalances(playerId),
|
getBalances(playerId),
|
||||||
listCharacters(playerId),
|
listCharacters(playerId),
|
||||||
listLedger({ playerId, take: 200 }),
|
listLedger({ playerId, take: 200 }),
|
||||||
@@ -76,6 +77,10 @@ export async function getPlayerDetail(playerId: number) {
|
|||||||
orderBy: { scrapedAt: 'desc' },
|
orderBy: { scrapedAt: 'desc' },
|
||||||
include: { player: { select: { id: true, displayName: true } } },
|
include: { player: { select: { id: true, displayName: true } } },
|
||||||
}),
|
}),
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ type: 'asc' }, { value: 'asc' }],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +90,7 @@ export async function getPlayerDetail(playerId: number) {
|
|||||||
ledger: ledger.items,
|
ledger: ledger.items,
|
||||||
ledgerTotal: ledger.total,
|
ledgerTotal: ledger.total,
|
||||||
unmatched,
|
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 { PLANS } from './imports.js';
|
||||||
import { recordAudit, type ActionContext } from './audit.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 }[]> {
|
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
|
||||||
const players = await prisma.player.findMany({
|
const [players, aliases] = await Promise.all([
|
||||||
where: { steamId: { not: null } },
|
prisma.player.findMany({
|
||||||
select: { id: true, steamId: true },
|
where: { steamId: { not: null } },
|
||||||
orderBy: { id: 'asc' },
|
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 {
|
export interface ScrapeSummary {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Players = typeof import('../src/services/players.js');
|
||||||
|
type Scraper = typeof import('../src/services/scraper.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let players: Players;
|
||||||
|
let scraper: Scraper;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54335);
|
||||||
|
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');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
scraper = await import('../src/services/scraper.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Frühere Konten', () => {
|
||||||
|
it('bringt eine nachgetragene SteamID auf dieselbe Form wie die Hauptangabe', async () => {
|
||||||
|
const player = await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Umzügler', steamId: '76561198000001111' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// In der Legacy-Schreibweise eingegeben, gespeichert wird die 64-Bit-Form.
|
||||||
|
await players.addAlias(player.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Alter Account', ctx);
|
||||||
|
|
||||||
|
const alias = await db.prisma.playerAlias.findFirstOrThrow({ where: { playerId: player.id } });
|
||||||
|
assert.equal(alias.value, '76561198000000999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('klappert beim Abgleich auch die früheren Konten ab', async () => {
|
||||||
|
const ids = await scraper.knownSteamIds();
|
||||||
|
const fuerUmzuegler = ids.filter((e) => e.steamId.startsWith('765611980000'));
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fuerUmzuegler.some((e) => e.steamId === '76561198000001111'),
|
||||||
|
'die aktuelle Kennung',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fuerUmzuegler.some((e) => e.steamId === '76561198000000999'),
|
||||||
|
'und die frühere',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt dieselbe Kennung nicht zweimal in den Lauf', async () => {
|
||||||
|
const ids = await scraper.knownSteamIds();
|
||||||
|
const eindeutig = new Set(ids.map((e) => e.steamId));
|
||||||
|
assert.equal(ids.length, eindeutig.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ein Konto nicht zwei Spielern gehören', async () => {
|
||||||
|
const anderer = await db.prisma.player.create({ data: { displayName: 'Fremder' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(anderer.id, 'STEAM_ID', '76561198000000999', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'ALIAS_TAKEN',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist die eigene Hauptangabe als Alias ab', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(player.id, 'STEAM_ID', '76561198000001111', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'IS_PRIMARY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unsinn als SteamID ab', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(player.id, 'STEAM_ID', 'kein-account', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'INVALID_STEAMID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt Forum-Namen ohne Umrechnung', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
const alias = await players.addAlias(player.id, 'FORUM_NAME', 'AlterName2019', undefined, ctx);
|
||||||
|
|
||||||
|
assert.equal(alias.value, 'AlterName2019');
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+1
@@ -70,6 +70,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
<woltlab-core-pagination page="1" count="25" url="https://forum.example.org/index.php?board/66-custom-characters/"></woltlab-core-pagination>
|
||||||
<nav class="pagination"><ul>
|
<nav class="pagination"><ul>
|
||||||
<li><a href="index.php?board/66-custom-characters/&pageNo=2" class="pageNavigationLink">2</a></li>
|
<li><a href="index.php?board/66-custom-characters/&pageNo=2" class="pageNavigationLink">2</a></li>
|
||||||
<li><a href="index.php?board/66-custom-characters/&pageNo=3" class="pageNavigationLink">3</a></li>
|
<li><a href="index.php?board/66-custom-characters/&pageNo=3" class="pageNavigationLink">3</a></li>
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ describe('Board-Übersicht lesen', () => {
|
|||||||
assert.equal(threads.some((t) => t.threadId === 9999), false);
|
assert.equal(threads.some((t) => t.threadId === 9999), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('liest die Seitenzahl aus der Blätterleiste', () => {
|
it('liest die Gesamtzahl der Seiten, nicht nur die verlinkten', () => {
|
||||||
assert.equal(parsePageCount(BOARD), 3);
|
// Die Blätterleiste verlinkt hier nur Seite 2 und 3, das Board hat aber 25.
|
||||||
|
// Wer die Links auswertet, liest den grössten Teil des Archivs nie.
|
||||||
|
assert.equal(parsePageCount(BOARD), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fällt ohne das Element auf die Links zurück', () => {
|
||||||
|
assert.equal(parsePageCount('<a href="x&pageNo=7">7</a>'), 7);
|
||||||
assert.equal(parsePageCount('<html>ohne Blaetterleiste</html>'), 1);
|
assert.equal(parsePageCount('<html>ohne Blaetterleiste</html>'), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/import"
|
path="/import"
|
||||||
element={
|
element={
|
||||||
<Protected minimum="ADMIN">
|
<Protected minimum="EDITOR">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Import />
|
<Import />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import type { AliasType, PlayerAlias } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frühere Steam- und Forumskonten. Abgleich und Import prüfen sie mit, damit
|
||||||
|
* ein Kontowechsel die Zuordnung nicht abreißen lässt.
|
||||||
|
*/
|
||||||
|
export default function AliasBox({
|
||||||
|
playerId,
|
||||||
|
aliases,
|
||||||
|
canEdit,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
playerId: number;
|
||||||
|
aliases: PlayerAlias[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const [type, setType] = useState<AliasType>('STEAM_ID');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
const steam = aliases.filter((a) => a.type === 'STEAM_ID');
|
||||||
|
const forum = aliases.filter((a) => a.type === 'FORUM_NAME');
|
||||||
|
|
||||||
|
const hinzufuegen = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/players/${playerId}/aliases`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type, value: value.trim(), ...(note.trim() ? { note: note.trim() } : {}) },
|
||||||
|
});
|
||||||
|
setValue('');
|
||||||
|
setNote('');
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Eintragen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entfernen = async (alias: PlayerAlias) => {
|
||||||
|
if (!window.confirm(`"${alias.value}" wirklich entfernen?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' });
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Entfernen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ohne Einträge und ohne Rechte gibt es nichts zu zeigen.
|
||||||
|
if (aliases.length === 0 && !canEdit) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<div className="block-head">
|
||||||
|
<h2>Frühere Konten ({aliases.length})</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<button type="button" className="btn btn-small" onClick={() => setOffen(!offen)}>
|
||||||
|
{offen ? 'Schliessen' : 'Konto nachtragen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
|
||||||
|
{aliases.length === 0 ? (
|
||||||
|
<p className="muted small">
|
||||||
|
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
|
||||||
|
genutzt, trag ihn hier nach — Abgleich und Import finden ihn dann mit.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="alias-groups">
|
||||||
|
{steam.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3>SteamIDs</h3>
|
||||||
|
<ul className="alias-list">
|
||||||
|
{steam.map((alias) => (
|
||||||
|
<li key={alias.id}>
|
||||||
|
<span className="mono">{alias.value}</span>
|
||||||
|
{alias.note && <span className="muted small"> · {alias.note}</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => void entfernen(alias)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{forum.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3>Forum-Namen</h3>
|
||||||
|
<ul className="alias-list">
|
||||||
|
{forum.map((alias) => (
|
||||||
|
<li key={alias.id}>
|
||||||
|
<span>{alias.value}</span>
|
||||||
|
{alias.note && <span className="muted small"> · {alias.note}</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => void entfernen(alias)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && offen && (
|
||||||
|
<form className="alias-form" onSubmit={hinzufuegen}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="alias-type">Art</label>
|
||||||
|
<select
|
||||||
|
id="alias-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => setType(event.target.value as AliasType)}
|
||||||
|
>
|
||||||
|
<option value="STEAM_ID">Frühere SteamID</option>
|
||||||
|
<option value="FORUM_NAME">Früherer Forum-Name</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="alias-value">
|
||||||
|
{type === 'STEAM_ID' ? 'SteamID' : 'Forum-Name'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="alias-value"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={type === 'STEAM_ID' ? '76561198... oder STEAM_0:1:...' : 'Alter Name im Forum'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label htmlFor="alias-note">Notiz (optional)</label>
|
||||||
|
<input
|
||||||
|
id="alias-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
placeholder="z. B. Account bis 2024"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Nachtragen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
Spieler
|
Spieler
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/forum">Anträge</NavLink>
|
<NavLink to="/forum">Anträge</NavLink>
|
||||||
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
|
{can('EDITOR') && <NavLink to="/import">Import</NavLink>}
|
||||||
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ export interface LedgerEntry {
|
|||||||
reversalOfId: number | null;
|
reversalOfId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AliasType = 'STEAM_ID' | 'FORUM_NAME';
|
||||||
|
|
||||||
|
export interface PlayerAlias {
|
||||||
|
id: number;
|
||||||
|
playerId: number;
|
||||||
|
type: AliasType;
|
||||||
|
value: string;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerDetail {
|
export interface PlayerDetail {
|
||||||
player: Player;
|
player: Player;
|
||||||
balances: Balances;
|
balances: Balances;
|
||||||
@@ -123,6 +134,7 @@ export interface PlayerDetail {
|
|||||||
ledger: LedgerEntry[];
|
ledger: LedgerEntry[];
|
||||||
ledgerTotal: number;
|
ledgerTotal: number;
|
||||||
unmatched: UnmatchedPackage[];
|
unmatched: UnmatchedPackage[];
|
||||||
|
aliases: PlayerAlias[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function Dashboard() {
|
|||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<h1>Spieler</h1>
|
<h1>Spieler</h1>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
{can('ADMIN') && (
|
{can('EDITOR') && (
|
||||||
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
|
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
|
||||||
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
|
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
|
|
||||||
export default function Forum() {
|
export default function Forum() {
|
||||||
const { can } = useSession();
|
const { can } = useSession();
|
||||||
const admin = can('ADMIN');
|
const darfEntscheiden = can('EDITOR');
|
||||||
|
|
||||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||||
const [notices, setNotices] = useState<ForumThread[]>([]);
|
const [notices, setNotices] = useState<ForumThread[]>([]);
|
||||||
@@ -88,7 +88,7 @@ export default function Forum() {
|
|||||||
die hier mit passendem Forum-Namen erfasst sind.
|
die hier mit passendem Forum-Namen erfasst sind.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{admin && (
|
{darfEntscheiden && (
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
||||||
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +124,7 @@ export default function Forum() {
|
|||||||
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
||||||
Thread öffnen
|
Thread öffnen
|
||||||
</a>
|
</a>
|
||||||
{admin && (
|
{darfEntscheiden && (
|
||||||
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
|
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
|
||||||
Erledigt
|
Erledigt
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +141,7 @@ export default function Forum() {
|
|||||||
|
|
||||||
{threads.length === 0 ? (
|
{threads.length === 0 ? (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Keine offenen Anträge. {admin && 'Über „Forum abgleichen" nach neuen suchen.'}
|
Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="cc-list">
|
<ul className="cc-list">
|
||||||
@@ -170,12 +170,12 @@ export default function Forum() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-actions">
|
<div className="cc-actions">
|
||||||
{admin && !thread.player && (
|
{darfEntscheiden && !thread.player && (
|
||||||
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
||||||
Spieler zuordnen
|
Spieler zuordnen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{admin && (
|
{darfEntscheiden && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-small btn-primary"
|
className="btn btn-small btn-primary"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import BalanceCards from '../components/BalanceCards';
|
import BalanceCards from '../components/BalanceCards';
|
||||||
import UnmatchedBox from '../components/UnmatchedBox';
|
import UnmatchedBox from '../components/UnmatchedBox';
|
||||||
|
import AliasBox from '../components/AliasBox';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import Notice from '../components/Notice';
|
import Notice from '../components/Notice';
|
||||||
import { ApiError, api } from '../lib/api';
|
import { ApiError, api } from '../lib/api';
|
||||||
@@ -192,14 +193,17 @@ export default function PlayerDetail() {
|
|||||||
>
|
>
|
||||||
+ Redesign-Paket
|
+ Redesign-Paket
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */}
|
||||||
type="button"
|
{can('ADMIN') && (
|
||||||
className="btn"
|
<button
|
||||||
disabled={busy}
|
type="button"
|
||||||
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
|
className="btn"
|
||||||
>
|
disabled={busy}
|
||||||
+ Kulanz
|
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
|
||||||
</button>
|
>
|
||||||
|
+ Kulanz
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -208,7 +212,7 @@ export default function PlayerDetail() {
|
|||||||
>
|
>
|
||||||
Neuer CC
|
Neuer CC
|
||||||
</button>
|
</button>
|
||||||
{can('ADMIN') && detail.player.steamId && (
|
{can('EDITOR') && detail.player.steamId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -340,6 +344,13 @@ export default function PlayerDetail() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AliasBox
|
||||||
|
playerId={playerId}
|
||||||
|
aliases={detail.aliases ?? []}
|
||||||
|
canEdit={editor}
|
||||||
|
onChange={() => void load()}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="block">
|
<section className="block">
|
||||||
<h2>Guthaben-Historie</h2>
|
<h2>Guthaben-Historie</h2>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
|
|||||||
@@ -910,3 +910,50 @@ textarea {
|
|||||||
margin-top: -2.25rem;
|
margin-top: -2.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Frühere Konten --- */
|
||||||
|
|
||||||
|
.alias-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-groups h3 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-list li > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias-form {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user