Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
834 lines
28 KiB
TypeScript
834 lines
28 KiB
TypeScript
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<string, string> = {
|
|
'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<void> {
|
|
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<string> {
|
|
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<ForumScanSummary> {
|
|
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<ForumScanSummary> {
|
|
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<number, { thread: ForumThread; board: ForumBoard }>();
|
|
|
|
// 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<Map<string, number>> {
|
|
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<string, number>();
|
|
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<number> {
|
|
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<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;
|
|
/// 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<void> {
|
|
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 },
|
|
});
|
|
}
|