From bebb89c10e2c752a4ec08d0c8d16ad017015bde1 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 18:06:37 +0200 Subject: [PATCH] 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. --- README.md | 16 +- .../migration.sql | 8 + server/prisma/schema.prisma | 4 + server/src/lib/forum.ts | 32 ++++ server/src/routes/characters.routes.ts | 30 +++- server/src/routes/forum.routes.ts | 3 +- server/src/services/forum.ts | 135 +++++++++++++++-- server/test/fixtures/board.html | 27 ++++ server/test/forum.test.ts | 50 ++++++- web/src/lib/format.ts | 1 + web/src/lib/types.ts | 11 +- web/src/pages/Forum.tsx | 107 ++++++++++++-- web/src/pages/PlayerDetail.tsx | 138 +++++++++++++++--- 13 files changed, 503 insertions(+), 59 deletions(-) create mode 100644 server/prisma/migrations/20250103000000_reaktivierung_und_labels/migration.sql diff --git a/README.md b/README.md index ee7ff60..95a260e 100644 --- a/README.md +++ b/README.md @@ -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. 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 — 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. +**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre +zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim +Spieler nach, dann nimmt ihn der nächste Abgleich auf. + +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 Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa diff --git a/server/prisma/migrations/20250103000000_reaktivierung_und_labels/migration.sql b/server/prisma/migrations/20250103000000_reaktivierung_und_labels/migration.sql new file mode 100644 index 0000000..b5c8bdb --- /dev/null +++ b/server/prisma/migrations/20250103000000_reaktivierung_und_labels/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d787197..e42a156 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -55,6 +55,8 @@ enum ForumBoard { enum ForumClassification { NEW_CC REDESIGN + /// Ein inaktiver CC kehrt zurueck. Ohne Redesign kostet das nichts. + REACTIVATION REJECTED IGNORE } @@ -317,6 +319,8 @@ model ForumThread { authorName String? /// Überlebt Namensänderungen im Forum, anders als der Anzeigename. authorForumId Int? + /// Labels des Threads, kommagetrennt. Reiner Kontext für die Entscheidung. + labels String? playerId Int? player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull) diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts index bb2eafe..e16bd9b 100644 --- a/server/src/lib/forum.ts +++ b/server/src/lib/forum.ts @@ -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 = /
  • /; @@ -32,6 +42,9 @@ const USER_LINK = // Ein zurückgelassener Verweis ist kein eigener Thread. const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g; + function decode(value: string): string { return value .replace(/"/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; +} diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 4894bda..03f8a2b 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -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, +): Promise { + 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 { 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 { 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) => { diff --git a/server/src/routes/forum.routes.ts b/server/src/routes/forum.routes.ts index 2e6ee09..4213f73 100644 --- a/server/src/routes/forum.routes.ts +++ b/server/src/routes/forum.routes.ts @@ -64,7 +64,8 @@ export async function forumRoutes(app: FastifyInstance): Promise { 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, ); diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index beb2af7..44561a6 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { + 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 }, + }); +} diff --git a/server/test/fixtures/board.html b/server/test/fixtures/board.html index 2bc1fbe..6d55be1 100644 --- a/server/test/fixtures/board.html +++ b/server/test/fixtures/board.html @@ -30,6 +30,33 @@
  • +
  • + +
  • +
  • + +