Antraege eingrenzen, Reaktivierung und Bestaetigungen
Sechs Ruecklaeufer aus dem ersten Praxistest: Das Archiv reicht ueber 25 Seiten und enthaelt vieles, was kein CC-Antrag ist. Aufgenommen werden jetzt nur Threads von Spielern, die im CC-Manager mit passendem Forum-Namen erfasst sind. Angepinnte Ankuendigungen tragen die Labels "Wichtig" oder "Information" und fallen darueber raus. Der Titel wird bewusst nirgends ausgewertet - weder zum Erkennen noch zum Aussortieren. Antraege heissen mal "CC Antrag | Name", mal "The Duke Rang Update"; daran ist nichts abzulesen. Eine Reaktivierung laeuft ebenfalls ueber einen Forums-Antrag und fehlte als Entscheidung. Sie ist jetzt waehlbar und kostet nichts, solange der CC nicht redesignt wurde - die bestehende Regel entscheidet das, nicht der Dialog. Der Titel in der Liste verlinkt jetzt auf den Thread im Forum. Die Labels stehen als Kontext daneben, ohne die Entscheidung vorwegzunehmen. Wer einen Antrag von Hand eintraegt, kann beim Anlegen oder Redesignen den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht nicht erneut als offen auf. Manuelle Guthaben-Buchungen fragen vorher nach: bisher buchte ein einzelner Klick sofort, ohne zu sagen, was genau passiert. Der Dialog nennt die Wirkung und nimmt eine Notiz entgegen.
This commit is contained in:
+122
-13
@@ -2,7 +2,7 @@ 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 { hasIgnoredLabel, 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
|
||||
@@ -61,8 +61,10 @@ export interface ForumScanSummary {
|
||||
movedThreads: number;
|
||||
boardChangeNotices: number;
|
||||
skippedStubs: number;
|
||||
skippedLabels: number;
|
||||
/// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon.
|
||||
skippedUnknownAuthor: number;
|
||||
matchedPlayers: number;
|
||||
withoutPlayer: number;
|
||||
failedBoards: { board: string; reason: string }[];
|
||||
}
|
||||
|
||||
@@ -79,8 +81,9 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
movedThreads: 0,
|
||||
boardChangeNotices: 0,
|
||||
skippedStubs: 0,
|
||||
skippedLabels: 0,
|
||||
skippedUnknownAuthor: 0,
|
||||
matchedPlayers: 0,
|
||||
withoutPlayer: 0,
|
||||
failedBoards: [],
|
||||
};
|
||||
|
||||
@@ -121,6 +124,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Angepinnte Ankündigungen liegen mit in den Boards.
|
||||
if (hasIgnoredLabel(thread)) {
|
||||
summary.skippedLabels += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const playerId = thread.authorName
|
||||
? byForumName.get(thread.authorName.toLowerCase()) ?? null
|
||||
: null;
|
||||
@@ -129,6 +138,15 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
where: { threadId: thread.threadId },
|
||||
});
|
||||
|
||||
// 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: {
|
||||
@@ -137,14 +155,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
url: thread.url,
|
||||
authorName: thread.authorName,
|
||||
authorForumId: thread.authorForumId,
|
||||
labels: thread.labels.join(', ') || null,
|
||||
playerId,
|
||||
currentBoard: board,
|
||||
history: { create: [{ board }] },
|
||||
},
|
||||
});
|
||||
summary.newThreads += 1;
|
||||
if (playerId) summary.matchedPlayers += 1;
|
||||
else summary.withoutPlayer += 1;
|
||||
summary.matchedPlayers += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -157,6 +175,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
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 } : {}),
|
||||
@@ -268,7 +287,9 @@ export interface ClassifyInput {
|
||||
loreText?: string;
|
||||
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
|
||||
predecessorId?: number;
|
||||
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||
/// Nur bei REACTIVATION: welcher inaktive CC kehrt zurück.
|
||||
characterId?: number;
|
||||
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ' | 'CC';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +310,33 @@ export async function classifyThread(
|
||||
|
||||
let characterId: number | null = null;
|
||||
|
||||
if (
|
||||
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
|
||||
) {
|
||||
@@ -313,14 +360,11 @@ export async function classifyThread(
|
||||
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: input.useKind ?? 'REDESIGN_KULANZ',
|
||||
note,
|
||||
},
|
||||
{ name, loreText: input.loreText, useKind: redesignKind, note },
|
||||
ctx,
|
||||
);
|
||||
characterId = result.character.id;
|
||||
@@ -352,3 +396,68 @@ export async function classifyThread(
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user