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 { hasIgnoredLabel, parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js'; import { isRateLimited, retryAfterMs, sleep } from '../lib/ratelimit.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' }, ]; /// Die Board-Pfade in lesbarer Form, für das Protokoll des Laufs. const BOARD_NAMES: Record = { 'board/66-custom-characters': 'Antragsübersicht', 'board/355-aktive-ccs': 'Aktive CCs', 'board/113-cc-archiv': 'Archiv', }; const MIN_REQUEST_GAP_MS = 1200; /// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin. const MAX_REQUEST_GAP_MS = 10000; /// So oft wird eine gebremste Seite erneut versucht, bevor aufgegeben wird. const MAX_RETRIES = 3; /// Das Archiv reicht Jahre zurück; die Grenze ist eine Notbremse, keine /// Sparmaßnahme. const MAX_PAGES_PER_BOARD = 60; let lastRequestAt = 0; /// Wächst, sobald das Forum bremst, und bleibt für den Rest des Laufs oben. let requestGapMs = MIN_REQUEST_GAP_MS; let rateLimitHits = 0; async function throttle(): Promise { const wait = lastRequestAt + requestGapMs - Date.now(); if (wait > 0) await sleep(wait); lastRequestAt = Date.now(); } /** * Holt eine Boardseite und nimmt Rücksicht darauf, dass das Forum bei zu * vielen Anfragen bremst. Eine abgewiesene Seite wird nach der angegebenen * Wartezeit erneut versucht, und der Abstand für alle weiteren Abrufe steigt. * Das kostet Zeit, ist aber der einzige Weg, ein Board vollständig zu lesen - * wer nach dem ersten Korb aufgibt, verliert den Rest des Boards. */ async function fetchPage(path: string, page: number): Promise { const base = env.FORUM_BASE_URL.replace(/\/+$/, ''); const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`; for (let versuch = 0; ; versuch += 1) { await throttle(); 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 (isRateLimited(response.status)) { rateLimitHits += 1; requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS); if (versuch >= MAX_RETRIES) { throw badRequest( 'BOARD_RATE_LIMITED', `Das Forum bremst den Abruf aus (HTTP ${response.status})`, ); } await sleep(retryAfterMs(response.headers.get('retry-after'), versuch)); continue; } 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; skippedLabels: number; /// Erfasst, aber noch keinem Spieler zugeordnet. Solche Threads landen nicht /// in der Arbeitsliste, gehen aber auch nicht verloren. unassignedThreads: number; matchedPlayers: number; /// Wie oft das Forum den Abruf gebremst hat. Über null heißt: der Lauf hat /// länger gedauert, gelesen wurde trotzdem alles. rateLimited: number; /// Boards, die nicht zu Ende gelesen werden konnten - mit der Zahl der /// Seiten, die es bis dahin geschafft haben. Was gelesen wurde, ist erfasst. failedBoards: { board: string; reason: string; pagesRead: number }[]; } export interface ScanState { running: boolean; startedAt: string | null; finishedAt: string | null; /// Woran gerade gelesen wird, für die Anzeige während des Laufs. currentBoard: string | null; pagesRead: number; /// Steht fest, sobald von jedem Board die erste Seite gelesen ist. Vorher 0. totalPages: number; /// Was der Lauf bisher getan hat, jüngster Eintrag zuletzt. log: string[]; summary: ForumScanSummary | null; error: string | null; } /** * Ein voller Lauf geht über dutzende Seiten, und wenn das Forum bremst, kommen * Wartezeiten obendrauf. Das überlebt keine HTTP-Anfrage, deshalb läuft der * Abgleich im Hintergrund und die Oberfläche fragt den Stand ab. * * Der Zustand liegt im Speicher: nach einem Neustart des Dienstes ist er weg, * was nichts kostet - der nächste Lauf liest ohnehin alles erneut. */ const scanState: ScanState = { running: false, startedAt: null, finishedAt: null, currentBoard: null, pagesRead: 0, totalPages: 0, log: [], summary: null, error: null, }; /// Damit ein langer Lauf den Speicher nicht vollschreibt. const MAX_LOG_LINES = 200; function protokoll(zeile: string): void { scanState.log.push(zeile); if (scanState.log.length > MAX_LOG_LINES) scanState.log.shift(); } export function getScanState(): ScanState { return { ...scanState }; } /** * Stößt einen Lauf an und kehrt sofort zurück. * * Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied: * die Sperre gegen zwei gleichzeitige Läufe sitzt in scanForum selbst. Zwei * Läufe nebeneinander würden dieselben Seiten doppelt abrufen - und das * Forum bremst schon bei einem. */ export function startScan(ctx: ActionContext): ScanState { if (scanState.running) { throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich'); } void scanForum(ctx).catch(() => { // Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet. }); return getScanState(); } /** * 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 { if (scanState.running) { throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich'); } // Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten // await am Stück, deshalb kann sich kein zweiter dazwischenschieben. scanState.running = true; scanState.startedAt = new Date().toISOString(); scanState.finishedAt = null; scanState.currentBoard = null; scanState.pagesRead = 0; scanState.totalPages = 0; scanState.log = []; scanState.summary = null; scanState.error = null; try { return await scanIntern(ctx); } catch (error) { scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen'; throw error; } finally { scanState.running = false; scanState.currentBoard = null; scanState.finishedAt = new Date().toISOString(); } } async function scanIntern(ctx: ActionContext): Promise { const summary: ForumScanSummary = { scannedBoards: 0, scannedPages: 0, newThreads: 0, movedThreads: 0, boardChangeNotices: 0, skippedStubs: 0, skippedLabels: 0, unassignedThreads: 0, matchedPlayers: 0, rateLimited: 0, failedBoards: [], }; // Jeder Lauf fängt mit dem normalen Abstand an; gebremst wird erst, wenn // das Forum es verlangt. requestGapMs = MIN_REQUEST_GAP_MS; rateLimitHits = 0; const byForumName = await forumNameIndex(); // Erst alles lesen, dann alles schreiben. Ein voller Lauf geht über mehrere // hundert Threads; je Thread einzeln zu lesen und zu schreiben würde länger // dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben. const gefunden = new Map(); // Erst von jedem Board die erste Seite. Danach steht der Umfang des Laufs // fest, und die Anzeige kann einen ehrlichen Fortschritt zeigen statt zu // raten, wie viel noch kommt. const offen: { board: ForumBoard; path: string; seiten: number; gelesen: number }[] = []; for (const { board, path } of BOARDS) { scanState.currentBoard = path; protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`); try { const first = await fetchPage(path, 1); summary.scannedPages += 1; scanState.pagesRead = summary.scannedPages; for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board }); const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD); scanState.totalPages += seiten; offen.push({ board, path, seiten, gelesen: 1 }); protokoll(`${BOARD_NAMES[path] ?? path}: Seite 1 von ${seiten} gelesen`); } catch (error) { const grund = error instanceof Error ? error.message : 'Unbekannter Fehler'; summary.failedBoards.push({ board: path, reason: grund, pagesRead: 0 }); protokoll(`${BOARD_NAMES[path] ?? path}: nicht erreichbar (${grund})`); } } for (const eintrag of offen) { const name = BOARD_NAMES[eintrag.path] ?? eintrag.path; scanState.currentBoard = eintrag.path; try { for (let page = 2; page <= eintrag.seiten; page += 1) { const html = await fetchPage(eintrag.path, page); eintrag.gelesen += 1; summary.scannedPages += 1; scanState.pagesRead = summary.scannedPages; for (const thread of parseBoard(html)) { gefunden.set(thread.threadId, { thread, board: eintrag.board }); } protokoll(`${name}: Seite ${page} von ${eintrag.seiten} gelesen`); } summary.scannedBoards += 1; protokoll(`${name}: fertig`); } catch (error) { // Was bis hierher gelesen wurde, bleibt erhalten und wird gleich // verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16 // Seiten mit in den Papierkorb nehmen. const grund = error instanceof Error ? error.message : 'Unbekannter Fehler'; summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen }); protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`); } } summary.rateLimited = rateLimitHits; scanState.currentBoard = null; protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`); const relevant: { thread: ForumThread; board: ForumBoard }[] = []; for (const eintrag of gefunden.values()) { // Ein zurückgelassener Verweis ist kein eigener Antrag. if (eintrag.thread.isMovedStub) { summary.skippedStubs += 1; continue; } // Angepinnte Ankündigungen liegen mit in den Boards. if (hasIgnoredLabel(eintrag.thread)) { summary.skippedLabels += 1; continue; } relevant.push(eintrag); } const ids = relevant.map((e) => e.thread.threadId); const bekannt = new Map( (await prisma.forumThread.findMany({ where: { threadId: { in: ids } } })).map((t) => [ t.threadId, t, ]), ); const neue: Prisma.ForumThreadCreateManyInput[] = []; const historie: Prisma.ForumThreadBoardCreateManyInput[] = []; const aenderungen: { threadId: number; data: Prisma.ForumThreadUpdateInput }[] = []; for (const { thread, board } of relevant) { const playerId = thread.authorName ? byForumName.get(thread.authorName.toLowerCase()) ?? null : null; const labels = thread.labels.join(', ') || null; const existing = bekannt.get(thread.threadId); // Auch ohne zuordenbaren Spieler wird der Thread erfasst. Er bleibt dann // aus der Arbeitsliste heraus, ist aber auffindbar und lässt sich von Hand // einem Spieler zuschlagen - sonst wäre er schlicht unsichtbar. if (!existing) { neue.push({ threadId: thread.threadId, title: thread.title, url: thread.url, authorName: thread.authorName, authorForumId: thread.authorForumId, labels, playerId, currentBoard: board, postedAt: thread.postedAt, }); historie.push({ threadId: thread.threadId, board }); summary.newThreads += 1; if (playerId === null) summary.unassignedThreads += 1; else summary.matchedPlayers += 1; continue; } const verschoben = existing.currentBoard !== board; // Eine später nachgetragene Zuordnung nicht wieder überschreiben. const nachgetragen = existing.playerId === null && playerId !== null; // Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier // nach, ohne dass ein vorhandener Wert überschrieben wird. const datumFehlt = existing.postedAt === null && thread.postedAt !== null; const geaendert = verschoben || nachgetragen || datumFehlt || existing.title !== thread.title || existing.url !== thread.url || existing.authorName !== thread.authorName || existing.authorForumId !== thread.authorForumId || existing.labels !== labels; if (!geaendert) continue; aenderungen.push({ threadId: thread.threadId, data: { title: thread.title, url: thread.url, authorName: thread.authorName, authorForumId: thread.authorForumId, labels, ...(datumFehlt ? { postedAt: thread.postedAt } : {}), ...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}), ...(verschoben ? { 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 (verschoben) { historie.push({ threadId: thread.threadId, board }); summary.movedThreads += 1; if (existing.classification !== null) summary.boardChangeNotices += 1; } } if (neue.length > 0) await prisma.forumThread.createMany({ data: neue }); for (const { threadId, data } of aenderungen) { await prisma.forumThread.update({ where: { threadId }, data }); } if (historie.length > 0) await prisma.forumThreadBoard.createMany({ data: historie }); // Gesehen wurde jeder gelesene Thread, auch der unveränderte. if (ids.length > 0) { await prisma.forumThread.updateMany({ where: { threadId: { in: ids } }, data: { lastSeenAt: new Date() }, }); } protokoll( summary.newThreads > 0 ? `${summary.newThreads} neue Anträge erfasst` : 'Keine neuen Anträge', ); scanState.summary = summary; await recordAudit(prisma, ctx, { action: 'forum.scan', targetType: 'forum', targetId: null, details: { ...summary } as unknown as Prisma.InputJsonValue, }); return summary; } /** * Forumsnamen auf Spieler abbilden - die aktuellen Angaben und frühere Konten, * damit ein Namenswechsel die Zuordnung nicht abreißen lässt. */ async function forumNameIndex(): Promise> { const [players, aliases] = await Promise.all([ prisma.player.findMany({ where: { forumName: { not: null } }, select: { id: true, forumName: true }, }), prisma.playerAlias.findMany({ where: { type: 'FORUM_NAME' }, select: { playerId: true, value: true }, }), ]); const index = new Map(); for (const alias of aliases) index.set(alias.value.toLowerCase(), alias.playerId); // Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht. for (const player of players) { index.set((player.forumName as string).toLowerCase(), player.id); } return index; } /// Neueste oder älteste zuerst. Massgeblich ist die Thread-ID: sie waechst /// mit der Zeit und bleibt stabil, waehrend der Zeitpunkt des letzten Abrufs /// nach einem Lauf bei allen Threads derselbe ist. export type ThreadOrder = 'newest' | 'oldest'; function orderOf(order: ThreadOrder | undefined): Prisma.ForumThreadOrderByWithRelationInput { return { threadId: order === 'oldest' ? 'asc' : 'desc' }; } /** Freitextsuche über Titel und Ersteller. */ function sucheNach(query: string | undefined): Prisma.ForumThreadWhereInput { const suche = query?.trim(); if (!suche) return {}; return { OR: [ { title: { contains: suche, mode: 'insensitive' } }, { authorName: { contains: suche, mode: 'insensitive' } }, ], }; } export async function listThreads(options: { onlyOpen?: boolean; playerId?: number; board?: ForumBoard; query?: string; order?: ThreadOrder; }) { return prisma.forumThread.findMany({ where: { // Die Arbeitsliste zeigt nur, was auch entschieden werden kann. Ein // Thread ohne Spieler steht in der eigenen Liste und wartet dort auf // die Zuordnung von Hand. ...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}), ...(options.playerId !== undefined ? { playerId: options.playerId } : {}), ...(options.board ? { currentBoard: options.board } : {}), ...sucheNach(options.query), }, orderBy: orderOf(options.order), include: { player: { select: { id: true, displayName: true } }, linkedCharacter: { select: { id: true, name: true } }, }, }); } /** * Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre * zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst * wurden - deshalb durchsuchbar und seitenweise statt als eine lange Liste. */ export async function listUnassignedThreads(options: { query?: string; take?: number; skip?: number; order?: ThreadOrder; }) { const take = Math.min(Math.max(options.take ?? 50, 1), 200); const skip = Math.max(options.skip ?? 0, 0); const where: Prisma.ForumThreadWhereInput = { playerId: null, needsClassification: true, ...sucheNach(options.query), }; const [items, total] = await Promise.all([ prisma.forumThread.findMany({ where, orderBy: orderOf(options.order), take, skip, include: { player: { select: { id: true, displayName: true } }, linkedCharacter: { select: { id: true, name: true } }, }, }), prisma.forumThread.count({ where }), ]); return { items, total, take, skip }; } /** * Wer im Forum Anträge gestellt hat, hier aber unter keinem Namen geführt * wird - zusammengefasst je Ersteller statt je Antrag. So sieht man auf einen * Blick, wen anzulegen sich lohnt: drei Anträge unter einem Namen sind ein * anderes Signal als ein einzelner. */ export async function listUntrackedAuthors(take = 100) { const gruppen = await prisma.forumThread.groupBy({ by: ['authorName'], where: { playerId: null, needsClassification: true, authorName: { not: null } }, _count: { _all: true }, _max: { threadId: true }, orderBy: { _count: { threadId: 'desc' } }, take: Math.min(Math.max(take, 1), 500), }); return { items: gruppen.map((gruppe) => ({ authorName: gruppe.authorName as string, threadCount: gruppe._count._all, latestThreadId: gruppe._max.threadId, })), }; } /** * Zieht wartende Threads an einen Spieler heran, sobald dessen Forumsname * bekannt wird. Ohne das bliebe ein frisch angelegter Spieler bis zum * nächsten vollen Abgleich ohne seine Anträge. */ export async function claimThreadsForPlayer(playerId: number, names: string[]): Promise { const gesucht = names.map((n) => n.trim()).filter((n) => n.length > 0); if (gesucht.length === 0) return 0; const kandidaten = await prisma.forumThread.findMany({ where: { playerId: null, authorName: { in: gesucht, mode: 'insensitive' } }, select: { threadId: true }, }); if (kandidaten.length === 0) return 0; const result = await prisma.forumThread.updateMany({ where: { threadId: { in: kandidaten.map((t) => t.threadId) }, playerId: null }, data: { playerId }, }); return result.count; } /** 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 { 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 { 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; /// Nur bei REACTIVATION: welcher inaktive CC kehrt zurück. characterId?: number; useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ' | 'CC'; } /** * 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; // Eine Entscheidung kann Kulanz verbrauchen. Das Vergeben von Kulanz ist // Admins vorbehalten, das Verbrauchen nicht - hier wird also nur gebucht, // was der Spieler bereits hat. 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 ) { 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'); } // 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: redesignKind, 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 }; } /** * 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 }, }); }