Forums-Anträge erfassen und von Hand entscheiden
Der GExtension-Abgleich liefert die Käufe, der Verbrauch steht im Forum. Die App liest jetzt die drei CC-Boards und sammelt die Anträge. Gelesen wird nur die Board-Übersicht: sie führt bereits den Ersteller, nicht nur den letzten Antwortenden. Damit bleibt ein Lauf bei wenigen Anfragen statt einer je Thread. Unterforen und globale Kästen tragen ebenfalls Thread-Links, deshalb liest der Parser strikt entlang der Listeneinträge - sonst landen Changelogs und Bewerbungen in der Antragsliste. Klassifiziert wird ausschließlich von Hand. Weder Titel noch Board sind verlässlich genug: Threads werden nicht immer verschoben, ein genehmigter Antrag kann weiter im Antrags-Board liegen. Das Board steht deshalb nur als Hinweis an der Zeile. Angenommene Anträge rufen dieselben Aktionen wie die Knöpfe auf der Spielerseite, es gibt keinen zweiten Buchungsweg mit eigenen Regeln. Wandert ein bereits entschiedener Thread später in ein anderes Board, erscheint das als Hinweis zum Abhaken - der CC-Status ändert sich nie von allein. Die Thread-ID bleibt beim Verschieben stabil und traegt deshalb die Wiedererkennung; die Board-Historie wird mitgeschrieben. Zurückgelassene Verweise erkennt der Parser und zählt sie nicht als eigenen Antrag.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
await app.register(importRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(scraperRoutes);
|
||||
await app.register(forumRoutes);
|
||||
|
||||
if (existsSync(webRoot)) {
|
||||
await app.register(fastifyStatic, {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = /<li class="tabularListRow">/;
|
||||
|
||||
const TOPIC_LINK =
|
||||
/<a\s+href="([^"]*thread\/(\d+)-[^"]*)"[^>]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/;
|
||||
const USER_LINK =
|
||||
/<a\s+href="[^"]*user\/(\d+)-[^"]*"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\s*([^<]{1,80})/;
|
||||
|
||||
// Ein zurückgelassener Verweis ist kein eigener Thread.
|
||||
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
|
||||
|
||||
function decode(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseBoard(html: string): ForumThread[] {
|
||||
const threads: ForumThread[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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<ForumScanSummary> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Nachbau einer WoltLab-Boardübersicht mit erfundenen Daten.
|
||||
Die echte Seite baut die Thread-Liste genauso auf. -->
|
||||
<html lang="de"><body>
|
||||
<div class="wbbBoardNode">
|
||||
<div class="wbbBoardNode__title"><a href="https://forum.example.org/index.php?board/99-unterforum/">Ein Unterforum</a></div>
|
||||
<div class="wbbBoardNode__lastPost">
|
||||
<a href="https://forum.example.org/index.php?thread/9999-letzter-beitrag-im-unterforum/" class="wbbBoardNode__lastPostLink wbbTopicLink" data-object-id="9999">Letzter Beitrag im Unterforum</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="tabularList wbbTopicList">
|
||||
<li class="tabularListRow">
|
||||
<ul class="tabularListColumns">
|
||||
<li class="columnIcon columnAvatar">
|
||||
<a href="https://forum.example.org/index.php?user/501-antragsteller/" data-object-id="501" class="userLink"><i class="sl"></i><span style="color: #333;">Antragsteller</span></a>
|
||||
</li>
|
||||
<li class="columnSubject">
|
||||
<a href="https://forum.example.org/index.php?thread/7001-cc-antrag-testfigur/" class="messageGroupLink wbbTopicLink" data-object-id="7001">CC Antrag | Testfigur</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="tabularListRow">
|
||||
<ul class="tabularListColumns">
|
||||
<li class="columnIcon columnAvatar">
|
||||
<a href="https://forum.example.org/index.php?user/502-zweiter-nutzer/" data-object-id="502" class="userLink"><span>Zweiter Nutzer</span></a>
|
||||
</li>
|
||||
<li class="columnSubject">
|
||||
<a href="https://forum.example.org/index.php?thread/7002-custom-character-redesign-alt/" class="messageGroupLink wbbTopicLink" data-object-id="7002">Custom Character Redesign | Alt zu Neu</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="tabularListRow">
|
||||
<ul class="tabularListColumns">
|
||||
<li class="columnIcon columnAvatar">
|
||||
<a href="https://forum.example.org/index.php?user/501-antragsteller/" data-object-id="501" class="userLink"><span>Antragsteller</span></a>
|
||||
</li>
|
||||
<li class="columnSubject">
|
||||
<span class="badge label green">Verschoben</span>
|
||||
<a href="https://forum.example.org/index.php?thread/7003-verschobener-thread/" class="messageGroupLink wbbTopicLink wbbTopicLinkMoved" data-object-id="7003">Ein verschobener Thread</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<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=3" class="pageNavigationLink">3</a></li>
|
||||
</ul></nav>
|
||||
</body></html>
|
||||
@@ -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('<html>ohne Blaetterleiste</html>'), 1);
|
||||
});
|
||||
|
||||
it('kommt mit einer leeren Seite klar', () => {
|
||||
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/forum"
|
||||
element={
|
||||
<Protected>
|
||||
<Layout>
|
||||
<Forum />
|
||||
</Layout>
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/import"
|
||||
element={
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
<NavLink to="/" end>
|
||||
Spieler
|
||||
</NavLink>
|
||||
<NavLink to="/forum">Anträge</NavLink>
|
||||
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
|
||||
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
||||
</nav>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
CUSTOM_CHARACTERS: 'Anträge',
|
||||
AKTIVE_CCS: 'Aktive CCs',
|
||||
CC_ARCHIV: 'Archiv',
|
||||
};
|
||||
|
||||
export const CLASSIFICATION_LABEL: Record<string, string> = {
|
||||
NEW_CC: 'Angenommen – neuer CC',
|
||||
REDESIGN: 'Angenommen – Redesign',
|
||||
REJECTED: 'Abgelehnt',
|
||||
IGNORE: 'Ignoriert',
|
||||
};
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -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<ForumThread[]>([]);
|
||||
const [notices, setNotices] = useState<ForumThread[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dialog, setDialog] = useState<ForumThread | null>(null);
|
||||
const [assigning, setAssigning] = useState<ForumThread | null>(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<ForumScanSummary>('/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 (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Forums-Anträge</h1>
|
||||
<p className="muted">
|
||||
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
|
||||
Signal und lösen nie eine Buchung aus.
|
||||
</p>
|
||||
</div>
|
||||
{admin && (
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
||||
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||
|
||||
{notices.length > 0 && (
|
||||
<section className="block block-flag">
|
||||
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
||||
<Notice kind="warn">
|
||||
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.
|
||||
</Notice>
|
||||
|
||||
<ul className="cc-list">
|
||||
{notices.map((thread) => (
|
||||
<li key={thread.threadId} className="cc-item">
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||
<p className="muted small">
|
||||
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
|
||||
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
|
||||
{thread.lastBoardChangeAt && ` · verschoben ${formatDate(thread.lastBoardChangeAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
||||
Thread öffnen
|
||||
</a>
|
||||
{admin && (
|
||||
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
|
||||
Erledigt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="block">
|
||||
<h2>Wartet auf Entscheidung ({threads.length})</h2>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<p className="muted">
|
||||
Keine offenen Anträge. {admin && 'Über „Forum abgleichen" nach neuen suchen.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="cc-list">
|
||||
{threads.map((thread) => (
|
||||
<li key={thread.threadId} className="cc-item">
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||
<p className="muted small">
|
||||
von {thread.authorName ?? 'unbekannt'} ·{' '}
|
||||
{thread.player ? (
|
||||
<Link to={`/players/${thread.player.id}`}>{thread.player.displayName}</Link>
|
||||
) : (
|
||||
<span className="neg">kein Spieler zugeordnet</span>
|
||||
)}{' '}
|
||||
· gesehen {formatDate(thread.lastSeenAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
||||
Thread
|
||||
</a>
|
||||
{admin && !thread.player && (
|
||||
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
||||
Spieler zuordnen
|
||||
</button>
|
||||
)}
|
||||
{admin && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => setDialog(thread)}
|
||||
>
|
||||
Entscheiden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<ClassifyModal
|
||||
thread={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(message) => {
|
||||
setDialog(null);
|
||||
setFlash(message);
|
||||
void load();
|
||||
window.setTimeout(() => setFlash(null), 6000);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{assigning && (
|
||||
<AssignModal
|
||||
thread={assigning}
|
||||
onClose={() => setAssigning(null)}
|
||||
onDone={() => {
|
||||
setAssigning(null);
|
||||
void load();
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | null>(null);
|
||||
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
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<PlayerDetail>(`/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 (
|
||||
<Modal title="Antrag entscheiden" onClose={onClose}>
|
||||
<form onSubmit={submit}>
|
||||
<Notice kind="info">
|
||||
<strong>{thread.title}</strong>
|
||||
<br />
|
||||
von {thread.authorName ?? 'unbekannt'}
|
||||
{thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'}
|
||||
</Notice>
|
||||
|
||||
<div className="radio-group">
|
||||
{(
|
||||
[
|
||||
['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]) => (
|
||||
<label key={value} className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="classification"
|
||||
checked={choice === value}
|
||||
onChange={() => setChoice(value)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<span className="muted"> — {hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{needsPlayer && !thread.playerId && (
|
||||
<Notice kind="warn">
|
||||
Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen".
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{needsPlayer && thread.playerId && (
|
||||
<>
|
||||
<label htmlFor="cc-name">Name des CCs</label>
|
||||
<input
|
||||
id="cc-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="muted small">Vorbelegt mit dem Thread-Titel — bitte anpassen.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{choice === 'REDESIGN' && thread.playerId && (
|
||||
<>
|
||||
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
|
||||
<select
|
||||
id="vorgaenger"
|
||||
value={predecessorId ?? ''}
|
||||
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
|
||||
required
|
||||
>
|
||||
<option value="">— bitte wählen —</option>
|
||||
{characters.map((character) => (
|
||||
<option key={character.id} value={character.id}>
|
||||
{character.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{characters.length === 0 && (
|
||||
<p className="muted small neg">Dieser Spieler hat keine aktiven CCs.</p>
|
||||
)}
|
||||
|
||||
<label>Womit bezahlen?</label>
|
||||
<div className="radio-group">
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="useKind"
|
||||
checked={useKind === 'REDESIGN_KULANZ'}
|
||||
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||
/>
|
||||
<span>Kulanz-Redesign</span>
|
||||
</label>
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="useKind"
|
||||
checked={useKind === 'REDESIGN_PAID'}
|
||||
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||
/>
|
||||
<span>Bezahltes Redesign</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || (needsPlayer && !thread.playerId)}
|
||||
>
|
||||
{busy ? 'Speichere …' : 'Entscheiden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlayerSummary[]>([]);
|
||||
|
||||
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 (
|
||||
<Modal title="Spieler zuordnen" onClose={onClose}>
|
||||
<Notice kind="info">
|
||||
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
|
||||
kein Eintrag, trag den Forumsnamen beim Spieler nach — dann findet der nächste Abgleich
|
||||
ihn von allein.
|
||||
</Notice>
|
||||
|
||||
<label htmlFor="suche">Spieler suchen</label>
|
||||
<input
|
||||
id="suche"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
|
||||
{results.map((player) => (
|
||||
<li key={player.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{player.displayName}</strong>
|
||||
{player.forumName && <span className="muted"> · Forum: {player.forumName}</span>}
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => void assign(player.id)}
|
||||
>
|
||||
Zuordnen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user