Forums-Anträge erfassen und von Hand entscheiden
Der GExtension-Abgleich liefert die Käufe, der Verbrauch steht im Forum. Die App liest jetzt die drei CC-Boards und sammelt die Anträge. Gelesen wird nur die Board-Übersicht: sie führt bereits den Ersteller, nicht nur den letzten Antwortenden. Damit bleibt ein Lauf bei wenigen Anfragen statt einer je Thread. Unterforen und globale Kästen tragen ebenfalls Thread-Links, deshalb liest der Parser strikt entlang der Listeneinträge - sonst landen Changelogs und Bewerbungen in der Antragsliste. Klassifiziert wird ausschließlich von Hand. Weder Titel noch Board sind verlässlich genug: Threads werden nicht immer verschoben, ein genehmigter Antrag kann weiter im Antrags-Board liegen. Das Board steht deshalb nur als Hinweis an der Zeile. Angenommene Anträge rufen dieselben Aktionen wie die Knöpfe auf der Spielerseite, es gibt keinen zweiten Buchungsweg mit eigenen Regeln. Wandert ein bereits entschiedener Thread später in ein anderes Board, erscheint das als Hinweis zum Abhaken - der CC-Status ändert sich nie von allein. Die Thread-ID bleibt beim Verschieben stabil und traegt deshalb die Wiedererkennung; die Board-Historie wird mitgeschrieben. Zurückgelassene Verweise erkennt der Parser und zählt sie nicht als eigenen Antrag.
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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 { 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' },
|
||||
];
|
||||
|
||||
const MIN_REQUEST_GAP_MS = 1000;
|
||||
const MAX_PAGES_PER_BOARD = 20;
|
||||
let lastRequestAt = 0;
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now();
|
||||
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
lastRequestAt = Date.now();
|
||||
}
|
||||
|
||||
async function fetchPage(path: string, page: number): Promise<string> {
|
||||
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);
|
||||
|
||||
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})`);
|
||||
}
|
||||
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;
|
||||
matchedPlayers: number;
|
||||
withoutPlayer: number;
|
||||
failedBoards: { board: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ForumScanSummary> {
|
||||
const summary: ForumScanSummary = {
|
||||
scannedBoards: 0,
|
||||
scannedPages: 0,
|
||||
newThreads: 0,
|
||||
movedThreads: 0,
|
||||
boardChangeNotices: 0,
|
||||
skippedStubs: 0,
|
||||
matchedPlayers: 0,
|
||||
withoutPlayer: 0,
|
||||
failedBoards: [],
|
||||
};
|
||||
|
||||
// Forumsnamen einmal laden statt je Thread zu suchen.
|
||||
const players = await prisma.player.findMany({
|
||||
where: { forumName: { not: null } },
|
||||
select: { id: true, forumName: true },
|
||||
});
|
||||
const byForumName = new Map(players.map((p) => [(p.forumName as string).toLowerCase(), p.id]));
|
||||
|
||||
for (const { board, path } of BOARDS) {
|
||||
let threads: ForumThread[] = [];
|
||||
|
||||
try {
|
||||
const first = await fetchPage(path, 1);
|
||||
summary.scannedPages += 1;
|
||||
threads = parseBoard(first);
|
||||
|
||||
const pages = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
|
||||
for (let page = 2; page <= pages; page += 1) {
|
||||
const html = await fetchPage(path, page);
|
||||
summary.scannedPages += 1;
|
||||
threads.push(...parseBoard(html));
|
||||
}
|
||||
summary.scannedBoards += 1;
|
||||
} catch (error) {
|
||||
summary.failedBoards.push({
|
||||
board: path,
|
||||
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const thread of threads) {
|
||||
// Ein zurückgelassener Verweis ist kein eigener Antrag.
|
||||
if (thread.isMovedStub) {
|
||||
summary.skippedStubs += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const playerId = thread.authorName
|
||||
? byForumName.get(thread.authorName.toLowerCase()) ?? null
|
||||
: null;
|
||||
|
||||
const existing = await prisma.forumThread.findUnique({
|
||||
where: { threadId: thread.threadId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.forumThread.create({
|
||||
data: {
|
||||
threadId: thread.threadId,
|
||||
title: thread.title,
|
||||
url: thread.url,
|
||||
authorName: thread.authorName,
|
||||
authorForumId: thread.authorForumId,
|
||||
playerId,
|
||||
currentBoard: board,
|
||||
history: { create: [{ board }] },
|
||||
},
|
||||
});
|
||||
summary.newThreads += 1;
|
||||
if (playerId) summary.matchedPlayers += 1;
|
||||
else summary.withoutPlayer += 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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'forum.scan',
|
||||
targetType: 'forum',
|
||||
targetId: null,
|
||||
details: { ...summary } as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function listThreads(options: {
|
||||
onlyOpen?: boolean;
|
||||
playerId?: number;
|
||||
board?: ForumBoard;
|
||||
}) {
|
||||
return prisma.forumThread.findMany({
|
||||
where: {
|
||||
...(options.onlyOpen ? { needsClassification: true } : {}),
|
||||
...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
|
||||
...(options.board ? { currentBoard: options.board } : {}),
|
||||
},
|
||||
orderBy: [{ lastSeenAt: 'desc' }, { threadId: 'desc' }],
|
||||
include: {
|
||||
player: { select: { id: true, displayName: true } },
|
||||
linkedCharacter: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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');
|
||||
}
|
||||
const result = await redesignCharacter(
|
||||
input.predecessorId,
|
||||
{
|
||||
name,
|
||||
loreText: input.loreText,
|
||||
useKind: input.useKind ?? 'REDESIGN_KULANZ',
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user