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 @@
  • +
  • + +
  • +
  • + +