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
+32
View File
@@ -20,6 +20,16 @@ export interface ForumThread {
authorForumId: number | null;
/// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück.
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">/;
@@ -32,6 +42,9 @@ const USER_LINK =
// Ein zurückgelassener Verweis ist kein eigener Thread.
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 {
return value
.replace(/&quot;/g, '"')
@@ -59,6 +72,9 @@ export function parseBoard(html: string): ForumThread[] {
const user = USER_LINK.exec(block);
LABEL.lastIndex = 0;
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
threads.push({
threadId,
title: decode(topic[3] ?? ''),
@@ -66,6 +82,7 @@ export function parseBoard(html: string): ForumThread[] {
authorName: user ? decode(user[2] ?? '') || null : null,
authorForumId: user ? Number(user[1]) : null,
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]));
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,
updateCharacter,
} 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';
/** 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 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),
loreText: z.string().trim().max(5000).optional(),
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
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);
return result;
});
@@ -52,10 +74,14 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
loreText: z.string().trim().max(5000).optional(),
useKind: redesignKind,
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
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) => {
+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(),
loreText: z.string().trim().max(5000).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,
);
+122 -13
View File
@@ -2,7 +2,7 @@ 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 { hasIgnoredLabel, 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
@@ -61,8 +61,10 @@ export interface ForumScanSummary {
movedThreads: number;
boardChangeNotices: number;
skippedStubs: number;
skippedLabels: number;
/// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon.
skippedUnknownAuthor: number;
matchedPlayers: number;
withoutPlayer: number;
failedBoards: { board: string; reason: string }[];
}
@@ -79,8 +81,9 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
movedThreads: 0,
boardChangeNotices: 0,
skippedStubs: 0,
skippedLabels: 0,
skippedUnknownAuthor: 0,
matchedPlayers: 0,
withoutPlayer: 0,
failedBoards: [],
};
@@ -121,6 +124,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
continue;
}
// Angepinnte Ankündigungen liegen mit in den Boards.
if (hasIgnoredLabel(thread)) {
summary.skippedLabels += 1;
continue;
}
const playerId = thread.authorName
? byForumName.get(thread.authorName.toLowerCase()) ?? null
: null;
@@ -129,6 +138,15 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
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) {
await prisma.forumThread.create({
data: {
@@ -137,14 +155,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
playerId,
currentBoard: board,
history: { create: [{ board }] },
},
});
summary.newThreads += 1;
if (playerId) summary.matchedPlayers += 1;
else summary.withoutPlayer += 1;
summary.matchedPlayers += 1;
continue;
}
@@ -157,6 +175,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
lastSeenAt: new Date(),
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
...(existing.playerId === null && playerId !== null ? { playerId } : {}),
@@ -268,7 +287,9 @@ export interface ClassifyInput {
loreText?: string;
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
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;
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.REDESIGN
) {
@@ -313,14 +360,11 @@ export async function classifyThread(
if (input.predecessorId === undefined) {
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(
input.predecessorId,
{
name,
loreText: input.loreText,
useKind: input.useKind ?? 'REDESIGN_KULANZ',
note,
},
{ name, loreText: input.loreText, useKind: redesignKind, note },
ctx,
);
characterId = result.character.id;
@@ -352,3 +396,68 @@ export async function classifyThread(
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 },
});
}