diff --git a/README.md b/README.md index 048be8b..cf8cb07 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe z dem eigenen Ledger, mehr nicht. Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. +Weist GExtension eine Anfrage wegen zu vieler Zugriffe ab, wird gewartet und erneut versucht, +statt den Spieler zu überspringen; das Tempo bleibt für den Rest des Laufs gedrosselt. Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der sauberere Weg, falls er zu haben ist. @@ -215,10 +217,16 @@ es gibt keinen zweiten Buchungsweg mit eigenen Regeln. Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler - und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen -Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. -**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. +Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der Ersteller nirgends, +landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der +Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu. + +Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder +ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste. +Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt. + +Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein +Klick auf den CC-Namen direkt zum Forumsbeitrag. 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 @@ -233,6 +241,17 @@ Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein and ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist das Signal zu unzuverlässig. +### Wie lange ein Abgleich dauert + +Das Archiv allein umfasst über zwanzig Seiten. Zwischen den Abrufen liegt gut eine Sekunde, ein +voller Lauf braucht also rund eine halbe Minute. Bremst das Forum den Zugriff aus, wird die +angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite +erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles. + +Deshalb läuft der Abgleich **im Hintergrund**. Der Knopf zeigt den Fortschritt, und der Lauf +geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis +dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat. + ## Rollen Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts index d1d239c..54faeb1 100644 --- a/server/src/lib/forum.ts +++ b/server/src/lib/forum.ts @@ -39,6 +39,11 @@ const TOPIC_LINK = const USER_LINK = /]*class="userLink"[^>]*>(?:]*><\/i>)?\s*(?:]*>)?\s*([^<]{1,80})/; +// Wer sein Konto gelöscht hat, steht ohne Profillink in der Zelle. Der Name +// ist trotzdem da und bleibt die einzige Spur zum Spieler - ohne diesen +// Rückfall fallen solche Threads bei der Zuordnung durchs Raster. +const AUTHOR_CELL = /
  • ([\s\S]{0,400}?)<\/li>/; + // Ein zurückgelassener Verweis ist kein eigener Thread. const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*]*>/g, ' ')) || null; + } LABEL.lastIndex = 0; const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean); @@ -79,7 +89,7 @@ export function parseBoard(html: string): ForumThread[] { threadId, title: decode(topic[3] ?? ''), url: decode(topic[1] ?? ''), - authorName: user ? decode(user[2] ?? '') || null : null, + authorName, authorForumId: user ? Number(user[1]) : null, isMovedStub: MOVED_MARKER.test(block), labels, diff --git a/server/src/lib/gextension.ts b/server/src/lib/gextension.ts index 798248a..04f993b 100644 --- a/server/src/lib/gextension.ts +++ b/server/src/lib/gextension.ts @@ -1,5 +1,6 @@ import { ProductTarget } from '@prisma/client'; import { badRequest } from '../errors.js'; +import { isRateLimited, retryAfterMs, sleep } from './ratelimit.js'; import { normalizeSteamId } from './steamid.js'; /** @@ -138,14 +139,26 @@ export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de'; // Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde. const MIN_REQUEST_GAP_MS = 1000; +const MAX_REQUEST_GAP_MS = 10000; +const MAX_RETRIES = 2; + let lastRequestAt = 0; +/// Wächst, sobald die Gegenseite bremst. Einmal oben, bleibt es oben - bei +/// einem Abgleich über viele Spieler wäre jedes Zurücksetzen ein Rückfall in +/// dasselbe Tempo, das gerade abgewiesen wurde. +let requestGapMs = MIN_REQUEST_GAP_MS; async function throttle(): Promise { - const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); - if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + const wait = lastRequestAt + requestGapMs - Date.now(); + if (wait > 0) await sleep(wait); lastRequestAt = Date.now(); } +/** Setzt das Tempo zurück, etwa zu Beginn eines neuen Abgleichs. */ +export function resetRequestPace(): void { + requestGapMs = MIN_REQUEST_GAP_MS; +} + export interface FetchOptions { baseUrl?: string; timeoutMs?: number; @@ -161,32 +174,49 @@ export async function fetchProfile( const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const timeoutMs = options.timeoutMs ?? 15000; - await throttle(); + for (let versuch = 0; ; versuch += 1) { + await throttle(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, { - signal: controller.signal, - headers: { - // Ehrliche Kennzeichnung statt Browser-Tarnung. - 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)', - accept: 'text/html', - }, - }); + try { + const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, { + signal: controller.signal, + headers: { + // Ehrliche Kennzeichnung statt Browser-Tarnung. + 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)', + accept: 'text/html', + }, + }); - if (!response.ok) { - throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`); + // Bremst die Gegenseite, wird gewartet statt den Spieler zu verlieren. + if (isRateLimited(response.status)) { + requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS); + + if (versuch >= MAX_RETRIES) { + throw badRequest( + 'PROFILE_RATE_LIMITED', + `GExtension bremst den Abruf aus (HTTP ${response.status})`, + ); + } + + await sleep(retryAfterMs(response.headers.get('retry-after'), versuch)); + continue; + } + + if (!response.ok) { + throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`); + } + + return parseProfile(await response.text(), steamId64); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils'); + } + throw error; + } finally { + clearTimeout(timer); } - - return parseProfile(await response.text(), steamId64); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils'); - } - throw error; - } finally { - clearTimeout(timer); } } diff --git a/server/src/lib/ratelimit.ts b/server/src/lib/ratelimit.ts new file mode 100644 index 0000000..2e0145f --- /dev/null +++ b/server/src/lib/ratelimit.ts @@ -0,0 +1,30 @@ +/** + * Rücksicht auf fremde Server. + * + * Sowohl das Forum als auch GExtension gehören der Community, nicht uns. Beide + * weisen zu viele Anfragen ab, statt sie zu beantworten. Wer darauf mit einem + * Abbruch reagiert, verliert den Rest des Laufs - deshalb wird gewartet und + * erneut versucht, und das Tempo bleibt für den Rest des Laufs gedrosselt. + */ + +/// Antworten, die "später nochmal" bedeuten. +export function isRateLimited(status: number): boolean { + return status === 429 || status === 503; +} + +/** + * Wie lange nach einer Abweisung gewartet wird. Nennt die Gegenseite selbst + * eine Zeit, gilt die; sonst wird mit jedem Versuch länger gewartet. Nach oben + * gedeckelt, damit ein Lauf nicht an einer absurden Angabe hängen bleibt. + */ +export function retryAfterMs(header: string | null, versuch: number): number { + const sekunden = Number(header); + if (Number.isFinite(sekunden) && sekunden > 0) { + return Math.min(sekunden * 1000, 60000); + } + return Math.min(5000 * (versuch + 1), 30000); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/server/src/routes/forum.routes.ts b/server/src/routes/forum.routes.ts index a1a3ab1..5093189 100644 --- a/server/src/routes/forum.routes.ts +++ b/server/src/routes/forum.routes.ts @@ -6,15 +6,23 @@ import { assignPlayer, classifyThread, dismissBoardChangeNotice, + getScanState, listBoardChangeNotices, listThreads, - scanForum, + listUnassignedThreads, + startScan, } from '../services/forum.js'; import { contextOf, idParam, parseWith } from './helpers.js'; export async function forumRoutes(app: FastifyInstance): Promise { + // Der Lauf braucht dutzende Abrufe und kann vom Forum ausgebremst werden. + // Deshalb wird er nur angestoßen; den Stand holt die Oberfläche ab. app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => { - return scanForum(contextOf(request)); + return startScan(contextOf(request)); + }); + + app.get('/api/forum/scan/status', { preHandler: requireAuth }, async () => { + return getScanState(); }); app.get('/api/forum/threads', { preHandler: requireAuth }, async (request) => { @@ -36,6 +44,21 @@ export async function forumRoutes(app: FastifyInstance): Promise { }; }); + app.get('/api/forum/threads/unassigned', { preHandler: requireAuth }, async (request) => { + const query = parseWith( + z.object({ + q: z.string().trim().max(120).optional(), + take: z.coerce.number().int().min(1).max(200).default(50), + }), + request.query, + ); + + return listUnassignedThreads({ + take: query.take, + ...(query.q ? { query: query.q } : {}), + }); + }); + app.get('/api/forum/notices', { preHandler: requireAuth }, async () => { return { items: await listBoardChangeNotices() }; }); diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 6dc7cf5..eff562a 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -469,6 +469,11 @@ export async function listCharacters(playerId: number) { include: { predecessor: { select: { id: true, name: true } }, successors: { select: { id: true, name: true } }, + // Der verknüpfte Antrag macht den Namen zum Link ins Forum. + forumThreads: { + select: { threadId: true, title: true, url: true }, + orderBy: { threadId: 'desc' }, + }, members: { orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], include: { player: { select: { id: true, displayName: true } } }, diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index f794e86..2c1e73f 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -3,6 +3,7 @@ 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 @@ -13,49 +14,83 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [ { board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' }, ]; -const MIN_REQUEST_GAP_MS = 1000; -// Das Archiv reicht Jahre zurück; bei einer Sekunde Abstand dauert ein voller -// Lauf entsprechend. Die Grenze ist eine Notbremse, keine Sparmaßnahme. +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 + MIN_REQUEST_GAP_MS - Date.now(); - if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + 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 { - await throttle(); - const base = env.FORUM_BASE_URL.replace(/\/+$/, ''); const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 20000); + for (let versuch = 0; ; versuch += 1) { + await throttle(); - 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 (!response.ok) { - throw badRequest('BOARD_UNAVAILABLE', `Board nicht abrufbar (HTTP ${response.status})`); + 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); } - 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; @@ -64,10 +99,79 @@ export interface ForumScanSummary { boardChangeNotices: number; skippedStubs: number; skippedLabels: number; - /// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon. - skippedUnknownAuthor: number; + /// Erfasst, aber noch keinem Spieler zugeordnet. Solche Threads landen nicht + /// in der Arbeitsliste, gehen aber auch nicht verloren. + unassignedThreads: number; matchedPlayers: number; - failedBoards: { board: string; reason: string }[]; + /// 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; + 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, + summary: null, + error: null, +}; + +export function getScanState(): ScanState { + return { ...scanState }; +} + +/** Stößt einen Lauf an und kehrt sofort zurück. */ +export function startScan(ctx: ActionContext): ScanState { + if (scanState.running) { + throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich'); + } + + scanState.running = true; + scanState.startedAt = new Date().toISOString(); + scanState.finishedAt = null; + scanState.currentBoard = null; + scanState.pagesRead = 0; + scanState.summary = null; + scanState.error = null; + + void scanForum(ctx) + .then((summary) => { + scanState.summary = summary; + }) + .catch((error: unknown) => { + scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen'; + }) + .finally(() => { + scanState.running = false; + scanState.currentBoard = null; + scanState.finishedAt = new Date().toISOString(); + }); + + return getScanState(); } /** @@ -84,136 +188,171 @@ export async function scanForum(ctx: ActionContext): Promise { boardChangeNotices: 0, skippedStubs: 0, skippedLabels: 0, - skippedUnknownAuthor: 0, + unassignedThreads: 0, matchedPlayers: 0, + rateLimited: 0, failedBoards: [], }; - // Forumsnamen einmal laden statt je Thread zu suchen - die aktuellen und - // frühere Konten, damit ein Namenswechsel die Zuordnung nicht abreißen lässt. - 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 }, - }), - ]); + // 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 = new Map(); - for (const alias of aliases) byForumName.set(alias.value.toLowerCase(), alias.playerId); - // Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht. - for (const player of players) { - byForumName.set((player.forumName as string).toLowerCase(), player.id); - } + 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(); for (const { board, path } of BOARDS) { - let threads: ForumThread[] = []; + let gelesen = 0; + scanState.currentBoard = path; try { const first = await fetchPage(path, 1); + gelesen += 1; summary.scannedPages += 1; - threads = parseBoard(first); + scanState.pagesRead = summary.scannedPages; - const pages = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD); - for (let page = 2; page <= pages; page += 1) { + const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD); + for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board }); + + for (let page = 2; page <= seiten; page += 1) { const html = await fetchPage(path, page); + gelesen += 1; summary.scannedPages += 1; - threads.push(...parseBoard(html)); + scanState.pagesRead = summary.scannedPages; + for (const thread of parseBoard(html)) gefunden.set(thread.threadId, { thread, board }); } summary.scannedBoards += 1; } 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. summary.failedBoards.push({ board: path, reason: error instanceof Error ? error.message : 'Unbekannter Fehler', + pagesRead: gelesen, }); + } + } + + summary.rateLimited = rateLimitHits; + + 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, + }); + historie.push({ threadId: thread.threadId, board }); + summary.newThreads += 1; + if (playerId === null) summary.unassignedThreads += 1; + else summary.matchedPlayers += 1; continue; } - for (const thread of threads) { - // Ein zurückgelassener Verweis ist kein eigener Antrag. - if (thread.isMovedStub) { - summary.skippedStubs += 1; - continue; - } + const verschoben = existing.currentBoard !== board; + // Eine später nachgetragene Zuordnung nicht wieder überschreiben. + const nachgetragen = existing.playerId === null && playerId !== null; - // Angepinnte Ankündigungen liegen mit in den Boards. - if (hasIgnoredLabel(thread)) { - summary.skippedLabels += 1; - continue; - } + const geaendert = + verschoben || + nachgetragen || + existing.title !== thread.title || + existing.url !== thread.url || + existing.authorName !== thread.authorName || + existing.authorForumId !== thread.authorForumId || + existing.labels !== labels; - const playerId = thread.authorName - ? byForumName.get(thread.authorName.toLowerCase()) ?? null - : null; + if (!geaendert) continue; - const existing = await prisma.forumThread.findUnique({ - where: { threadId: thread.threadId }, - }); + aenderungen.push({ + threadId: thread.threadId, + data: { + title: thread.title, + url: thread.url, + authorName: thread.authorName, + authorForumId: thread.authorForumId, + labels, + ...(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 } : {}), + } + : {}), + }, + }); - // 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: { - threadId: thread.threadId, - title: thread.title, - url: thread.url, - authorName: thread.authorName, - authorForumId: thread.authorForumId, - labels: thread.labels.join(', ') || null, - playerId, - currentBoard: board, - history: { create: [{ board }] }, - }, - }); - summary.newThreads += 1; - summary.matchedPlayers += 1; - continue; - } - - const movedBoard = existing.currentBoard !== board; - - await prisma.forumThread.update({ - where: { threadId: thread.threadId }, - data: { - title: thread.title, - 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 } : {}), - ...(movedBoard - ? { - 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 (movedBoard) { - await prisma.forumThreadBoard.create({ data: { threadId: thread.threadId, board } }); - summary.movedThreads += 1; - if (existing.classification !== null) summary.boardChangeNotices += 1; - } + 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() }, + }); + } + await recordAudit(prisma, ctx, { action: 'forum.scan', targetType: 'forum', @@ -224,6 +363,31 @@ export async function scanForum(ctx: ActionContext): Promise { 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; +} + export async function listThreads(options: { onlyOpen?: boolean; playerId?: number; @@ -231,7 +395,10 @@ export async function listThreads(options: { }) { return prisma.forumThread.findMany({ where: { - ...(options.onlyOpen ? { needsClassification: true } : {}), + // 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 } : {}), }, @@ -243,6 +410,66 @@ export async function listThreads(options: { }); } +/** + * 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 begrenzt statt als eine lange Liste. + */ +export async function listUnassignedThreads(options: { query?: string; take?: number }) { + const take = Math.min(Math.max(options.take ?? 50, 1), 200); + const suche = options.query?.trim(); + + const where: Prisma.ForumThreadWhereInput = { + playerId: null, + needsClassification: true, + ...(suche + ? { + OR: [ + { title: { contains: suche, mode: 'insensitive' } }, + { authorName: { contains: suche, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + prisma.forumThread.findMany({ + where, + orderBy: [{ threadId: 'desc' }], + take, + include: { + player: { select: { id: true, displayName: true } }, + linkedCharacter: { select: { id: true, name: true } }, + }, + }), + prisma.forumThread.count({ where }), + ]); + + return { items, total }; +} + +/** + * 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({ diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 92d3d3a..5bcfa91 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -124,7 +124,7 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) { const steamId = canonicalSteamId(input.steamId); if (steamId) await assertSteamIdFree(steamId); - return prisma.$transaction(async (tx) => { + const created = await prisma.$transaction(async (tx) => { const created = await tx.player.create({ data: { displayName: input.displayName.trim(), @@ -143,6 +143,15 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) { return created; }); + + // Wartende Forums-Anträge unter diesem Namen gleich mit übernehmen, sonst + // blieben sie bis zum nächsten vollen Abgleich in der Warteliste liegen. + if (created.forumName) { + const { claimThreadsForPlayer } = await import('./forum.js'); + await claimThreadsForPlayer(created.id, [created.forumName]); + } + + return created; } export async function updatePlayer(playerId: number, input: Partial, ctx: ActionContext) { @@ -152,7 +161,7 @@ export async function updatePlayer(playerId: number, input: Partial const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId); if (steamId) await assertSteamIdFree(steamId, playerId); - return prisma.$transaction(async (tx) => { + const updated = await prisma.$transaction(async (tx) => { const updated = await tx.player.update({ where: { id: playerId }, data: { @@ -172,6 +181,13 @@ export async function updatePlayer(playerId: number, input: Partial return updated; }); + + if (updated.forumName && updated.forumName !== existing.forumName) { + const { claimThreadsForPlayer } = await import('./forum.js'); + await claimThreadsForPlayer(playerId, [updated.forumName]); + } + + return updated; } /** @@ -282,6 +298,11 @@ export async function addAlias( details: { type, value, note: note ?? null }, }); + if (type === AliasType.FORUM_NAME) { + const { claimThreadsForPlayer } = await import('./forum.js'); + await claimThreadsForPlayer(playerId, [value]); + } + return created; } diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index a87fe51..1f335ce 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -5,6 +5,7 @@ import { badRequest } from '../errors.js'; import { classifyPackage, fetchProfile, + resetRequestPace, type ScrapedProfile, } from '../lib/gextension.js'; import { PLANS } from './imports.js'; @@ -81,6 +82,10 @@ export async function scrapePlayers( const rows: Prisma.ImportRowCreateManyImportInput[] = []; let lineNo = 0; + // Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst, + // wenn GExtension es verlangt. + resetRequestPace(); + // Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne // diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren // Import, und beim Buchen kollidieren sie miteinander. diff --git a/server/test/antraege.test.ts b/server/test/antraege.test.ts new file mode 100644 index 0000000..cec7634 --- /dev/null +++ b/server/test/antraege.test.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Forum = typeof import('../src/services/forum.js'); +type Players = typeof import('../src/services/players.js'); +type Characters = typeof import('../src/services/characters.js'); +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let forum: Forum; +let players: Players; +let characters: Characters; +let db: Db; +let ctx: { actorId: number; actorName: string }; + +before(async () => { + database = await startTestDatabase(54337); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + + db = await import('../src/db.js'); + forum = await import('../src/services/forum.js'); + players = await import('../src/services/players.js'); + characters = await import('../src/services/characters.js'); + + const user = await db.prisma.user.create({ + data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' }, + }); + ctx = { actorId: user.id, actorName: user.username }; +}); + +after(async () => { + await db.prisma.$disconnect(); + await database.stop(); +}); + +/** Legt einen Thread an, wie ihn der Abgleich ohne Spieler vorfinden würde. */ +async function offenerThread(threadId: number, titel: string, autor: string) { + return db.prisma.forumThread.create({ + data: { + threadId, + title: titel, + url: `https://forum.example.org/index.php?thread/${threadId}-antrag/`, + authorName: autor, + currentBoard: 'CC_ARCHIV', + }, + }); +} + +describe('Anträge ohne zugeordneten Spieler', () => { + it('hält sie aus der Arbeitsliste heraus, aber nicht aus der Datenbank', async () => { + await offenerThread(9001, 'CC Antrag von einem Fremden', 'Unbekannter'); + + const arbeitsliste = await forum.listThreads({ onlyOpen: true }); + assert.equal( + arbeitsliste.some((t) => t.threadId === 9001), + false, + 'ohne Spieler ist nichts zu entscheiden', + ); + + const wartend = await forum.listUnassignedThreads({}); + assert.equal( + wartend.items.some((t) => t.threadId === 9001), + true, + 'auffindbar bleibt er trotzdem', + ); + }); + + it('findet sie über Titel und Ersteller', async () => { + await offenerThread(9002, 'Feldagent Ersthelfer', 'Darkbrine44'); + + const nachTitel = await forum.listUnassignedThreads({ query: 'ersthelfer' }); + assert.deepEqual( + nachTitel.items.map((t) => t.threadId), + [9002], + 'Grossschreibung darf keine Rolle spielen', + ); + + const nachAutor = await forum.listUnassignedThreads({ query: 'darkbrine' }); + assert.deepEqual( + nachAutor.items.map((t) => t.threadId), + [9002], + ); + }); + + it('begrenzt die Ausgabe, nennt aber die volle Zahl', async () => { + for (const id of [9010, 9011, 9012]) await offenerThread(id, `Antrag ${id}`, 'Serienbeantrager'); + + const seite = await forum.listUnassignedThreads({ query: 'Serienbeantrager', take: 2 }); + assert.equal(seite.items.length, 2); + assert.equal(seite.total, 3, 'sonst wüsste niemand, dass etwas fehlt'); + }); +}); + +describe('Nachträgliche Zuordnung', () => { + it('zieht wartende Anträge an einen neu angelegten Spieler', async () => { + await offenerThread(9101, 'CC Antrag von Monox', 'Monox'); + await offenerThread(9102, 'Redesign von Monox', 'monox'); + + const spieler = await players.createPlayer( + { displayName: 'Monox', steamId: '76561198000002222', forumName: 'Monox' }, + ctx, + ); + + const zugeordnet = await forum.listThreads({ playerId: spieler.id }); + assert.deepEqual( + zugeordnet.map((t) => t.threadId).sort(), + [9101, 9102], + 'auch die abweichende Schreibweise gehört dazu', + ); + }); + + it('zieht sie auch nach, wenn der Forum-Name erst später eingetragen wird', async () => { + await offenerThread(9201, 'Antrag eines Umbenannten', 'NeuerName'); + + const spieler = await players.createPlayer( + { displayName: 'Ohne Forum', steamId: '76561198000003333' }, + ctx, + ); + assert.equal((await forum.listThreads({ playerId: spieler.id })).length, 0); + + await players.updatePlayer(spieler.id, { forumName: 'NeuerName' }, ctx); + + const zugeordnet = await forum.listThreads({ playerId: spieler.id }); + assert.deepEqual( + zugeordnet.map((t) => t.threadId), + [9201], + ); + }); + + it('holt über ein früheres Konto auch die alten Anträge', async () => { + await offenerThread(9301, 'Antrag unter dem alten Namen', 'AltesKonto'); + + const spieler = await players.createPlayer( + { displayName: 'Umzügler', steamId: '76561198000004444', forumName: 'NeuesKonto' }, + ctx, + ); + await players.addAlias(spieler.id, 'FORUM_NAME', 'AltesKonto', 'Vor dem Wechsel', ctx); + + const zugeordnet = await forum.listThreads({ playerId: spieler.id }); + assert.deepEqual( + zugeordnet.map((t) => t.threadId), + [9301], + ); + }); + + it('nimmt einem Spieler nie einen bereits zugeordneten Antrag weg', async () => { + const erster = await players.createPlayer( + { displayName: 'Erster', steamId: '76561198000005555', forumName: 'Doppelgänger' }, + ctx, + ); + await offenerThread(9401, 'Strittiger Antrag', 'Doppelgänger'); + await forum.assignPlayer(9401, erster.id, ctx); + + // Ein zweiter Spieler trägt denselben Namen als früheres Konto nach. + const zweiter = await players.createPlayer( + { displayName: 'Zweiter', steamId: '76561198000006666' }, + ctx, + ); + await players.updatePlayer(zweiter.id, { forumName: 'Doppelgänger' }, ctx); + + const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9401 } }); + assert.equal(thread.playerId, erster.id, 'eine getroffene Entscheidung bleibt stehen'); + }); +}); + +describe('Antrag am CC', () => { + it('hängt den Thread an den CC, der aus ihm hervorgeht', async () => { + const spieler = await players.createPlayer( + { displayName: 'Antragsteller', steamId: '76561198000007777', forumName: 'Antragsteller' }, + ctx, + ); + await db.prisma.creditLedger.create({ + data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, + }); + + await offenerThread(9501, 'The Duke', 'Antragsteller'); + await forum.claimThreadsForPlayer(spieler.id, ['Antragsteller']); + + const { characterId } = await forum.classifyThread( + 9501, + { classification: 'NEW_CC', characterName: 'The Duke' }, + ctx, + ); + assert.ok(characterId); + + const liste = await characters.listCharacters(spieler.id); + const cc = liste.find((c) => c.id === characterId); + assert.deepEqual( + cc?.forumThreads.map((t) => t.threadId), + [9501], + 'ohne das bliebe der Name auf der Spielerseite ein toter Text', + ); + assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/); + }); +}); diff --git a/server/test/fixtures/board.html b/server/test/fixtures/board.html index ebcc525..53cd200 100644 --- a/server/test/fixtures/board.html +++ b/server/test/fixtures/board.html @@ -68,6 +68,25 @@
  • +
  • + +
  • diff --git a/server/test/forum.test.ts b/server/test/forum.test.ts index 631135e..a95d208 100644 --- a/server/test/forum.test.ts +++ b/server/test/forum.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it } from 'node:test'; import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js'; +import { isRateLimited, retryAfterMs } from '../src/lib/ratelimit.js'; const here = dirname(fileURLToPath(import.meta.url)); const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8'); @@ -12,13 +13,22 @@ describe('Board-Übersicht lesen', () => { const threads = parseBoard(BOARD); it('liest die Threads der Liste', () => { - assert.equal(threads.length, 5); + assert.equal(threads.length, 6); assert.deepEqual( threads.map((t) => t.threadId), - [7001, 7002, 7004, 7005, 7003], + [7001, 7002, 7004, 7005, 7003, 7006], ); }); + it('liest den Ersteller auch ohne Profil-Link', () => { + // Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne + // Rueckfall haette der Thread keinen Autor und liesse sich niemandem + // zuordnen - er waere im Abgleich unsichtbar. + const ohneKonto = threads.find((t) => t.threadId === 7006); + assert.equal(ohneKonto?.authorName, 'Ehemaliger'); + assert.equal(ohneKonto?.authorForumId, null); + }); + it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => { const first = threads.find((t) => t.threadId === 7001); assert.equal(first?.authorName, 'Antragsteller'); @@ -99,3 +109,32 @@ describe('Thread-Verweis lesen', () => { assert.equal(parseThreadId('kein link'), null); }); }); + +describe('Ruecksicht auf einen bremsenden Server', () => { + it('erkennt die Abweisungen, die "spaeter nochmal" bedeuten', () => { + assert.equal(isRateLimited(429), true); + assert.equal(isRateLimited(503), true); + assert.equal(isRateLimited(404), false, 'eine fehlende Seite kommt auch spaeter nicht'); + assert.equal(isRateLimited(200), false); + }); + + it('haelt sich an die vom Forum genannte Wartezeit', () => { + assert.equal(retryAfterMs('30', 0), 30000); + }); + + it('deckelt eine unverschaemt lange Angabe', () => { + assert.equal(retryAfterMs('86400', 0), 60000); + }); + + it('wartet ohne Angabe mit jedem Versuch laenger', () => { + assert.equal(retryAfterMs(null, 0), 5000); + assert.equal(retryAfterMs(null, 1), 10000); + assert.equal(retryAfterMs(null, 2), 15000); + }); + + it('ignoriert eine unbrauchbare Angabe', () => { + // WoltLab darf hier auch ein Datum schicken - dann greift die Staffelung. + assert.equal(retryAfterMs('Wed, 21 Oct 2026 07:28:00 GMT', 1), 10000); + assert.equal(retryAfterMs('-5', 0), 5000); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index d3a7456..498bbb0 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -68,9 +68,9 @@ export const BOARD_LABEL: Record = { }; export const CLASSIFICATION_LABEL: Record = { - NEW_CC: 'Angenommen – neuer CC', - REDESIGN: 'Angenommen – Redesign', - REACTIVATION: 'Angenommen – Reaktivierung', + NEW_CC: 'Angenommen - neuer CC', + REDESIGN: 'Angenommen - Redesign', + REACTIVATION: 'Angenommen - Reaktivierung', REJECTED: 'Abgelehnt', IGNORE: 'Ignoriert', }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index dab75ce..b537e79 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -90,6 +90,13 @@ export interface CharacterRef { name: string; } +/// Der Forums-Antrag, aus dem ein CC hervorgegangen ist. +export interface ThreadRef { + threadId: number; + title: string; + url: string; +} + export interface CharacterMember { id: number; characterId: number; @@ -113,6 +120,7 @@ export interface Character { predecessorId: number | null; predecessor: CharacterRef | null; successors: CharacterRef[]; + forumThreads?: ThreadRef[]; members?: CharacterMember[]; createdAt: string; } @@ -257,7 +265,23 @@ export interface ForumScanSummary { boardChangeNotices: number; skippedStubs: number; skippedLabels: number; - skippedUnknownAuthor: number; + unassignedThreads: number; + rateLimited: number; matchedPlayers: number; - failedBoards: { board: string; reason: string }[]; + failedBoards: { board: string; reason: string; pagesRead: number }[]; +} + +export interface UnassignedThreads { + items: ForumThread[]; + total: number; +} + +export interface ScanState { + running: boolean; + startedAt: string | null; + finishedAt: string | null; + currentBoard: string | null; + pagesRead: number; + summary: ForumScanSummary | null; + error: string | null; } diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx index cd298df..82e33ba 100644 --- a/web/src/pages/Forum.tsx +++ b/web/src/pages/Forum.tsx @@ -12,8 +12,17 @@ import type { ForumThread, PlayerDetail, PlayerSummary, + ScanState, + UnassignedThreads, } from '../lib/types'; +/// Die Board-Pfade des Abgleichs in lesbarer Form. +const BOARD_PFAD: Record = { + 'board/66-custom-characters': 'die Antragsübersicht', + 'board/355-aktive-ccs': 'die aktiven CCs', + 'board/113-cc-archiv': 'das Archiv', +}; + export default function Forum() { const { can } = useSession(); const darfEntscheiden = can('EDITOR'); @@ -25,6 +34,21 @@ export default function Forum() { const [busy, setBusy] = useState(false); const [dialog, setDialog] = useState(null); const [assigning, setAssigning] = useState(null); + const [ohneSpieler, setOhneSpieler] = useState(null); + const [suche, setSuche] = useState(''); + const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false); + /// Hochzählen heißt: die Liste ohne Spieler neu holen. + const [stand, setStand] = useState(0); + const [scanZustand, setScan] = useState(null); + + const ladeOhneSpieler = useCallback(async (query: string) => { + const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''; + try { + setOhneSpieler(await api(`/api/forum/threads/unassigned${param}`)); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } + }, []); const load = useCallback(async () => { try { @@ -38,33 +62,88 @@ export default function Forum() { } catch (err) { setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } + setStand((n) => n + 1); }, []); useEffect(() => { void load(); }, [load]); + // Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken. + useEffect(() => { + const timer = window.setTimeout(() => void ladeOhneSpieler(suche), suche ? 250 : 0); + return () => window.clearTimeout(timer); + }, [suche, stand, ladeOhneSpieler]); + + const bericht = (result: ForumScanSummary): string => { + const parts = [`${result.scannedPages} Seiten gelesen`]; + if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`); + if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`); + if (result.unassignedThreads > 0) { + parts.push(`${result.unassignedThreads} ohne zugeordneten Spieler`); + } + if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`); + if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`); + if (result.rateLimited > 0) parts.push(`${result.rateLimited} Mal ausgebremst`); + if (result.failedBoards.length > 0) { + parts.push( + result.failedBoards + .map((b) => `${b.board} nur bis Seite ${b.pagesRead} (${b.reason})`) + .join('; '), + ); + } + return parts.join(', ') + '.'; + }; + + // Der Lauf läuft im Hintergrund weiter, auch wenn die Seite neu geladen + // wird. Deshalb wird der Stand abgefragt, statt auf eine Antwort zu warten. + useEffect(() => { + if (!busy) return; + + const timer = window.setInterval(() => { + void (async () => { + try { + const state = await api('/api/forum/scan/status'); + setScan(state); + if (state.running) return; + + setBusy(false); + if (state.error) setError(state.error); + else if (state.summary) { + setFlash(bericht(state.summary)); + window.setTimeout(() => setFlash(null), 12000); + } + await load(); + } catch (err) { + setBusy(false); + setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } + })(); + }, 2000); + + return () => window.clearInterval(timer); + }, [busy, load]); + + // Ein Lauf aus einer anderen Sitzung soll hier ebenfalls sichtbar sein. + useEffect(() => { + void (async () => { + try { + const state = await api('/api/forum/scan/status'); + setScan(state); + if (state.running) setBusy(true); + } catch { + // Ohne Stand bleibt der Knopf einfach bedienbar. + } + })(); + }, []); + const scan = async () => { - setBusy(true); setError(null); try { - const result = await api('/api/forum/scan', { method: 'POST', body: {} }); - const parts = [`${result.scannedPages} Seiten gelesen`]; - if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`); - if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`); - if (result.skippedUnknownAuthor > 0) { - 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`); - setFlash(parts.join(', ') + '.'); - await load(); - window.setTimeout(() => setFlash(null), 8000); + setScan(await api('/api/forum/scan', { method: 'POST', body: {} })); + setBusy(true); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); - } finally { - setBusy(false); } }; @@ -90,7 +169,9 @@ export default function Forum() { {darfEntscheiden && ( )} @@ -98,6 +179,15 @@ export default function Forum() { {error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} + {busy && ( + + Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} - + bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und + zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der + Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird. + + )} + {notices.length > 0 && (

    Verschoben nach der Entscheidung ({notices.length})

    @@ -191,6 +281,86 @@ export default function Forum() { )}
    +
    +
    +
    +

    Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})

    +

    + Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben + aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit + passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber. +

    +
    + +
    + + {zeigeOhneSpieler && ( + <> + setSuche(event.target.value)} + /> + + {ohneSpieler === null || ohneSpieler.items.length === 0 ? ( +

    + {suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'} +

    + ) : ( + <> +
      + {ohneSpieler.items.map((thread) => ( +
    • +
      + + {thread.title} + + {BOARD_LABEL[thread.currentBoard]} +

      + von {thread.authorName ?? 'unbekannt'} + {thread.labels && ` · ${thread.labels}`} +

      +
      + {darfEntscheiden && ( +
      + +
      + )} +
    • + ))} +
    + {ohneSpieler.total > ohneSpieler.items.length && ( +

    + Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche + eingrenzen. +

    + )} + + )} + + )} +
    + {dialog && ( {( [ - ['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'], - ['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'], - ['REACTIVATION', 'Angenommen – Reaktivierung', 'ein inaktiver CC kehrt zurück'], + ['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'], + ['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'], + ['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'], ['REJECTED', 'Abgelehnt', 'keine Buchung'], ['IGNORE', 'Kein Antrag', 'keine Buchung'], ] as const diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index de8c1d3..642d162 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -26,6 +26,27 @@ import type { type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD'; +/** + * Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt + * dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei. + */ +function CharacterName({ character }: { character: Character }) { + const thread = character.forumThreads?.[0]; + if (!thread) return {character.name}; + + return ( + + {character.name} + + ); +} + type DialogState = | { type: 'none' } | { type: 'newCharacter' } @@ -247,7 +268,7 @@ export default function PlayerDetail() { {active.map((character) => (
  • - {character.name} + {character.members && character.members.filter((m) => !m.leftAt).length > 1 && ( Gruppen-CC · {character.members.filter((m) => !m.leftAt).length} @@ -316,7 +337,7 @@ export default function PlayerDetail() { return (
  • - {character.name} +