Antraege vollstaendig erfassen und zum Forum verlinken
Threads ohne zuordenbaren Spieler wurden bisher verworfen. Von 502 Antraegen im Forum kamen dadurch nur die wenigen an, deren Ersteller schon als Spieler mit Forum-Namen erfasst war - der Rest war schlicht unsichtbar. Sie werden jetzt erfasst und stehen unter "Ohne zugeordneten Spieler", durchsuchbar nach Titel und Ersteller, aber ausserhalb der Arbeitsliste: ohne Spieler laesst sich nichts buchen. Wird spaeter ein Spieler mit passendem Forum-Namen angelegt, der Name nachgetragen oder ein frueheres Konto ergaenzt, wandern seine wartenden Antraege von selbst hinueber. Ein erneuter Abgleich ist dafuer nicht noetig, und eine bereits getroffene Zuordnung bleibt unberuehrt. Der Name eines CCs fuehrt auf der Spielerseite jetzt zum Forumsbeitrag, sofern einer verknuepft ist. Weitere Korrekturen am Abgleich: - Wer sein Forumskonto geloescht hat, stand ohne Profil-Link in der Zeile und hatte damit keinen Autor. Solche Antraege liessen sich niemandem zuordnen. - Bei einer abgewiesenen Anfrage brach das ganze Board ab; die bis dahin gelesenen Seiten gingen mit verloren. Jetzt wird die genannte Wartezeit eingehalten, das Tempo fuer den Rest des Laufs gedrosselt und die Seite erneut versucht. Was gelesen wurde, bleibt in jedem Fall erhalten. - Derselbe Schutz im GExtension-Abgleich, der sonst den Spieler uebersprungen haette. - Der Board-Filter fragte nach einem Feld "board", das es nicht gibt. Ein voller Lauf dauert rund eine halbe Minute und laenger, wenn das Forum bremst. Er laeuft deshalb im Hintergrund weiter und meldet seinen Fortschritt, statt an einer HTTP-Zeitgrenze zu sterben. Gelesen und geschrieben wird gebuendelt statt Thread fuer Thread.
This commit is contained in:
+357
-130
@@ -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<void> {
|
||||
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<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);
|
||||
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<ForumScanSummary> {
|
||||
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<string, number>();
|
||||
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<number, { thread: ForumThread; board: ForumBoard }>();
|
||||
|
||||
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<ForumScanSummary> {
|
||||
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;
|
||||
}
|
||||
|
||||
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<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({
|
||||
|
||||
Reference in New Issue
Block a user