diff --git a/README.md b/README.md index 95a260e..26c6ef4 100644 --- a/README.md +++ b/README.md @@ -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 — 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 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. @@ -211,8 +213,15 @@ das Signal zu unzuverlässig. Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. - **Viewer** — darf nur lesen. -- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren. -- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen. +- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen, + 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* eine Zwei-Faktor-Anmeldung per TOTP einrichten. diff --git a/server/prisma/migrations/20250104000000_spieler_aliasse/migration.sql b/server/prisma/migrations/20250104000000_spieler_aliasse/migration.sql new file mode 100644 index 0000000..5d36b29 --- /dev/null +++ b/server/prisma/migrations/20250104000000_spieler_aliasse/migration.sql @@ -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 $$; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e42a156..d4a746e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -61,6 +61,11 @@ enum ForumClassification { IGNORE } +enum AliasType { + STEAM_ID + FORUM_NAME +} + enum ImportStatus { PENDING COMMITTED @@ -121,6 +126,7 @@ model Player { orders ImportedOrder[] unmatched UnmatchedPackage[] forumThreads ForumThread[] + aliases PlayerAlias[] @@index([displayName]) } @@ -361,3 +367,20 @@ model ForumThreadBoard { @@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]) +} diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts index e16bd9b..d1d239c 100644 --- a/server/src/lib/forum.ts +++ b/server/src/lib/forum.ts @@ -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 = /]*\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; } diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index 9226fdc..c2761a2 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -30,7 +30,9 @@ export async function creditRoutes(app: FastifyInstance): Promise { }, ); - 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 }); diff --git a/server/src/routes/forum.routes.ts b/server/src/routes/forum.routes.ts index 4213f73..a1a3ab1 100644 --- a/server/src/routes/forum.routes.ts +++ b/server/src/routes/forum.routes.ts @@ -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 { - 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 { 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 { 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({ diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts index 65205c9..c1dd32b 100644 --- a/server/src/routes/imports.routes.ts +++ b/server/src/routes/imports.routes.ts @@ -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 { // 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 { }; }); - 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 { 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 }; diff --git a/server/src/routes/players.routes.ts b/server/src/routes/players.routes.ts index dc683f8..2787e1f 100644 --- a/server/src/routes/players.routes.ts +++ b/server/src/routes/players.routes.ts @@ -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 { 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)); diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts index 9b768c0..1d0a416 100644 --- a/server/src/routes/scraper.routes.ts +++ b/server/src/routes/scraper.routes.ts @@ -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 { * 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 { }); /** 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({ diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index 44561a6..f794e86 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -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 { @@ -87,12 +89,25 @@ export async function scanForum(ctx: ActionContext): Promise { 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(); + 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'); diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index 9d40c51..544d644 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -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(); + 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 diff --git a/server/src/services/players.ts b/server/src/services/players.ts index fef762a..92d3d3a 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -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 { + 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 }, + }); +} diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index 656b1a4..a87fe51 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -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(); + return alle.filter((eintrag) => { + if (gesehen.has(eintrag.steamId)) return false; + gesehen.add(eintrag.steamId); + return true; + }); } export interface ScrapeSummary { diff --git a/server/test/aliases.test.ts b/server/test/aliases.test.ts new file mode 100644 index 0000000..5bc86e3 --- /dev/null +++ b/server/test/aliases.test.ts @@ -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'); + }); +}); diff --git a/server/test/fixtures/board.html b/server/test/fixtures/board.html index 6d55be1..ebcc525 100644 --- a/server/test/fixtures/board.html +++ b/server/test/fixtures/board.html @@ -70,6 +70,7 @@ + diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b43c09b..be66f74 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -116,6 +116,17 @@ export interface LedgerEntry { 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 { player: Player; balances: Balances; @@ -123,6 +134,7 @@ export interface PlayerDetail { ledger: LedgerEntry[]; ledgerTotal: number; unmatched: UnmatchedPackage[]; + aliases: PlayerAlias[]; } export interface AdminUser { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index a170452..cf52a63 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -101,7 +101,7 @@ export default function Dashboard() {

Spieler

- {can('ADMIN') && ( + {can('EDITOR') && ( diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx index 62d4d65..12bfa4a 100644 --- a/web/src/pages/Forum.tsx +++ b/web/src/pages/Forum.tsx @@ -16,7 +16,7 @@ import type { export default function Forum() { const { can } = useSession(); - const admin = can('ADMIN'); + const darfEntscheiden = can('EDITOR'); const [threads, setThreads] = useState([]); const [notices, setNotices] = useState([]); @@ -88,7 +88,7 @@ export default function Forum() { die hier mit passendem Forum-Namen erfasst sind.

- {admin && ( + {darfEntscheiden && ( @@ -124,7 +124,7 @@ export default function Forum() { Thread öffnen - {admin && ( + {darfEntscheiden && ( @@ -141,7 +141,7 @@ export default function Forum() { {threads.length === 0 ? (

- Keine offenen Anträge. {admin && 'Über „Forum abgleichen" nach neuen suchen.'} + Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'}

) : (
    @@ -170,12 +170,12 @@ export default function Forum() {

- {admin && !thread.player && ( + {darfEntscheiden && !thread.player && ( )} - {admin && ( + {darfEntscheiden && ( - + {/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */} + {can('ADMIN') && ( + + )} - {can('ADMIN') && detail.player.steamId && ( + {can('EDITOR') && detail.player.steamId && (