diff --git a/.env.example b/.env.example index 1dfb981..de14fcf 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ BOOTSTRAP_ADMIN_PASSWORD= # auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird. # GEXTENSION_BASE_URL=https://gextension.senseigaming.de +# Adresse des Forums für den Antrags-Abgleich. Der Parser ist auf WoltLab +# (Burning Board) zugeschnitten. +# FORUM_BASE_URL=https://senseigaming.de/forum + # Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen). # AUDIT_RETENTION_DAYS=365 diff --git a/README.md b/README.md index 25fb8d8..ee7ff60 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,34 @@ Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per Die Seite gehört dem Serverbetreiber, nicht dieser App — ein offizieller Datenzugang wäre der sauberere Weg, falls er zu haben ist. +## Forums-Anträge + +> Ebenfalls auf eine bestimmte Instanz zugeschnitten: gelesen wird ein **WoltLab-Forum** +> (Burning Board) unter `senseigaming.de/forum`, konfigurierbar über `FORUM_BASE_URL`. Ein +> anderes Forensystem bräuchte einen eigenen Parser in `server/src/lib/forum.ts`. + +Während der GExtension-Abgleich die **Käufe** liefert, steht der **Verbrauch** im Forum: Wurde +ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest +die App die drei CC-Boards aus und sammelt die Threads. + +**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet — +Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im +Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile. + +Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl, +welcher CC ersetzt wurde und woraus bezahlt wird), *abgelehnt* oder *kein Antrag*. Die beiden +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. +Ohne Treffer bleibt der Antrag in der Liste und lässt sich von Hand zuordnen; trägst du den +Forumsnamen beim Spieler nach, findet ihn der nächste Abgleich von allein. + +Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die +Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa +ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist +das Signal zu unzuverlässig. + ## Rollen Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. diff --git a/compose.yaml b/compose.yaml index 591a929..7628ad7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,7 @@ services: TRUST_PROXY: "true" # Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll. GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de} + FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum} AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} expose: # Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS. diff --git a/server/prisma/migrations/20250101000000_init/migration.sql b/server/prisma/migrations/20250101000000_init/migration.sql index 0af8c03..d6ef782 100644 --- a/server/prisma/migrations/20250101000000_init/migration.sql +++ b/server/prisma/migrations/20250101000000_init/migration.sql @@ -19,6 +19,12 @@ CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER'); -- CreateEnum CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE'); +-- CreateEnum +CREATE TYPE "ForumBoard" AS ENUM ('CUSTOM_CHARACTERS', 'AKTIVE_CCS', 'CC_ARCHIV'); + +-- CreateEnum +CREATE TYPE "ForumClassification" AS ENUM ('NEW_CC', 'REDESIGN', 'REJECTED', 'IGNORE'); + -- CreateEnum CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED'); @@ -198,6 +204,38 @@ CREATE TABLE "AuditLog" ( CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "ForumThread" ( + "threadId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "url" TEXT NOT NULL, + "authorName" TEXT, + "authorForumId" INTEGER, + "playerId" INTEGER, + "currentBoard" "ForumBoard" NOT NULL, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastBoardChangeAt" TIMESTAMP(3), + "classification" "ForumClassification", + "needsClassification" BOOLEAN NOT NULL DEFAULT true, + "classifiedById" INTEGER, + "classifiedAt" TIMESTAMP(3), + "linkedCharacterId" INTEGER, + "boardChangeNotice" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("threadId") +); + +-- CreateTable +CREATE TABLE "ForumThreadBoard" ( + "id" SERIAL NOT NULL, + "threadId" INTEGER NOT NULL, + "board" "ForumBoard" NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ForumThreadBoard_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); @@ -273,6 +311,18 @@ CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "tar -- CreateIndex CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId"); +-- CreateIndex +CREATE INDEX "ForumThread_needsClassification_idx" ON "ForumThread"("needsClassification"); + +-- CreateIndex +CREATE INDEX "ForumThread_playerId_idx" ON "ForumThread"("playerId"); + +-- CreateIndex +CREATE INDEX "ForumThread_currentBoard_idx" ON "ForumThread"("currentBoard"); + +-- CreateIndex +CREATE INDEX "ForumThreadBoard_threadId_idx" ON "ForumThreadBoard"("threadId"); + -- AddForeignKey ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -318,3 +368,12 @@ ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN -- AddForeignKey ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_linkedCharacterId_fkey" FOREIGN KEY ("linkedCharacterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ForumThreadBoard" ADD CONSTRAINT "ForumThreadBoard_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ForumThread"("threadId") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1574913..d787197 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -46,6 +46,19 @@ enum ProductTarget { IGNORE } +enum ForumBoard { + CUSTOM_CHARACTERS + AKTIVE_CCS + CC_ARCHIV +} + +enum ForumClassification { + NEW_CC + REDESIGN + REJECTED + IGNORE +} + enum ImportStatus { PENDING COMMITTED @@ -101,10 +114,11 @@ model Player { gextensionUsage Int? gextensionSyncedAt DateTime? - ledger CreditLedger[] - characters Character[] - orders ImportedOrder[] - unmatched UnmatchedPackage[] + ledger CreditLedger[] + characters Character[] + orders ImportedOrder[] + unmatched UnmatchedPackage[] + forumThreads ForumThread[] @@index([displayName]) } @@ -191,6 +205,7 @@ model Character { successors Character[] @relation("CharacterLineage") ledgerEntries CreditLedger[] + forumThreads ForumThread[] createdById Int? createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) @@ -290,3 +305,55 @@ model AuditLog { @@index([targetType, targetId]) @@index([actorUserId]) } + +/// Ein CC-Antrag im Forum. Die Thread-ID bleibt stabil, auch wenn der Thread +/// in ein anderes Board verschoben wird - deshalb ist sie der Schlüssel und +/// nicht die Board-Zugehörigkeit. +model ForumThread { + threadId Int @id + title String + url String + + authorName String? + /// Überlebt Namensänderungen im Forum, anders als der Anzeigename. + authorForumId Int? + + playerId Int? + player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull) + + currentBoard ForumBoard + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + lastBoardChangeAt DateTime? + + /// Bleibt leer, bis ein Mensch entschieden hat. Es gibt bewusst keine + /// Vorbelegung aus Titel oder Board - beides ist nicht verlässlich genug. + classification ForumClassification? + needsClassification Boolean @default(true) + classifiedById Int? + classifiedAt DateTime? + + linkedCharacterId Int? + linkedCharacter Character? @relation(fields: [linkedCharacterId], references: [id], onDelete: SetNull) + + /// Ein bereits entschiedener Thread ist in ein anderes Board gewandert. + /// Reine Gedächtnisstütze, löst nie eine Buchung aus. + boardChangeNotice Boolean @default(false) + + history ForumThreadBoard[] + + @@index([needsClassification]) + @@index([playerId]) + @@index([currentBoard]) +} + +/// Wann lag ein Thread in welchem Board. Für Streitfälle nachvollziehbar. +model ForumThreadBoard { + id Int @id @default(autoincrement()) + threadId Int + thread ForumThread @relation(fields: [threadId], references: [threadId], onDelete: Cascade) + board ForumBoard + observedAt DateTime @default(now()) + + @@index([threadId]) +} diff --git a/server/src/app.ts b/server/src/app.ts index 02fc324..c24c19f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -18,6 +18,7 @@ import { characterRoutes } from './routes/characters.routes.js'; import { importRoutes } from './routes/imports.routes.js'; import { adminRoutes } from './routes/admin.routes.js'; import { scraperRoutes } from './routes/scraper.routes.js'; +import { forumRoutes } from './routes/forum.routes.js'; const here = dirname(fileURLToPath(import.meta.url)); // Im Container liegt das gebaute Frontend neben dem kompilierten Server. @@ -123,6 +124,7 @@ export async function buildApp(): Promise { await app.register(importRoutes); await app.register(adminRoutes); await app.register(scraperRoutes); + await app.register(forumRoutes); if (existsSync(webRoot)) { await app.register(fastifyStatic, { diff --git a/server/src/env.ts b/server/src/env.ts index e4a40fd..fa2c52c 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -31,6 +31,10 @@ const schema = z.object({ // zugeschnitten; eine völlig andere Software ließe sich damit nicht lesen. GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'), + // Basis-Adresse des Forums für den Antrags-Abgleich. Der Parser ist auf + // WoltLab (Burning Board) zugeschnitten. + FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'), + // Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab. // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts new file mode 100644 index 0000000..bb2eafe --- /dev/null +++ b/server/src/lib/forum.ts @@ -0,0 +1,79 @@ +/** + * Liest Thread-Listen eines WoltLab-Forums (Burning Board). + * + * Gelesen wird ausschließlich die Übersichtsseite: sie enthält neben Titel und + * Thread-ID auch den Ersteller, sodass die Threads nicht einzeln geöffnet + * werden müssen. Das hält einen Lauf bei drei Anfragen statt einer je Thread. + * + * Wichtig: Eine Boardseite enthält neben der Thread-Liste auch Unterforen und + * globale Kästen ("letzte Beiträge"). Beides trägt ebenfalls Thread-Links, ist + * aber kein Thread dieses Boards - deshalb wird strikt entlang der + * tabularListRow-Einträge gelesen und nicht über alle Links im Dokument. + */ + +export interface ForumThread { + threadId: number; + title: string; + url: string; + authorName: string | null; + /// Stabiler als der Name: Forumsnamen ändern sich, die ID nicht. + authorForumId: number | null; + /// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück. + isMovedStub: boolean; +} + +const ROW_SPLIT = /
  • /; + +const TOPIC_LINK = + /]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/; +const USER_LINK = + /]*class="userLink"[^>]*>(?:]*><\/i>)?\s*(?:]*>)?\s*([^<]{1,80})/; + +// Ein zurückgelassener Verweis ist kein eigener Thread. +const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function parseBoard(html: string): ForumThread[] { + const threads: ForumThread[] = []; + const seen = new Set(); + + for (const block of html.split(ROW_SPLIT).slice(1)) { + // Nur bis zum Ende der Zeile schauen, sonst greift der nächste Eintrag rein. + const topic = TOPIC_LINK.exec(block); + if (!topic) continue; + + const threadId = Number(topic[2]); + if (!Number.isInteger(threadId) || seen.has(threadId)) continue; + seen.add(threadId); + + const user = USER_LINK.exec(block); + + threads.push({ + threadId, + title: decode(topic[3] ?? ''), + url: decode(topic[1] ?? ''), + authorName: user ? decode(user[2] ?? '') || null : null, + authorForumId: user ? Number(user[1]) : null, + isMovedStub: MOVED_MARKER.test(block), + }); + } + + return threads; +} + +/** Höchste Seitenzahl der Blätterleiste, mindestens 1. */ +export function parsePageCount(html: string): number { + 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/forum.routes.ts b/server/src/routes/forum.routes.ts new file mode 100644 index 0000000..2e6ee09 --- /dev/null +++ b/server/src/routes/forum.routes.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { ForumBoard, ForumClassification } from '@prisma/client'; +import { requireAdmin, requireAuth } from '../auth/guard.js'; +import { + assignPlayer, + classifyThread, + dismissBoardChangeNotice, + listBoardChangeNotices, + listThreads, + scanForum, +} from '../services/forum.js'; +import { contextOf, idParam, parseWith } from './helpers.js'; + +export async function forumRoutes(app: FastifyInstance): Promise { + app.post('/api/forum/scan', { preHandler: requireAdmin }, async (request) => { + return scanForum(contextOf(request)); + }); + + app.get('/api/forum/threads', { preHandler: requireAuth }, async (request) => { + const query = parseWith( + z.object({ + open: z.enum(['true', 'false']).default('true'), + playerId: z.coerce.number().int().positive().optional(), + board: z.nativeEnum(ForumBoard).optional(), + }), + request.query, + ); + + return { + items: await listThreads({ + onlyOpen: query.open === 'true', + ...(query.playerId !== undefined ? { playerId: query.playerId } : {}), + ...(query.board ? { board: query.board } : {}), + }), + }; + }); + + app.get('/api/forum/notices', { preHandler: requireAuth }, async () => { + return { items: await listBoardChangeNotices() }; + }); + + app.post('/api/forum/threads/:id/dismiss', { preHandler: requireAdmin }, 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) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ playerId: z.number().int().positive() }), + request.body, + ); + await assignPlayer(id, body.playerId, contextOf(request)); + return { ok: true }; + }); + + app.post('/api/forum/threads/:id/classify', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + classification: z.nativeEnum(ForumClassification), + characterName: z.string().trim().min(1).max(160).optional(), + loreText: z.string().trim().max(5000).optional(), + predecessorId: z.number().int().positive().optional(), + useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ']).optional(), + }), + request.body, + ); + + return classifyThread(id, body, contextOf(request)); + }); +} diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts new file mode 100644 index 0000000..beb2af7 --- /dev/null +++ b/server/src/services/forum.ts @@ -0,0 +1,354 @@ +import { ForumBoard, ForumClassification, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { env } from '../env.js'; +import { badRequest, conflict, notFound } from '../errors.js'; +import { parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js'; +import { recordAudit, type ActionContext } from './audit.js'; + +/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben +/// Unterforen auch eigene Threads, deshalb wird es mitgelesen. +export const BOARDS: { board: ForumBoard; path: string }[] = [ + { board: ForumBoard.CUSTOM_CHARACTERS, path: 'board/66-custom-characters' }, + { board: ForumBoard.AKTIVE_CCS, path: 'board/355-aktive-ccs' }, + { board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' }, +]; + +const MIN_REQUEST_GAP_MS = 1000; +const MAX_PAGES_PER_BOARD = 20; +let lastRequestAt = 0; + +async function throttle(): Promise { + const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + lastRequestAt = Date.now(); +} + +async function fetchPage(path: string, page: number): Promise { + await throttle(); + + const base = env.FORUM_BASE_URL.replace(/\/+$/, ''); + const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20000); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'user-agent': 'CC-Manager/1.0 (Antrags-Abgleich für das CC-Team)', + accept: 'text/html', + }, + }); + if (!response.ok) { + throw badRequest('BOARD_UNAVAILABLE', `Board nicht abrufbar (HTTP ${response.status})`); + } + return await response.text(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw badRequest('BOARD_TIMEOUT', 'Zeitüberschreitung beim Abruf des Boards'); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export interface ForumScanSummary { + scannedBoards: number; + scannedPages: number; + newThreads: number; + movedThreads: number; + boardChangeNotices: number; + skippedStubs: number; + matchedPlayers: number; + withoutPlayer: number; + failedBoards: { board: string; reason: string }[]; +} + +/** + * Liest die Boards und schreibt gefundene Threads fort. Klassifiziert wird + * bewusst nichts: weder Titel noch Board sind verlässlich genug, um daraus + * eine Buchung abzuleiten. Jeder neue Thread wartet auf eine Entscheidung. + */ +export async function scanForum(ctx: ActionContext): Promise { + const summary: ForumScanSummary = { + scannedBoards: 0, + scannedPages: 0, + newThreads: 0, + movedThreads: 0, + boardChangeNotices: 0, + skippedStubs: 0, + matchedPlayers: 0, + withoutPlayer: 0, + 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])); + + for (const { board, path } of BOARDS) { + let threads: ForumThread[] = []; + + try { + const first = await fetchPage(path, 1); + summary.scannedPages += 1; + threads = parseBoard(first); + + const pages = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD); + for (let page = 2; page <= pages; page += 1) { + const html = await fetchPage(path, page); + summary.scannedPages += 1; + threads.push(...parseBoard(html)); + } + summary.scannedBoards += 1; + } catch (error) { + summary.failedBoards.push({ + board: path, + reason: error instanceof Error ? error.message : 'Unbekannter Fehler', + }); + continue; + } + + for (const thread of threads) { + // Ein zurückgelassener Verweis ist kein eigener Antrag. + if (thread.isMovedStub) { + summary.skippedStubs += 1; + continue; + } + + const playerId = thread.authorName + ? byForumName.get(thread.authorName.toLowerCase()) ?? null + : null; + + const existing = await prisma.forumThread.findUnique({ + where: { threadId: thread.threadId }, + }); + + if (!existing) { + await prisma.forumThread.create({ + data: { + threadId: thread.threadId, + title: thread.title, + url: thread.url, + authorName: thread.authorName, + authorForumId: thread.authorForumId, + playerId, + currentBoard: board, + history: { create: [{ board }] }, + }, + }); + summary.newThreads += 1; + if (playerId) summary.matchedPlayers += 1; + else summary.withoutPlayer += 1; + continue; + } + + const movedBoard = existing.currentBoard !== board; + + await prisma.forumThread.update({ + where: { threadId: thread.threadId }, + data: { + title: thread.title, + url: thread.url, + authorName: thread.authorName, + authorForumId: thread.authorForumId, + lastSeenAt: new Date(), + // Eine später nachgetragene Zuordnung nicht wieder überschreiben. + ...(existing.playerId === null && playerId !== null ? { playerId } : {}), + ...(movedBoard + ? { + currentBoard: board, + lastBoardChangeAt: new Date(), + // Wandert ein bereits entschiedener Thread, ist das ein Hinweis + // für den Admin - nie eine automatische Statusänderung. + ...(existing.classification !== null ? { boardChangeNotice: true } : {}), + } + : {}), + }, + }); + + if (movedBoard) { + await prisma.forumThreadBoard.create({ data: { threadId: thread.threadId, board } }); + summary.movedThreads += 1; + if (existing.classification !== null) summary.boardChangeNotices += 1; + } + } + } + + await recordAudit(prisma, ctx, { + action: 'forum.scan', + targetType: 'forum', + targetId: null, + details: { ...summary } as unknown as Prisma.InputJsonValue, + }); + + return summary; +} + +export async function listThreads(options: { + onlyOpen?: boolean; + playerId?: number; + board?: ForumBoard; +}) { + return prisma.forumThread.findMany({ + where: { + ...(options.onlyOpen ? { needsClassification: true } : {}), + ...(options.playerId !== undefined ? { playerId: options.playerId } : {}), + ...(options.board ? { currentBoard: options.board } : {}), + }, + orderBy: [{ lastSeenAt: 'desc' }, { threadId: 'desc' }], + include: { + player: { select: { id: true, displayName: true } }, + linkedCharacter: { select: { id: true, name: true } }, + }, + }); +} + +/** Threads, bei denen nach der Entscheidung das Board gewechselt hat. */ +export async function listBoardChangeNotices() { + return prisma.forumThread.findMany({ + where: { boardChangeNotice: true }, + orderBy: { lastBoardChangeAt: 'desc' }, + include: { + player: { select: { id: true, displayName: true } }, + linkedCharacter: { select: { id: true, name: true, status: true } }, + }, + }); +} + +export async function dismissBoardChangeNotice(threadId: number, ctx: ActionContext): Promise { + const thread = await prisma.forumThread.findUnique({ where: { threadId } }); + if (!thread) throw notFound('Thread'); + + await prisma.forumThread.update({ + where: { threadId }, + data: { boardChangeNotice: false }, + }); + + await recordAudit(prisma, ctx, { + action: 'forum.notice_dismissed', + targetType: 'forum_thread', + targetId: threadId, + details: { title: thread.title, board: thread.currentBoard }, + }); +} + +/** Ordnet einen Thread nachträglich einem Spieler zu. */ +export async function assignPlayer( + threadId: number, + playerId: number, + ctx: ActionContext, +): Promise { + const [thread, player] = await Promise.all([ + prisma.forumThread.findUnique({ where: { threadId } }), + prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }), + ]); + if (!thread) throw notFound('Thread'); + if (!player) throw notFound('Spieler'); + + await prisma.forumThread.update({ where: { threadId }, data: { playerId } }); + + await recordAudit(prisma, ctx, { + action: 'forum.player_assigned', + targetType: 'forum_thread', + targetId: threadId, + details: { title: thread.title, playerId }, + }); +} + +export interface ClassifyInput { + classification: ForumClassification; + /// Nur bei NEW_CC und REDESIGN. + characterName?: string; + loreText?: string; + /// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt. + predecessorId?: number; + useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ'; +} + +/** + * Entscheidet über einen Antrag. Angenommene Anträge laufen durch dieselben + * Aktionen wie eine Eingabe von Hand - es gibt keinen zweiten Buchungsweg, + * der andere Regeln anwenden könnte. + */ +export async function classifyThread( + threadId: number, + input: ClassifyInput, + ctx: ActionContext, +): Promise<{ characterId: number | null }> { + const thread = await prisma.forumThread.findUnique({ where: { threadId } }); + if (!thread) throw notFound('Thread'); + if (thread.classification !== null) { + throw conflict('ALREADY_CLASSIFIED', 'Dieser Antrag wurde bereits entschieden'); + } + + let characterId: number | null = null; + + if ( + input.classification === ForumClassification.NEW_CC || + input.classification === ForumClassification.REDESIGN + ) { + if (thread.playerId === null) { + throw badRequest('NO_PLAYER', 'Dem Antrag ist kein Spieler zugeordnet'); + } + const name = input.characterName?.trim(); + if (!name) throw badRequest('NO_NAME', 'Für den CC wird ein Name gebraucht'); + + const { createCharacter, redesignCharacter } = await import('./characters.js'); + const note = `Forum-Antrag #${threadId}: ${thread.title}`; + + if (input.classification === ForumClassification.NEW_CC) { + const result = await createCharacter( + thread.playerId, + { name, loreText: input.loreText, note }, + ctx, + ); + characterId = result.character.id; + } else { + if (input.predecessorId === undefined) { + throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde'); + } + const result = await redesignCharacter( + input.predecessorId, + { + name, + loreText: input.loreText, + useKind: input.useKind ?? 'REDESIGN_KULANZ', + note, + }, + ctx, + ); + characterId = result.character.id; + } + } + + await prisma.forumThread.update({ + where: { threadId }, + data: { + classification: input.classification, + needsClassification: false, + classifiedById: ctx.actorId, + classifiedAt: new Date(), + linkedCharacterId: characterId, + }, + }); + + await recordAudit(prisma, ctx, { + action: 'forum.classified', + targetType: 'forum_thread', + targetId: threadId, + details: { + title: thread.title, + classification: input.classification, + playerId: thread.playerId, + characterId, + }, + }); + + return { characterId }; +} diff --git a/server/test/fixtures/board.html b/server/test/fixtures/board.html new file mode 100644 index 0000000..2bc1fbe --- /dev/null +++ b/server/test/fixtures/board.html @@ -0,0 +1,50 @@ + + + + + +
      +
    1. + +
    2. +
    3. + +
    4. +
    5. + +
    6. +
    + + + diff --git a/server/test/forum.test.ts b/server/test/forum.test.ts new file mode 100644 index 0000000..2097045 --- /dev/null +++ b/server/test/forum.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { parseBoard, parsePageCount } from '../src/lib/forum.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8'); + +describe('Board-Übersicht lesen', () => { + const threads = parseBoard(BOARD); + + it('liest die Threads der Liste', () => { + assert.equal(threads.length, 3); + assert.deepEqual( + threads.map((t) => t.threadId), + [7001, 7002, 7003], + ); + }); + + it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => { + const first = threads.find((t) => t.threadId === 7001); + assert.equal(first?.authorName, 'Antragsteller'); + assert.equal(first?.authorForumId, 501, 'die Forum-ID überlebt Namensänderungen'); + }); + + it('löst Umlaut- und Anführungszeichen-Entities auf', () => { + const second = threads.find((t) => t.threadId === 7002); + assert.equal(second?.title, 'Custom Character Redesign | Alt zu Neu'); + }); + + it('erkennt einen zurückgelassenen Verweis als solchen', () => { + const stub = threads.find((t) => t.threadId === 7003); + assert.equal(stub?.isMovedStub, true); + }); + + it('ignoriert Unterforen und deren letzte Beiträge', () => { + // 9999 steht im wbbBoardNode-Kasten, gehoert aber nicht in dieses Board. + assert.equal(threads.some((t) => t.threadId === 9999), false); + }); + + it('liest die Seitenzahl aus der Blätterleiste', () => { + assert.equal(parsePageCount(BOARD), 3); + assert.equal(parsePageCount('ohne Blaetterleiste'), 1); + }); + + it('kommt mit einer leeren Seite klar', () => { + assert.deepEqual(parseBoard('nichts'), []); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 0ced32a..fb32ac1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import type { Role } from './lib/types'; import Account from './pages/Account'; import Admin from './pages/Admin'; import Dashboard from './pages/Dashboard'; +import Forum from './pages/Forum'; import Import from './pages/Import'; import Login from './pages/Login'; import PlayerDetail from './pages/PlayerDetail'; @@ -69,6 +70,17 @@ export default function App() { } /> + + + + + + } + /> + Spieler + Anträge {can('ADMIN') && Import} {can('ADMIN') && Verwaltung} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 66b08f6..835753a 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -47,3 +47,16 @@ export function formatDate(value: string | null | undefined): string { export function formatAmount(amount: number): string { return amount > 0 ? `+${amount}` : String(amount); } + +export const BOARD_LABEL: Record = { + CUSTOM_CHARACTERS: 'Anträge', + AKTIVE_CCS: 'Aktive CCs', + CC_ARCHIV: 'Archiv', +}; + +export const CLASSIFICATION_LABEL: Record = { + NEW_CC: 'Angenommen – neuer CC', + REDESIGN: 'Angenommen – Redesign', + REJECTED: 'Abgelehnt', + IGNORE: 'Ignoriert', +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1b2c500..a8842e7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -191,3 +191,37 @@ export interface ImportRecord { importedBy: { username: string } | null; rows?: ImportRow[]; } + +export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV'; +export type ForumClassification = 'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'; + +export interface ForumThread { + threadId: number; + title: string; + url: string; + authorName: string | null; + authorForumId: number | null; + playerId: number | null; + player: { id: number; displayName: string } | null; + currentBoard: ForumBoard; + firstSeenAt: string; + lastSeenAt: string; + lastBoardChangeAt: string | null; + classification: ForumClassification | null; + needsClassification: boolean; + linkedCharacterId: number | null; + linkedCharacter: { id: number; name: string; status?: string } | null; + boardChangeNotice: boolean; +} + +export interface ForumScanSummary { + scannedBoards: number; + scannedPages: number; + newThreads: number; + movedThreads: number; + boardChangeNotices: number; + skippedStubs: number; + matchedPlayers: number; + withoutPlayer: number; + failedBoards: { board: string; reason: string }[]; +} diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx new file mode 100644 index 0000000..7995d92 --- /dev/null +++ b/web/src/pages/Forum.tsx @@ -0,0 +1,454 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format'; +import { useSession } from '../lib/session'; +import type { + Character, + ForumScanSummary, + ForumThread, + PlayerDetail, + PlayerSummary, +} from '../lib/types'; + +export default function Forum() { + const { can } = useSession(); + const admin = can('ADMIN'); + + const [threads, setThreads] = useState([]); + const [notices, setNotices] = useState([]); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(false); + const [dialog, setDialog] = useState(null); + const [assigning, setAssigning] = useState(null); + + const load = useCallback(async () => { + try { + const [open, notice] = await Promise.all([ + api<{ items: ForumThread[] }>('/api/forum/threads?open=true'), + api<{ items: ForumThread[] }>('/api/forum/notices'), + ]); + setThreads(open.items); + setNotices(notice.items); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const scan = async () => { + setBusy(true); + setError(null); + try { + const result = await api('/api/forum/scan', { method: 'POST', body: {} }); + const parts = [`${result.scannedPages} Seiten gelesen`]; + if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`); + if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`); + if (result.withoutPlayer > 0) parts.push(`${result.withoutPlayer} ohne Spieler`); + if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise übersprungen`); + if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`); + setFlash(parts.join(', ') + '.'); + await load(); + window.setTimeout(() => setFlash(null), 8000); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const dismiss = async (thread: ForumThread) => { + try { + await api(`/api/forum/threads/${thread.threadId}/dismiss`, { method: 'POST', body: {} }); + await load(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Konnte nicht ausgeblendet werden'); + } + }; + + return ( +
    +
    +
    +

    Forums-Anträge

    +

    + Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches + Signal und lösen nie eine Buchung aus. +

    +
    + {admin && ( + + )} +
    + + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + {notices.length > 0 && ( +
    +

    Verschoben nach der Entscheidung ({notices.length})

    + + Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem + anderen Board. Das ist nur ein Hinweis — geprüft und geklickt wird von Hand. + + +
      + {notices.map((thread) => ( +
    • +
      + {thread.title} + {BOARD_LABEL[thread.currentBoard]} +

      + {thread.classification && CLASSIFICATION_LABEL[thread.classification]} + {thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`} + {thread.lastBoardChangeAt && ` · verschoben ${formatDate(thread.lastBoardChangeAt)}`} +

      +
      +
      + + Thread öffnen + + {admin && ( + + )} +
      +
    • + ))} +
    +
    + )} + +
    +

    Wartet auf Entscheidung ({threads.length})

    + + {threads.length === 0 ? ( +

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

    + ) : ( +
      + {threads.map((thread) => ( +
    • +
      + {thread.title} + {BOARD_LABEL[thread.currentBoard]} +

      + von {thread.authorName ?? 'unbekannt'} ·{' '} + {thread.player ? ( + {thread.player.displayName} + ) : ( + kein Spieler zugeordnet + )}{' '} + · gesehen {formatDate(thread.lastSeenAt)} +

      +
      +
      + + Thread + + {admin && !thread.player && ( + + )} + {admin && ( + + )} +
      +
    • + ))} +
    + )} +
    + + {dialog && ( + setDialog(null)} + onDone={(message) => { + setDialog(null); + setFlash(message); + void load(); + window.setTimeout(() => setFlash(null), 6000); + }} + onError={setError} + /> + )} + + {assigning && ( + setAssigning(null)} + onDone={() => { + setAssigning(null); + void load(); + }} + onError={setError} + /> + )} +
    + ); +} + +function ClassifyModal({ + thread, + onClose, + onDone, + onError, +}: { + thread: ForumThread; + onClose: () => void; + onDone: (message: string) => void; + onError: (message: string) => void; +}) { + const [choice, setChoice] = useState<'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'>('NEW_CC'); + const [name, setName] = useState(thread.title); + const [predecessorId, setPredecessorId] = useState(null); + const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ'); + const [characters, setCharacters] = useState([]); + const [busy, setBusy] = useState(false); + + // Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers. + useEffect(() => { + if (!thread.playerId) return; + void (async () => { + try { + const detail = await api(`/api/players/${thread.playerId}`); + setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE')); + } catch { + // Ohne Liste bleibt nur die Ablehnung - das Formular sagt es. + } + })(); + }, [thread.playerId]); + + const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN'; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + try { + await api(`/api/forum/threads/${thread.threadId}/classify`, { + method: 'POST', + body: { + classification: choice, + ...(needsPlayer ? { characterName: name.trim() } : {}), + ...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}), + }, + }); + onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Entscheidung fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( + +
    + + {thread.title} +
    + von {thread.authorName ?? 'unbekannt'} + {thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'} +
    + +
    + {( + [ + ['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'], + ['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'], + ['REJECTED', 'Abgelehnt', 'keine Buchung'], + ['IGNORE', 'Kein Antrag', 'keine Buchung'], + ] as const + ).map(([value, label, hint]) => ( + + ))} +
    + + {needsPlayer && !thread.playerId && ( + + Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen". + + )} + + {needsPlayer && thread.playerId && ( + <> + + setName(event.target.value)} + required + /> +

    Vorbelegt mit dem Thread-Titel — bitte anpassen.

    + + )} + + {choice === 'REDESIGN' && thread.playerId && ( + <> + + + {characters.length === 0 && ( +

    Dieser Spieler hat keine aktiven CCs.

    + )} + + +
    + + +
    + + )} + +
    + + +
    +
    +
    + ); +} + +function AssignModal({ + thread, + onClose, + onDone, + onError, +}: { + thread: ForumThread; + onClose: () => void; + onDone: () => void; + onError: (message: string) => void; +}) { + const [query, setQuery] = useState(thread.authorName ?? ''); + const [results, setResults] = useState([]); + + useEffect(() => { + const timer = setTimeout(async () => { + try { + const params = new URLSearchParams({ q: query, take: '20' }); + const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`); + setResults(data.items); + } catch { + // Suchfehler blockieren den Dialog nicht. + } + }, 250); + return () => clearTimeout(timer); + }, [query]); + + const assign = async (playerId: number) => { + try { + await api(`/api/forum/threads/${thread.threadId}/player`, { + method: 'POST', + body: { playerId }, + }); + onDone(); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Zuordnen fehlgeschlagen'); + } + }; + + return ( + + + Forumsname des Antragstellers: {thread.authorName ?? 'unbekannt'}. Passt + kein Eintrag, trag den Forumsnamen beim Spieler nach — dann findet der nächste Abgleich + ihn von allein. + + + + setQuery(event.target.value)} + autoFocus + /> + +
      + {results.map((player) => ( +
    • +
      + {player.displayName} + {player.forumName && · Forum: {player.forumName}} +
      +
      + +
      +
    • + ))} + {results.length === 0 &&

      Kein Treffer.

      } +
    +
    + ); +}