Antraege eingrenzen, Reaktivierung und Bestaetigungen

Sechs Ruecklaeufer aus dem ersten Praxistest:

Das Archiv reicht ueber 25 Seiten und enthaelt vieles, was kein CC-Antrag ist.
Aufgenommen werden jetzt nur Threads von Spielern, die im CC-Manager mit
passendem Forum-Namen erfasst sind. Angepinnte Ankuendigungen tragen die
Labels "Wichtig" oder "Information" und fallen darueber raus.

Der Titel wird bewusst nirgends ausgewertet - weder zum Erkennen noch zum
Aussortieren. Antraege heissen mal "CC Antrag | Name", mal "The Duke Rang
Update"; daran ist nichts abzulesen.

Eine Reaktivierung laeuft ebenfalls ueber einen Forums-Antrag und fehlte als
Entscheidung. Sie ist jetzt waehlbar und kostet nichts, solange der CC nicht
redesignt wurde - die bestehende Regel entscheidet das, nicht der Dialog.

Der Titel in der Liste verlinkt jetzt auf den Thread im Forum. Die Labels
stehen als Kontext daneben, ohne die Entscheidung vorwegzunehmen.

Wer einen Antrag von Hand eintraegt, kann beim Anlegen oder Redesignen den
Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht nicht
erneut als offen auf.

Manuelle Guthaben-Buchungen fragen vorher nach: bisher buchte ein einzelner
Klick sofort, ohne zu sagen, was genau passiert. Der Dialog nennt die Wirkung
und nimmt eine Notiz entgegen.
This commit is contained in:
2026-09-18 18:06:37 +02:00
parent 751f8bfe8e
commit bebb89c10e
13 changed files with 503 additions and 59 deletions
+13 -3
View File
@@ -183,13 +183,23 @@ Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antr
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile. 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, 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 welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite — angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
es gibt keinen zweiten Buchungsweg mit eigenen Regeln. es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler. Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler.
Ohne Treffer bleibt der Antrag in der Liste und lässt sich von Hand zuordnen; trägst du den **Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
Forumsnamen beim Spieler nach, findet ihn der nächste Abgleich von allein. zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" — daran ist nichts zu erkennen.
Trägst du einen Antrag von Hand ein — über *Neuer CC* oder *Redesign* auf der Spielerseite —
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
nächsten Abgleich nicht erneut auf.
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die 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 Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
@@ -0,0 +1,8 @@
-- Reaktivierung als eigene Entscheidung, Labels und Thread-Verknuepfung.
DO $$ BEGIN
ALTER TYPE "ForumClassification" ADD VALUE IF NOT EXISTS 'REACTIVATION';
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- Labels des Threads, nur zur Anzeige als Kontext.
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "labels" TEXT;
+4
View File
@@ -55,6 +55,8 @@ enum ForumBoard {
enum ForumClassification { enum ForumClassification {
NEW_CC NEW_CC
REDESIGN REDESIGN
/// Ein inaktiver CC kehrt zurueck. Ohne Redesign kostet das nichts.
REACTIVATION
REJECTED REJECTED
IGNORE IGNORE
} }
@@ -317,6 +319,8 @@ model ForumThread {
authorName String? authorName String?
/// Überlebt Namensänderungen im Forum, anders als der Anzeigename. /// Überlebt Namensänderungen im Forum, anders als der Anzeigename.
authorForumId Int? authorForumId Int?
/// Labels des Threads, kommagetrennt. Reiner Kontext für die Entscheidung.
labels String?
playerId Int? playerId Int?
player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull) player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull)
+32
View File
@@ -20,6 +20,16 @@ export interface ForumThread {
authorForumId: number | null; authorForumId: number | null;
/// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück. /// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück.
isMovedStub: boolean; isMovedStub: boolean;
/// Labels des Threads, etwa "Wichtig" oder "SCP-RP".
labels: string[];
}
/// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs,
/// Regelwerke, Bewerbungsphasen. Sie tragen diese Labels und sind keine Anträge.
export const IGNORED_LABELS = ['wichtig', 'information'];
export function hasIgnoredLabel(thread: ForumThread): boolean {
return thread.labels.some((label) => IGNORED_LABELS.includes(label.toLowerCase()));
} }
const ROW_SPLIT = /<li class="tabularListRow">/; const ROW_SPLIT = /<li class="tabularListRow">/;
@@ -32,6 +42,9 @@ const USER_LINK =
// Ein zurückgelassener Verweis ist kein eigener Thread. // Ein zurückgelassener Verweis ist kein eigener Thread.
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i; const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
// Labels hängen als eigene Links über dem Titel, nicht als span.
const LABEL = /<a[^>]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g;
function decode(value: string): string { function decode(value: string): string {
return value return value
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
@@ -59,6 +72,9 @@ export function parseBoard(html: string): ForumThread[] {
const user = USER_LINK.exec(block); const user = USER_LINK.exec(block);
LABEL.lastIndex = 0;
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
threads.push({ threads.push({
threadId, threadId,
title: decode(topic[3] ?? ''), title: decode(topic[3] ?? ''),
@@ -66,6 +82,7 @@ export function parseBoard(html: string): ForumThread[] {
authorName: user ? decode(user[2] ?? '') || null : null, authorName: user ? decode(user[2] ?? '') || null : null,
authorForumId: user ? Number(user[1]) : null, authorForumId: user ? Number(user[1]) : null,
isMovedStub: MOVED_MARKER.test(block), isMovedStub: MOVED_MARKER.test(block),
labels,
}); });
} }
@@ -77,3 +94,18 @@ export function parsePageCount(html: string): number {
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1])); const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
return numbers.length > 0 ? Math.max(1, ...numbers) : 1; return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
} }
/**
* Zieht die Thread-ID aus einer Forums-URL. Angenommen werden der volle Link,
* der Pfad oder die blanke Nummer - beim Eintippen von Hand landet mal das
* eine, mal das andere im Feld.
*/
export function parseThreadId(input: string): number | null {
const value = input.trim();
if (value.length === 0) return null;
const fromUrl = /thread\/(\d+)/.exec(value);
if (fromUrl) return Number(fromUrl[1]);
return /^\d+$/.test(value) ? Number(value) : null;
}
+28 -2
View File
@@ -11,8 +11,27 @@ import {
revertCharacter, revertCharacter,
updateCharacter, updateCharacter,
} from '../services/characters.js'; } from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js';
import { badRequest } from '../errors.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
/** Hakt den zugehörigen Forums-Thread ab, wenn einer angegeben wurde. */
async function linkThread(
threadInput: string | undefined,
characterId: number,
classification: 'NEW_CC' | 'REDESIGN',
ctx: ReturnType<typeof contextOf>,
): Promise<void> {
if (!threadInput?.trim()) return;
const threadId = parseThreadId(threadInput);
if (threadId === null) {
throw badRequest('INVALID_THREAD', 'Der Thread-Link konnte nicht gelesen werden');
}
await linkThreadToCharacter(threadId, characterId, classification, ctx);
}
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
@@ -24,10 +43,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(), loreText: z.string().trim().max(5000).optional(),
note: optionalNote, note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}), }),
request.body, request.body,
); );
const result = await createCharacter(id, body, contextOf(request)); const ctx = contextOf(request);
const result = await createCharacter(id, body, ctx);
await linkThread(body.forumThread, result.character.id, 'NEW_CC', ctx);
reply.code(201); reply.code(201);
return result; return result;
}); });
@@ -52,10 +74,14 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
loreText: z.string().trim().max(5000).optional(), loreText: z.string().trim().max(5000).optional(),
useKind: redesignKind, useKind: redesignKind,
note: optionalNote, note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}), }),
request.body, request.body,
); );
return redesignCharacter(id, body, contextOf(request)); const ctx = contextOf(request);
const result = await redesignCharacter(id, body, ctx);
await linkThread(body.forumThread, result.character.id, 'REDESIGN', ctx);
return result;
}); });
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => { app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
+2 -1
View File
@@ -64,7 +64,8 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
characterName: z.string().trim().min(1).max(160).optional(), characterName: z.string().trim().min(1).max(160).optional(),
loreText: z.string().trim().max(5000).optional(), loreText: z.string().trim().max(5000).optional(),
predecessorId: z.number().int().positive().optional(), predecessorId: z.number().int().positive().optional(),
useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ']).optional(), characterId: z.number().int().positive().optional(),
useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']).optional(),
}), }),
request.body, request.body,
); );
+122 -13
View File
@@ -2,7 +2,7 @@ import { ForumBoard, ForumClassification, type Prisma } from '@prisma/client';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { env } from '../env.js'; import { env } from '../env.js';
import { badRequest, conflict, notFound } from '../errors.js'; import { badRequest, conflict, notFound } from '../errors.js';
import { parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js'; import { hasIgnoredLabel, parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js';
import { recordAudit, type ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben /// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben
@@ -61,8 +61,10 @@ export interface ForumScanSummary {
movedThreads: number; movedThreads: number;
boardChangeNotices: number; boardChangeNotices: number;
skippedStubs: number; skippedStubs: number;
skippedLabels: number;
/// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon.
skippedUnknownAuthor: number;
matchedPlayers: number; matchedPlayers: number;
withoutPlayer: number;
failedBoards: { board: string; reason: string }[]; failedBoards: { board: string; reason: string }[];
} }
@@ -79,8 +81,9 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
movedThreads: 0, movedThreads: 0,
boardChangeNotices: 0, boardChangeNotices: 0,
skippedStubs: 0, skippedStubs: 0,
skippedLabels: 0,
skippedUnknownAuthor: 0,
matchedPlayers: 0, matchedPlayers: 0,
withoutPlayer: 0,
failedBoards: [], failedBoards: [],
}; };
@@ -121,6 +124,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
continue; continue;
} }
// Angepinnte Ankündigungen liegen mit in den Boards.
if (hasIgnoredLabel(thread)) {
summary.skippedLabels += 1;
continue;
}
const playerId = thread.authorName const playerId = thread.authorName
? byForumName.get(thread.authorName.toLowerCase()) ?? null ? byForumName.get(thread.authorName.toLowerCase()) ?? null
: null; : null;
@@ -129,6 +138,15 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
where: { threadId: thread.threadId }, where: { threadId: thread.threadId },
}); });
// Das Archiv umfasst Jahre und enthält vieles, was kein CC-Antrag ist.
// Ohne zuordenbaren Spieler wird ein Thread deshalb gar nicht erst
// erfasst - sonst ertrinkt die Liste in Fremdem. Bereits bekannte
// Threads werden weiter gepflegt, auch wenn die Zuordnung fehlt.
if (!existing && playerId === null) {
summary.skippedUnknownAuthor += 1;
continue;
}
if (!existing) { if (!existing) {
await prisma.forumThread.create({ await prisma.forumThread.create({
data: { data: {
@@ -137,14 +155,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
url: thread.url, url: thread.url,
authorName: thread.authorName, authorName: thread.authorName,
authorForumId: thread.authorForumId, authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
playerId, playerId,
currentBoard: board, currentBoard: board,
history: { create: [{ board }] }, history: { create: [{ board }] },
}, },
}); });
summary.newThreads += 1; summary.newThreads += 1;
if (playerId) summary.matchedPlayers += 1; summary.matchedPlayers += 1;
else summary.withoutPlayer += 1;
continue; continue;
} }
@@ -157,6 +175,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
url: thread.url, url: thread.url,
authorName: thread.authorName, authorName: thread.authorName,
authorForumId: thread.authorForumId, authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
lastSeenAt: new Date(), lastSeenAt: new Date(),
// Eine später nachgetragene Zuordnung nicht wieder überschreiben. // Eine später nachgetragene Zuordnung nicht wieder überschreiben.
...(existing.playerId === null && playerId !== null ? { playerId } : {}), ...(existing.playerId === null && playerId !== null ? { playerId } : {}),
@@ -268,7 +287,9 @@ export interface ClassifyInput {
loreText?: string; loreText?: string;
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt. /// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
predecessorId?: number; predecessorId?: number;
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ'; /// Nur bei REACTIVATION: welcher inaktive CC kehrt zurück.
characterId?: number;
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ' | 'CC';
} }
/** /**
@@ -289,7 +310,33 @@ export async function classifyThread(
let characterId: number | null = null; let characterId: number | null = null;
if ( if (input.classification === ForumClassification.REACTIVATION) {
if (input.characterId === undefined) {
throw badRequest('NO_CHARACTER', 'Bitte angeben, welcher CC zurückkehrt');
}
const { reactivateFree, reactivatePaid } = await import('./characters.js');
const note = `Forum-Antrag #${threadId}: ${thread.title}`;
// Ein unverändert existierender CC kommt gratis zurück; nur ein redesignter
// muss neu erschaffen werden. Welcher Fall vorliegt, entscheidet die
// bestehende Regel - hier wird sie nicht umgangen.
const target = await prisma.character.findUnique({ where: { id: input.characterId } });
if (!target) throw notFound('CC');
if (target.inactiveReason === 'REWORKED') {
const result = await reactivatePaid(
input.characterId,
input.useKind ?? 'REDESIGN_KULANZ',
ctx,
note,
);
characterId = result.character.id;
} else {
const result = await reactivateFree(input.characterId, ctx);
characterId = result.character.id;
}
} else if (
input.classification === ForumClassification.NEW_CC || input.classification === ForumClassification.NEW_CC ||
input.classification === ForumClassification.REDESIGN input.classification === ForumClassification.REDESIGN
) { ) {
@@ -313,14 +360,11 @@ export async function classifyThread(
if (input.predecessorId === undefined) { if (input.predecessorId === undefined) {
throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde'); throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde');
} }
// Ein Redesign kann nicht aus dem CC-Topf bezahlt werden.
const redesignKind = input.useKind === 'REDESIGN_PAID' ? 'REDESIGN_PAID' : 'REDESIGN_KULANZ';
const result = await redesignCharacter( const result = await redesignCharacter(
input.predecessorId, input.predecessorId,
{ { name, loreText: input.loreText, useKind: redesignKind, note },
name,
loreText: input.loreText,
useKind: input.useKind ?? 'REDESIGN_KULANZ',
note,
},
ctx, ctx,
); );
characterId = result.character.id; characterId = result.character.id;
@@ -352,3 +396,68 @@ export async function classifyThread(
return { characterId }; return { characterId };
} }
/**
* Verknüpft einen von Hand angelegten CC mit seinem Forums-Thread und hakt
* diesen ab. Ohne das taucht derselbe Antrag beim nächsten Abgleich erneut in
* der Liste auf, obwohl er längst eingetragen ist.
*/
export async function linkThreadToCharacter(
threadId: number,
characterId: number,
classification: ForumClassification,
ctx: ActionContext,
): Promise<void> {
const character = await prisma.character.findUnique({
where: { id: characterId },
select: { id: true, name: true, playerId: true },
});
if (!character) throw notFound('CC');
const existing = await prisma.forumThread.findUnique({ where: { threadId } });
if (existing) {
if (existing.classification !== null) {
throw conflict(
'ALREADY_CLASSIFIED',
`Dieser Thread ist bereits als "${existing.classification}" eingetragen`,
);
}
await prisma.forumThread.update({
where: { threadId },
data: {
classification,
needsClassification: false,
classifiedById: ctx.actorId,
classifiedAt: new Date(),
linkedCharacterId: characterId,
playerId: existing.playerId ?? character.playerId,
},
});
} else {
// Der Abgleich kennt den Thread noch nicht - trotzdem vormerken, damit er
// später nicht als offener Antrag auftaucht.
await prisma.forumThread.create({
data: {
threadId,
title: `Von Hand eingetragen: ${character.name}`,
url: `${env.FORUM_BASE_URL.replace(/\/+$/, '')}/index.php?thread/${threadId}/`,
playerId: character.playerId,
currentBoard: 'CUSTOM_CHARACTERS',
classification,
needsClassification: false,
classifiedById: ctx.actorId,
classifiedAt: new Date(),
linkedCharacterId: characterId,
history: { create: [{ board: 'CUSTOM_CHARACTERS' }] },
},
});
}
await recordAudit(prisma, ctx, {
action: 'forum.linked_manually',
targetType: 'forum_thread',
targetId: threadId,
details: { characterId, characterName: character.name, classification },
});
}
+27
View File
@@ -30,6 +30,33 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="tabularListRow">
<ul class="tabularListColumns">
<li class="columnIcon columnAvatar">
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
</li>
<li class="columnSubject">
<ul class="labelList">
<li><a href="index.php?board/1/&amp;labelIDs=1" class="badge label red jsTooltip" title="Label">Wichtig</a></li>
<li><a href="index.php?board/1/&amp;labelIDs=2" class="badge label global jsTooltip" title="Label">Global</a></li>
</ul>
<a href="https://forum.example.org/index.php?thread/7004-regelwerk-changelog/" class="messageGroupLink wbbTopicLink" data-object-id="7004">Regelwerk / Changelog</a>
</li>
</ul>
</li>
<li class="tabularListRow">
<ul class="tabularListColumns">
<li class="columnIcon columnAvatar">
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
</li>
<li class="columnSubject">
<ul class="labelList">
<li><a href="index.php?board/1/&amp;labelIDs=3" class="badge label orange jsTooltip" title="Label">Information</a></li>
</ul>
<a href="https://forum.example.org/index.php?thread/7005-das-grosse-update/" class="messageGroupLink wbbTopicLink" data-object-id="7005">Das grosse Update</a>
</li>
</ul>
</li>
<li class="tabularListRow"> <li class="tabularListRow">
<ul class="tabularListColumns"> <ul class="tabularListColumns">
<li class="columnIcon columnAvatar"> <li class="columnIcon columnAvatar">
+47 -3
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import { parseBoard, parsePageCount } from '../src/lib/forum.js'; import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8'); const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
@@ -12,10 +12,10 @@ describe('Board-Übersicht lesen', () => {
const threads = parseBoard(BOARD); const threads = parseBoard(BOARD);
it('liest die Threads der Liste', () => { it('liest die Threads der Liste', () => {
assert.equal(threads.length, 3); assert.equal(threads.length, 5);
assert.deepEqual( assert.deepEqual(
threads.map((t) => t.threadId), threads.map((t) => t.threadId),
[7001, 7002, 7003], [7001, 7002, 7004, 7005, 7003],
); );
}); });
@@ -49,3 +49,47 @@ describe('Board-Übersicht lesen', () => {
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []); assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
}); });
}); });
describe('Ankündigungen aussortieren', () => {
const threads = parseBoard(BOARD);
it('liest die Labels eines Threads', () => {
const regelwerk = threads.find((t) => t.threadId === 7004);
assert.deepEqual(regelwerk?.labels, ['Wichtig', 'Global']);
});
it('erkennt Wichtig und Information als Ausschluss', () => {
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7004)!), true);
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7005)!), true);
});
it('lässt echte Anträge durch, auch mit anderen Labels', () => {
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7001)!), false);
});
it('wertet den Titel nicht aus', () => {
// "The Duke Rang Update" war ein echter Antrag, "Regelwerk" keiner. Am Titel
// ist das nicht zu erkennen - deshalb entscheidet allein das Label.
const ohneStichwort = parseBoard(BOARD).find((t) => t.threadId === 7005);
assert.ok(ohneStichwort, 'auch ein Thread ohne CC im Titel wird gelesen');
});
});
describe('Thread-Verweis lesen', () => {
it('nimmt den vollen Link', () => {
assert.equal(
parseThreadId('https://senseigaming.de/forum/index.php?thread/8041-cc-antrag-feldagent/'),
8041,
);
});
it('nimmt auch nur den Pfad oder die blanke Nummer', () => {
assert.equal(parseThreadId('index.php?thread/7001-irgendwas/'), 7001);
assert.equal(parseThreadId('7001'), 7001);
});
it('weist Unbrauchbares ab', () => {
assert.equal(parseThreadId(''), null);
assert.equal(parseThreadId('kein link'), null);
});
});
+1
View File
@@ -57,6 +57,7 @@ export const BOARD_LABEL: Record<string, string> = {
export const CLASSIFICATION_LABEL: Record<string, string> = { export const CLASSIFICATION_LABEL: Record<string, string> = {
NEW_CC: 'Angenommen – neuer CC', NEW_CC: 'Angenommen – neuer CC',
REDESIGN: 'Angenommen – Redesign', REDESIGN: 'Angenommen – Redesign',
REACTIVATION: 'Angenommen – Reaktivierung',
REJECTED: 'Abgelehnt', REJECTED: 'Abgelehnt',
IGNORE: 'Ignoriert', IGNORE: 'Ignoriert',
}; };
+9 -2
View File
@@ -193,7 +193,12 @@ export interface ImportRecord {
} }
export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV'; export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV';
export type ForumClassification = 'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'; export type ForumClassification =
| 'NEW_CC'
| 'REDESIGN'
| 'REACTIVATION'
| 'REJECTED'
| 'IGNORE';
export interface ForumThread { export interface ForumThread {
threadId: number; threadId: number;
@@ -201,6 +206,7 @@ export interface ForumThread {
url: string; url: string;
authorName: string | null; authorName: string | null;
authorForumId: number | null; authorForumId: number | null;
labels: string | null;
playerId: number | null; playerId: number | null;
player: { id: number; displayName: string } | null; player: { id: number; displayName: string } | null;
currentBoard: ForumBoard; currentBoard: ForumBoard;
@@ -221,7 +227,8 @@ export interface ForumScanSummary {
movedThreads: number; movedThreads: number;
boardChangeNotices: number; boardChangeNotices: number;
skippedStubs: number; skippedStubs: number;
skippedLabels: number;
skippedUnknownAuthor: number;
matchedPlayers: number; matchedPlayers: number;
withoutPlayer: number;
failedBoards: { board: string; reason: string }[]; failedBoards: { board: string; reason: string }[];
} }
+95 -12
View File
@@ -7,6 +7,7 @@ import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
import type { import type {
Character, Character,
ForumClassification,
ForumScanSummary, ForumScanSummary,
ForumThread, ForumThread,
PlayerDetail, PlayerDetail,
@@ -51,8 +52,11 @@ export default function Forum() {
const parts = [`${result.scannedPages} Seiten gelesen`]; const parts = [`${result.scannedPages} Seiten gelesen`];
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`); if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`); if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
if (result.withoutPlayer > 0) parts.push(`${result.withoutPlayer} ohne Spieler`); if (result.skippedUnknownAuthor > 0) {
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise übersprungen`); parts.push(`${result.skippedUnknownAuthor} ohne bekannten Spieler übersprungen`);
}
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`); if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
setFlash(parts.join(', ') + '.'); setFlash(parts.join(', ') + '.');
await load(); await load();
@@ -80,7 +84,8 @@ export default function Forum() {
<h1>Forums-Anträge</h1> <h1>Forums-Anträge</h1>
<p className="muted"> <p className="muted">
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
Signal und lösen nie eine Buchung aus. Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
die hier mit passendem Forum-Namen erfasst sind.
</p> </p>
</div> </div>
{admin && ( {admin && (
@@ -105,7 +110,9 @@ export default function Forum() {
{notices.map((thread) => ( {notices.map((thread) => (
<li key={thread.threadId} className="cc-item"> <li key={thread.threadId} className="cc-item">
<div> <div>
<strong>{thread.title}</strong> <a href={thread.url} target="_blank" rel="noreferrer" className="link-strong">
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span> <span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
<p className="muted small"> <p className="muted small">
{thread.classification && CLASSIFICATION_LABEL[thread.classification]} {thread.classification && CLASSIFICATION_LABEL[thread.classification]}
@@ -141,8 +148,17 @@ export default function Forum() {
{threads.map((thread) => ( {threads.map((thread) => (
<li key={thread.threadId} className="cc-item"> <li key={thread.threadId} className="cc-item">
<div> <div>
<strong>{thread.title}</strong> <a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title="Im Forum öffnen"
>
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span> <span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
{thread.labels && <span className="muted small"> · {thread.labels}</span>}
<p className="muted small"> <p className="muted small">
von {thread.authorName ?? 'unbekannt'} ·{' '} von {thread.authorName ?? 'unbekannt'} ·{' '}
{thread.player ? ( {thread.player ? (
@@ -154,9 +170,6 @@ export default function Forum() {
</p> </p>
</div> </div>
<div className="cc-actions"> <div className="cc-actions">
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
Thread
</a>
{admin && !thread.player && ( {admin && !thread.player && (
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}> <button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
Spieler zuordnen Spieler zuordnen
@@ -218,11 +231,13 @@ function ClassifyModal({
onDone: (message: string) => void; onDone: (message: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
}) { }) {
const [choice, setChoice] = useState<'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'>('NEW_CC'); const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
const [name, setName] = useState(thread.title); const [name, setName] = useState(thread.title);
const [predecessorId, setPredecessorId] = useState<number | null>(null); const [predecessorId, setPredecessorId] = useState<number | null>(null);
const [reviveId, setReviveId] = useState<number | null>(null);
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ'); const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
const [characters, setCharacters] = useState<Character[]>([]); const [characters, setCharacters] = useState<Character[]>([]);
const [inactive, setInactive] = useState<Character[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers. // Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
@@ -232,13 +247,18 @@ function ClassifyModal({
try { try {
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`); const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE')); setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
} catch { } catch {
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es. // Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
} }
})(); })();
}, [thread.playerId]); }, [thread.playerId]);
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN'; const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -248,8 +268,11 @@ function ClassifyModal({
method: 'POST', method: 'POST',
body: { body: {
classification: choice, classification: choice,
...(needsPlayer ? { characterName: name.trim() } : {}), ...(needsName ? { characterName: name.trim() } : {}),
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}), ...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
...(choice === 'REACTIVATION'
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
: {}),
}, },
}); });
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`); onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
@@ -275,6 +298,7 @@ function ClassifyModal({
[ [
['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'], ['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'],
['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'], ['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'],
['REACTIVATION', 'Angenommen – Reaktivierung', 'ein inaktiver CC kehrt zurück'],
['REJECTED', 'Abgelehnt', 'keine Buchung'], ['REJECTED', 'Abgelehnt', 'keine Buchung'],
['IGNORE', 'Kein Antrag', 'keine Buchung'], ['IGNORE', 'Kein Antrag', 'keine Buchung'],
] as const ] as const
@@ -300,7 +324,7 @@ function ClassifyModal({
</Notice> </Notice>
)} )}
{needsPlayer && thread.playerId && ( {needsName && thread.playerId && (
<> <>
<label htmlFor="cc-name">Name des CCs</label> <label htmlFor="cc-name">Name des CCs</label>
<input <input
@@ -313,6 +337,65 @@ function ClassifyModal({
</> </>
)} )}
{choice === 'REACTIVATION' && thread.playerId && (
<>
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
<select
id="revive"
value={reviveId ?? ''}
onChange={(event) => setReviveId(Number(event.target.value) || null)}
required
>
<option value="">— bitte wählen —</option>
{inactive.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
{character.inactiveReason === 'REWORKED' ? ' (redesignt — kostet)' : ' (gratis)'}
</option>
))}
</select>
{inactive.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
)}
{gewaehlterCC && !reaktivierungKostet && (
<Notice kind="success">
Kommt gratis zurück — der CC existiert unverändert weiter.
</Notice>
)}
{reaktivierungKostet && (
<>
<Notice kind="warn">
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
kostet ein Guthaben.
</Notice>
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
</>
)}
{choice === 'REDESIGN' && thread.playerId && ( {choice === 'REDESIGN' && thread.playerId && (
<> <>
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label> <label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
+115 -23
View File
@@ -24,7 +24,8 @@ type DialogState =
| { type: 'revert'; character: Character } | { type: 'revert'; character: Character }
| { type: 'reverse'; entry: LedgerEntry } | { type: 'reverse'; entry: LedgerEntry }
| { type: 'editPlayer' } | { type: 'editPlayer' }
| { type: 'deletePlayer' }; | { type: 'deletePlayer' }
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' };
export default function PlayerDetail() { export default function PlayerDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -179,12 +180,7 @@ export default function PlayerDetail() {
type="button" type="button"
className="btn" className="btn"
disabled={busy} disabled={busy}
onClick={() => onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
void run(
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
)
}
> >
+ CC-Paket + CC-Paket
</button> </button>
@@ -192,16 +188,7 @@ export default function PlayerDetail() {
type="button" type="button"
className="btn" className="btn"
disabled={busy} disabled={busy}
onClick={() => onClick={() => setDialog({ type: 'addCredit', kind: 'redesign-package' })}
void run(
() =>
api(`/api/players/${playerId}/credits/redesign-package`, {
method: 'POST',
body: {},
}),
'Redesign-Paket eingetragen: +1 Redesign.',
)
}
> >
+ Redesign-Paket + Redesign-Paket
</button> </button>
@@ -209,12 +196,7 @@ export default function PlayerDetail() {
type="button" type="button"
className="btn" className="btn"
disabled={busy} disabled={busy}
onClick={() => onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
void run(
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
'Kulanz-Redesign eingetragen: +1 Kulanz.',
)
}
> >
+ Kulanz + Kulanz
</button> </button>
@@ -523,6 +505,25 @@ export default function PlayerDetail() {
/> />
)} )}
{dialog.type === 'addCredit' && (
<AddCreditModal
kind={dialog.kind}
playerName={detail.player.displayName}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onConfirm={(note) =>
void run(
() =>
api(`/api/players/${playerId}/credits/${dialog.kind}`, {
method: 'POST',
body: note.trim() ? { note: note.trim() } : {},
}),
CREDIT_ACTIONS[dialog.kind].flash,
)
}
/>
)}
{dialog.type === 'editPlayer' && ( {dialog.type === 'editPlayer' && (
<EditPlayerModal <EditPlayerModal
player={detail.player} player={detail.player}
@@ -580,10 +581,87 @@ export default function PlayerDetail() {
); );
} }
const CREDIT_ACTIONS = {
'cc-package': {
titel: 'CC-Paket eintragen',
wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.',
hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.',
flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.',
},
'redesign-package': {
titel: 'Redesign-Paket eintragen',
wirkung: 'Schreibt +1 bezahltes Redesign-Guthaben gut.',
hinweis: 'Für einzeln gekaufte Redesigns, ohne CC-Guthaben.',
flash: 'Redesign-Paket eingetragen: +1 Redesign.',
},
kulanz: {
titel: 'Kulanz-Redesign eintragen',
wirkung: 'Schreibt +1 Kulanz-Redesign gut.',
hinweis: 'Für vom Team gewährte Redesigns, die nicht bezahlt wurden.',
flash: 'Kulanz-Redesign eingetragen: +1 Kulanz.',
},
} as const;
function AddCreditModal({
kind,
playerName,
busy,
onClose,
onConfirm,
}: {
kind: keyof typeof CREDIT_ACTIONS;
playerName: string;
busy: boolean;
onClose: () => void;
onConfirm: (note: string) => void;
}) {
const [note, setNote] = useState('');
const aktion = CREDIT_ACTIONS[kind];
return (
<Modal title={aktion.titel} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onConfirm(note);
}}
>
<Notice kind="info">
Für <strong>{playerName}</strong>: {aktion.wirkung}
<br />
<span className="muted">{aktion.hinweis}</span>
</Notice>
<label htmlFor="credit-note">Notiz (optional)</label>
<input
id="credit-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Bestellnummer oder Grund der Kulanz"
autoFocus
/>
<p className="muted small">
Eine Buchung lässt sich später stornieren, verschwindet aber nie aus der Historie.
</p>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Buche …' : 'Eintragen'}
</button>
</div>
</form>
</Modal>
);
}
interface CharacterValues { interface CharacterValues {
name: string; name: string;
loreText?: string; loreText?: string;
useKind?: CreditKind; useKind?: CreditKind;
forumThread?: string;
} }
function CharacterFormModal({ function CharacterFormModal({
@@ -607,6 +685,7 @@ function CharacterFormModal({
}) { }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [loreText, setLoreText] = useState(''); const [loreText, setLoreText] = useState('');
const [forumThread, setForumThread] = useState('');
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden. // Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC'); const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
@@ -618,6 +697,7 @@ function CharacterFormModal({
onSubmit({ onSubmit({
name, name,
...(loreText.trim() ? { loreText: loreText.trim() } : {}), ...(loreText.trim() ? { loreText: loreText.trim() } : {}),
...(forumThread.trim() ? { forumThread: forumThread.trim() } : {}),
...(withKind ? { useKind } : {}), ...(withKind ? { useKind } : {}),
}); });
}} }}
@@ -641,6 +721,18 @@ function CharacterFormModal({
onChange={(event) => setLoreText(event.target.value)} onChange={(event) => setLoreText(event.target.value)}
/> />
<label htmlFor="cc-thread">Forums-Thread (optional)</label>
<input
id="cc-thread"
value={forumThread}
onChange={(event) => setForumThread(event.target.value)}
placeholder="Link zum Antrag oder nur die Thread-Nummer"
/>
<p className="muted small">
Trag den Antrag hier ein, dann taucht er beim nächsten Forums-Abgleich nicht erneut
als offen auf.
</p>
{withKind && balances && ( {withKind && balances && (
<> <>
<label>Welches Guthaben soll verbraucht werden?</label> <label>Welches Guthaben soll verbraucht werden?</label>