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:
@@ -183,13 +183,23 @@ Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antr
|
|||||||
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
||||||
|
|
||||||
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
||||||
welcher CC ersetzt wurde und woraus bezahlt wird), *abgelehnt* oder *kein Antrag*. Die beiden
|
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
||||||
|
CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
||||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
||||||
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||||
|
|
||||||
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler.
|
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler.
|
||||||
Ohne Treffer bleibt der Antrag in der Liste und lässt sich von Hand zuordnen; trägst du den
|
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
||||||
Forumsnamen beim Spieler nach, findet ihn der nächste Abgleich von allein.
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" — daran ist nichts zu erkennen.
|
||||||
|
|
||||||
|
Trägst du einen Antrag von Hand ein — über *Neuer CC* oder *Redesign* auf der Spielerseite —
|
||||||
|
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
||||||
|
nächsten Abgleich nicht erneut auf.
|
||||||
|
|
||||||
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die
|
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die
|
||||||
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Reaktivierung als eigene Entscheidung, Labels und Thread-Verknuepfung.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE "ForumClassification" ADD VALUE IF NOT EXISTS 'REACTIVATION';
|
||||||
|
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- Labels des Threads, nur zur Anzeige als Kontext.
|
||||||
|
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "labels" TEXT;
|
||||||
@@ -55,6 +55,8 @@ enum ForumBoard {
|
|||||||
enum ForumClassification {
|
enum ForumClassification {
|
||||||
NEW_CC
|
NEW_CC
|
||||||
REDESIGN
|
REDESIGN
|
||||||
|
/// Ein inaktiver CC kehrt zurueck. Ohne Redesign kostet das nichts.
|
||||||
|
REACTIVATION
|
||||||
REJECTED
|
REJECTED
|
||||||
IGNORE
|
IGNORE
|
||||||
}
|
}
|
||||||
@@ -317,6 +319,8 @@ model ForumThread {
|
|||||||
authorName String?
|
authorName String?
|
||||||
/// Überlebt Namensänderungen im Forum, anders als der Anzeigename.
|
/// Überlebt Namensänderungen im Forum, anders als der Anzeigename.
|
||||||
authorForumId Int?
|
authorForumId Int?
|
||||||
|
/// Labels des Threads, kommagetrennt. Reiner Kontext für die Entscheidung.
|
||||||
|
labels String?
|
||||||
|
|
||||||
playerId Int?
|
playerId Int?
|
||||||
player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull)
|
player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull)
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export interface ForumThread {
|
|||||||
authorForumId: number | null;
|
authorForumId: number | null;
|
||||||
/// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück.
|
/// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück.
|
||||||
isMovedStub: boolean;
|
isMovedStub: boolean;
|
||||||
|
/// Labels des Threads, etwa "Wichtig" oder "SCP-RP".
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs,
|
||||||
|
/// Regelwerke, Bewerbungsphasen. Sie tragen diese Labels und sind keine Anträge.
|
||||||
|
export const IGNORED_LABELS = ['wichtig', 'information'];
|
||||||
|
|
||||||
|
export function hasIgnoredLabel(thread: ForumThread): boolean {
|
||||||
|
return thread.labels.some((label) => IGNORED_LABELS.includes(label.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_SPLIT = /<li class="tabularListRow">/;
|
const ROW_SPLIT = /<li class="tabularListRow">/;
|
||||||
@@ -32,6 +42,9 @@ const USER_LINK =
|
|||||||
// Ein zurückgelassener Verweis ist kein eigener Thread.
|
// Ein zurückgelassener Verweis ist kein eigener Thread.
|
||||||
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
|
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
|
||||||
|
|
||||||
|
// Labels hängen als eigene Links über dem Titel, nicht als span.
|
||||||
|
const LABEL = /<a[^>]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g;
|
||||||
|
|
||||||
function decode(value: string): string {
|
function decode(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
@@ -59,6 +72,9 @@ export function parseBoard(html: string): ForumThread[] {
|
|||||||
|
|
||||||
const user = USER_LINK.exec(block);
|
const user = USER_LINK.exec(block);
|
||||||
|
|
||||||
|
LABEL.lastIndex = 0;
|
||||||
|
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
|
||||||
|
|
||||||
threads.push({
|
threads.push({
|
||||||
threadId,
|
threadId,
|
||||||
title: decode(topic[3] ?? ''),
|
title: decode(topic[3] ?? ''),
|
||||||
@@ -66,6 +82,7 @@ export function parseBoard(html: string): ForumThread[] {
|
|||||||
authorName: user ? decode(user[2] ?? '') || null : null,
|
authorName: user ? decode(user[2] ?? '') || null : null,
|
||||||
authorForumId: user ? Number(user[1]) : null,
|
authorForumId: user ? Number(user[1]) : null,
|
||||||
isMovedStub: MOVED_MARKER.test(block),
|
isMovedStub: MOVED_MARKER.test(block),
|
||||||
|
labels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,3 +94,18 @@ export function parsePageCount(html: string): number {
|
|||||||
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
|
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
|
||||||
return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
|
return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht die Thread-ID aus einer Forums-URL. Angenommen werden der volle Link,
|
||||||
|
* der Pfad oder die blanke Nummer - beim Eintippen von Hand landet mal das
|
||||||
|
* eine, mal das andere im Feld.
|
||||||
|
*/
|
||||||
|
export function parseThreadId(input: string): number | null {
|
||||||
|
const value = input.trim();
|
||||||
|
if (value.length === 0) return null;
|
||||||
|
|
||||||
|
const fromUrl = /thread\/(\d+)/.exec(value);
|
||||||
|
if (fromUrl) return Number(fromUrl[1]);
|
||||||
|
|
||||||
|
return /^\d+$/.test(value) ? Number(value) : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,27 @@ import {
|
|||||||
revertCharacter,
|
revertCharacter,
|
||||||
updateCharacter,
|
updateCharacter,
|
||||||
} from '../services/characters.js';
|
} from '../services/characters.js';
|
||||||
|
import { parseThreadId } from '../lib/forum.js';
|
||||||
|
import { linkThreadToCharacter } from '../services/forum.js';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
/** Hakt den zugehörigen Forums-Thread ab, wenn einer angegeben wurde. */
|
||||||
|
async function linkThread(
|
||||||
|
threadInput: string | undefined,
|
||||||
|
characterId: number,
|
||||||
|
classification: 'NEW_CC' | 'REDESIGN',
|
||||||
|
ctx: ReturnType<typeof contextOf>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!threadInput?.trim()) return;
|
||||||
|
|
||||||
|
const threadId = parseThreadId(threadInput);
|
||||||
|
if (threadId === null) {
|
||||||
|
throw badRequest('INVALID_THREAD', 'Der Thread-Link konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
await linkThreadToCharacter(threadId, characterId, classification, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
||||||
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
||||||
|
|
||||||
@@ -24,10 +43,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
loreText: z.string().trim().max(5000).optional(),
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
|
forumThread: z.string().trim().max(300).optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
const result = await createCharacter(id, body, contextOf(request));
|
const ctx = contextOf(request);
|
||||||
|
const result = await createCharacter(id, body, ctx);
|
||||||
|
await linkThread(body.forumThread, result.character.id, 'NEW_CC', ctx);
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
@@ -52,10 +74,14 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
loreText: z.string().trim().max(5000).optional(),
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
useKind: redesignKind,
|
useKind: redesignKind,
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
|
forumThread: z.string().trim().max(300).optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return redesignCharacter(id, body, contextOf(request));
|
const ctx = contextOf(request);
|
||||||
|
const result = await redesignCharacter(id, body, ctx);
|
||||||
|
await linkThread(body.forumThread, result.character.id, 'REDESIGN', ctx);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
characterName: z.string().trim().min(1).max(160).optional(),
|
characterName: z.string().trim().min(1).max(160).optional(),
|
||||||
loreText: z.string().trim().max(5000).optional(),
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
predecessorId: z.number().int().positive().optional(),
|
predecessorId: z.number().int().positive().optional(),
|
||||||
useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ']).optional(),
|
characterId: z.number().int().positive().optional(),
|
||||||
|
useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']).optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
|
|||||||
+122
-13
@@ -2,7 +2,7 @@ import { ForumBoard, ForumClassification, type Prisma } from '@prisma/client';
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { env } from '../env.js';
|
import { env } from '../env.js';
|
||||||
import { badRequest, conflict, notFound } from '../errors.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';
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben
|
/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben
|
||||||
@@ -61,8 +61,10 @@ export interface ForumScanSummary {
|
|||||||
movedThreads: number;
|
movedThreads: number;
|
||||||
boardChangeNotices: number;
|
boardChangeNotices: number;
|
||||||
skippedStubs: number;
|
skippedStubs: number;
|
||||||
|
skippedLabels: number;
|
||||||
|
/// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon.
|
||||||
|
skippedUnknownAuthor: number;
|
||||||
matchedPlayers: number;
|
matchedPlayers: number;
|
||||||
withoutPlayer: number;
|
|
||||||
failedBoards: { board: string; reason: string }[];
|
failedBoards: { board: string; reason: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +81,9 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
movedThreads: 0,
|
movedThreads: 0,
|
||||||
boardChangeNotices: 0,
|
boardChangeNotices: 0,
|
||||||
skippedStubs: 0,
|
skippedStubs: 0,
|
||||||
|
skippedLabels: 0,
|
||||||
|
skippedUnknownAuthor: 0,
|
||||||
matchedPlayers: 0,
|
matchedPlayers: 0,
|
||||||
withoutPlayer: 0,
|
|
||||||
failedBoards: [],
|
failedBoards: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,6 +124,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Angepinnte Ankündigungen liegen mit in den Boards.
|
||||||
|
if (hasIgnoredLabel(thread)) {
|
||||||
|
summary.skippedLabels += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const playerId = thread.authorName
|
const playerId = thread.authorName
|
||||||
? byForumName.get(thread.authorName.toLowerCase()) ?? null
|
? byForumName.get(thread.authorName.toLowerCase()) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -129,6 +138,15 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
where: { threadId: thread.threadId },
|
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) {
|
if (!existing) {
|
||||||
await prisma.forumThread.create({
|
await prisma.forumThread.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -137,14 +155,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
url: thread.url,
|
url: thread.url,
|
||||||
authorName: thread.authorName,
|
authorName: thread.authorName,
|
||||||
authorForumId: thread.authorForumId,
|
authorForumId: thread.authorForumId,
|
||||||
|
labels: thread.labels.join(', ') || null,
|
||||||
playerId,
|
playerId,
|
||||||
currentBoard: board,
|
currentBoard: board,
|
||||||
history: { create: [{ board }] },
|
history: { create: [{ board }] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
summary.newThreads += 1;
|
summary.newThreads += 1;
|
||||||
if (playerId) summary.matchedPlayers += 1;
|
summary.matchedPlayers += 1;
|
||||||
else summary.withoutPlayer += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +175,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
url: thread.url,
|
url: thread.url,
|
||||||
authorName: thread.authorName,
|
authorName: thread.authorName,
|
||||||
authorForumId: thread.authorForumId,
|
authorForumId: thread.authorForumId,
|
||||||
|
labels: thread.labels.join(', ') || null,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
||||||
...(existing.playerId === null && playerId !== null ? { playerId } : {}),
|
...(existing.playerId === null && playerId !== null ? { playerId } : {}),
|
||||||
@@ -268,7 +287,9 @@ export interface ClassifyInput {
|
|||||||
loreText?: string;
|
loreText?: string;
|
||||||
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
|
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
|
||||||
predecessorId?: number;
|
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;
|
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.NEW_CC ||
|
||||||
input.classification === ForumClassification.REDESIGN
|
input.classification === ForumClassification.REDESIGN
|
||||||
) {
|
) {
|
||||||
@@ -313,14 +360,11 @@ export async function classifyThread(
|
|||||||
if (input.predecessorId === undefined) {
|
if (input.predecessorId === undefined) {
|
||||||
throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde');
|
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(
|
const result = await redesignCharacter(
|
||||||
input.predecessorId,
|
input.predecessorId,
|
||||||
{
|
{ name, loreText: input.loreText, useKind: redesignKind, note },
|
||||||
name,
|
|
||||||
loreText: input.loreText,
|
|
||||||
useKind: input.useKind ?? 'REDESIGN_KULANZ',
|
|
||||||
note,
|
|
||||||
},
|
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
characterId = result.character.id;
|
characterId = result.character.id;
|
||||||
@@ -352,3 +396,68 @@ export async function classifyThread(
|
|||||||
|
|
||||||
return { 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+27
@@ -30,6 +30,33 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<ul class="labelList">
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=1" class="badge label red jsTooltip" title="Label">Wichtig</a></li>
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=2" class="badge label global jsTooltip" title="Label">Global</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7004-regelwerk-changelog/" class="messageGroupLink wbbTopicLink" data-object-id="7004">Regelwerk / Changelog</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<ul class="labelList">
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=3" class="badge label orange jsTooltip" title="Label">Information</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7005-das-grosse-update/" class="messageGroupLink wbbTopicLink" data-object-id="7005">Das grosse Update</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<li class="tabularListRow">
|
<li class="tabularListRow">
|
||||||
<ul class="tabularListColumns">
|
<ul class="tabularListColumns">
|
||||||
<li class="columnIcon columnAvatar">
|
<li class="columnIcon columnAvatar">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import { parseBoard, parsePageCount } from '../src/lib/forum.js';
|
import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
|
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
|
||||||
@@ -12,10 +12,10 @@ describe('Board-Übersicht lesen', () => {
|
|||||||
const threads = parseBoard(BOARD);
|
const threads = parseBoard(BOARD);
|
||||||
|
|
||||||
it('liest die Threads der Liste', () => {
|
it('liest die Threads der Liste', () => {
|
||||||
assert.equal(threads.length, 3);
|
assert.equal(threads.length, 5);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
threads.map((t) => t.threadId),
|
threads.map((t) => t.threadId),
|
||||||
[7001, 7002, 7003],
|
[7001, 7002, 7004, 7005, 7003],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,3 +49,47 @@ describe('Board-Übersicht lesen', () => {
|
|||||||
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
|
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Ankündigungen aussortieren', () => {
|
||||||
|
const threads = parseBoard(BOARD);
|
||||||
|
|
||||||
|
it('liest die Labels eines Threads', () => {
|
||||||
|
const regelwerk = threads.find((t) => t.threadId === 7004);
|
||||||
|
assert.deepEqual(regelwerk?.labels, ['Wichtig', 'Global']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt Wichtig und Information als Ausschluss', () => {
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7004)!), true);
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7005)!), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt echte Anträge durch, auch mit anderen Labels', () => {
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7001)!), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wertet den Titel nicht aus', () => {
|
||||||
|
// "The Duke Rang Update" war ein echter Antrag, "Regelwerk" keiner. Am Titel
|
||||||
|
// ist das nicht zu erkennen - deshalb entscheidet allein das Label.
|
||||||
|
const ohneStichwort = parseBoard(BOARD).find((t) => t.threadId === 7005);
|
||||||
|
assert.ok(ohneStichwort, 'auch ein Thread ohne CC im Titel wird gelesen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Thread-Verweis lesen', () => {
|
||||||
|
it('nimmt den vollen Link', () => {
|
||||||
|
assert.equal(
|
||||||
|
parseThreadId('https://senseigaming.de/forum/index.php?thread/8041-cc-antrag-feldagent/'),
|
||||||
|
8041,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt auch nur den Pfad oder die blanke Nummer', () => {
|
||||||
|
assert.equal(parseThreadId('index.php?thread/7001-irgendwas/'), 7001);
|
||||||
|
assert.equal(parseThreadId('7001'), 7001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unbrauchbares ab', () => {
|
||||||
|
assert.equal(parseThreadId(''), null);
|
||||||
|
assert.equal(parseThreadId('kein link'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const BOARD_LABEL: Record<string, string> = {
|
|||||||
export const CLASSIFICATION_LABEL: Record<string, string> = {
|
export const CLASSIFICATION_LABEL: Record<string, string> = {
|
||||||
NEW_CC: 'Angenommen – neuer CC',
|
NEW_CC: 'Angenommen – neuer CC',
|
||||||
REDESIGN: 'Angenommen – Redesign',
|
REDESIGN: 'Angenommen – Redesign',
|
||||||
|
REACTIVATION: 'Angenommen – Reaktivierung',
|
||||||
REJECTED: 'Abgelehnt',
|
REJECTED: 'Abgelehnt',
|
||||||
IGNORE: 'Ignoriert',
|
IGNORE: 'Ignoriert',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -193,7 +193,12 @@ export interface ImportRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV';
|
export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV';
|
||||||
export type ForumClassification = 'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE';
|
export type ForumClassification =
|
||||||
|
| 'NEW_CC'
|
||||||
|
| 'REDESIGN'
|
||||||
|
| 'REACTIVATION'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'IGNORE';
|
||||||
|
|
||||||
export interface ForumThread {
|
export interface ForumThread {
|
||||||
threadId: number;
|
threadId: number;
|
||||||
@@ -201,6 +206,7 @@ export interface ForumThread {
|
|||||||
url: string;
|
url: string;
|
||||||
authorName: string | null;
|
authorName: string | null;
|
||||||
authorForumId: number | null;
|
authorForumId: number | null;
|
||||||
|
labels: string | null;
|
||||||
playerId: number | null;
|
playerId: number | null;
|
||||||
player: { id: number; displayName: string } | null;
|
player: { id: number; displayName: string } | null;
|
||||||
currentBoard: ForumBoard;
|
currentBoard: ForumBoard;
|
||||||
@@ -221,7 +227,8 @@ export interface ForumScanSummary {
|
|||||||
movedThreads: number;
|
movedThreads: number;
|
||||||
boardChangeNotices: number;
|
boardChangeNotices: number;
|
||||||
skippedStubs: number;
|
skippedStubs: number;
|
||||||
|
skippedLabels: number;
|
||||||
|
skippedUnknownAuthor: number;
|
||||||
matchedPlayers: number;
|
matchedPlayers: number;
|
||||||
withoutPlayer: number;
|
|
||||||
failedBoards: { board: string; reason: string }[];
|
failedBoards: { board: string; reason: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-12
@@ -7,6 +7,7 @@ import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
|||||||
import { useSession } from '../lib/session';
|
import { useSession } from '../lib/session';
|
||||||
import type {
|
import type {
|
||||||
Character,
|
Character,
|
||||||
|
ForumClassification,
|
||||||
ForumScanSummary,
|
ForumScanSummary,
|
||||||
ForumThread,
|
ForumThread,
|
||||||
PlayerDetail,
|
PlayerDetail,
|
||||||
@@ -51,8 +52,11 @@ export default function Forum() {
|
|||||||
const parts = [`${result.scannedPages} Seiten gelesen`];
|
const parts = [`${result.scannedPages} Seiten gelesen`];
|
||||||
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
|
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
|
||||||
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
|
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
|
||||||
if (result.withoutPlayer > 0) parts.push(`${result.withoutPlayer} ohne Spieler`);
|
if (result.skippedUnknownAuthor > 0) {
|
||||||
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise übersprungen`);
|
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`);
|
if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
|
||||||
setFlash(parts.join(', ') + '.');
|
setFlash(parts.join(', ') + '.');
|
||||||
await load();
|
await load();
|
||||||
@@ -80,7 +84,8 @@ export default function Forum() {
|
|||||||
<h1>Forums-Anträge</h1>
|
<h1>Forums-Anträge</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
|
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
|
||||||
Signal und lösen nie eine Buchung aus.
|
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
|
||||||
|
die hier mit passendem Forum-Namen erfasst sind.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{admin && (
|
{admin && (
|
||||||
@@ -105,7 +110,9 @@ export default function Forum() {
|
|||||||
{notices.map((thread) => (
|
{notices.map((thread) => (
|
||||||
<li key={thread.threadId} className="cc-item">
|
<li key={thread.threadId} className="cc-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{thread.title}</strong>
|
<a href={thread.url} target="_blank" rel="noreferrer" className="link-strong">
|
||||||
|
{thread.title}
|
||||||
|
</a>
|
||||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
|
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
|
||||||
@@ -141,8 +148,17 @@ export default function Forum() {
|
|||||||
{threads.map((thread) => (
|
{threads.map((thread) => (
|
||||||
<li key={thread.threadId} className="cc-item">
|
<li key={thread.threadId} className="cc-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{thread.title}</strong>
|
<a
|
||||||
|
href={thread.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="link-strong"
|
||||||
|
title="Im Forum öffnen"
|
||||||
|
>
|
||||||
|
{thread.title}
|
||||||
|
</a>
|
||||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||||
|
{thread.labels && <span className="muted small"> · {thread.labels}</span>}
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
von {thread.authorName ?? 'unbekannt'} ·{' '}
|
von {thread.authorName ?? 'unbekannt'} ·{' '}
|
||||||
{thread.player ? (
|
{thread.player ? (
|
||||||
@@ -154,9 +170,6 @@ export default function Forum() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="cc-actions">
|
<div className="cc-actions">
|
||||||
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
|
||||||
Thread
|
|
||||||
</a>
|
|
||||||
{admin && !thread.player && (
|
{admin && !thread.player && (
|
||||||
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
|
||||||
Spieler zuordnen
|
Spieler zuordnen
|
||||||
@@ -218,11 +231,13 @@ function ClassifyModal({
|
|||||||
onDone: (message: string) => void;
|
onDone: (message: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [choice, setChoice] = useState<'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'>('NEW_CC');
|
const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
|
||||||
const [name, setName] = useState(thread.title);
|
const [name, setName] = useState(thread.title);
|
||||||
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
||||||
|
const [reviveId, setReviveId] = useState<number | null>(null);
|
||||||
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
|
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
|
||||||
const [characters, setCharacters] = useState<Character[]>([]);
|
const [characters, setCharacters] = useState<Character[]>([]);
|
||||||
|
const [inactive, setInactive] = useState<Character[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
|
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
|
||||||
@@ -232,13 +247,18 @@ function ClassifyModal({
|
|||||||
try {
|
try {
|
||||||
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
|
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
|
||||||
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
|
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
|
||||||
|
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
|
||||||
} catch {
|
} catch {
|
||||||
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
|
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [thread.playerId]);
|
}, [thread.playerId]);
|
||||||
|
|
||||||
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN';
|
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
|
||||||
|
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
|
||||||
|
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
|
||||||
|
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
|
||||||
|
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
const submit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -248,8 +268,11 @@ function ClassifyModal({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
classification: choice,
|
classification: choice,
|
||||||
...(needsPlayer ? { characterName: name.trim() } : {}),
|
...(needsName ? { characterName: name.trim() } : {}),
|
||||||
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
|
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
|
||||||
|
...(choice === 'REACTIVATION'
|
||||||
|
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
|
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
|
||||||
@@ -275,6 +298,7 @@ function ClassifyModal({
|
|||||||
[
|
[
|
||||||
['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'],
|
['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'],
|
||||||
['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'],
|
['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'],
|
||||||
|
['REACTIVATION', 'Angenommen – Reaktivierung', 'ein inaktiver CC kehrt zurück'],
|
||||||
['REJECTED', 'Abgelehnt', 'keine Buchung'],
|
['REJECTED', 'Abgelehnt', 'keine Buchung'],
|
||||||
['IGNORE', 'Kein Antrag', 'keine Buchung'],
|
['IGNORE', 'Kein Antrag', 'keine Buchung'],
|
||||||
] as const
|
] as const
|
||||||
@@ -300,7 +324,7 @@ function ClassifyModal({
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{needsPlayer && thread.playerId && (
|
{needsName && thread.playerId && (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="cc-name">Name des CCs</label>
|
<label htmlFor="cc-name">Name des CCs</label>
|
||||||
<input
|
<input
|
||||||
@@ -313,6 +337,65 @@ function ClassifyModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{choice === 'REACTIVATION' && thread.playerId && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
|
||||||
|
<select
|
||||||
|
id="revive"
|
||||||
|
value={reviveId ?? ''}
|
||||||
|
onChange={(event) => setReviveId(Number(event.target.value) || null)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">— bitte wählen —</option>
|
||||||
|
{inactive.map((character) => (
|
||||||
|
<option key={character.id} value={character.id}>
|
||||||
|
{character.name}
|
||||||
|
{character.inactiveReason === 'REWORKED' ? ' (redesignt — kostet)' : ' (gratis)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{inactive.length === 0 && (
|
||||||
|
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gewaehlterCC && !reaktivierungKostet && (
|
||||||
|
<Notice kind="success">
|
||||||
|
Kommt gratis zurück — der CC existiert unverändert weiter.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reaktivierungKostet && (
|
||||||
|
<>
|
||||||
|
<Notice kind="warn">
|
||||||
|
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
|
||||||
|
kostet ein Guthaben.
|
||||||
|
</Notice>
|
||||||
|
<label>Womit bezahlen?</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reviveKind"
|
||||||
|
checked={useKind === 'REDESIGN_KULANZ'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||||
|
/>
|
||||||
|
<span>Kulanz-Redesign</span>
|
||||||
|
</label>
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reviveKind"
|
||||||
|
checked={useKind === 'REDESIGN_PAID'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||||
|
/>
|
||||||
|
<span>Bezahltes Redesign</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{choice === 'REDESIGN' && thread.playerId && (
|
{choice === 'REDESIGN' && thread.playerId && (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
|
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
|
||||||
|
|||||||
+115
-23
@@ -24,7 +24,8 @@ type DialogState =
|
|||||||
| { type: 'revert'; character: Character }
|
| { type: 'revert'; character: Character }
|
||||||
| { type: 'reverse'; entry: LedgerEntry }
|
| { type: 'reverse'; entry: LedgerEntry }
|
||||||
| { type: 'editPlayer' }
|
| { type: 'editPlayer' }
|
||||||
| { type: 'deletePlayer' };
|
| { type: 'deletePlayer' }
|
||||||
|
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' };
|
||||||
|
|
||||||
export default function PlayerDetail() {
|
export default function PlayerDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -179,12 +180,7 @@ export default function PlayerDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
|
||||||
void run(
|
|
||||||
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
|
|
||||||
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ CC-Paket
|
+ CC-Paket
|
||||||
</button>
|
</button>
|
||||||
@@ -192,16 +188,7 @@ export default function PlayerDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() => setDialog({ type: 'addCredit', kind: 'redesign-package' })}
|
||||||
void run(
|
|
||||||
() =>
|
|
||||||
api(`/api/players/${playerId}/credits/redesign-package`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
'Redesign-Paket eingetragen: +1 Redesign.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ Redesign-Paket
|
+ Redesign-Paket
|
||||||
</button>
|
</button>
|
||||||
@@ -209,12 +196,7 @@ export default function PlayerDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
|
||||||
void run(
|
|
||||||
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
|
|
||||||
'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ Kulanz
|
+ Kulanz
|
||||||
</button>
|
</button>
|
||||||
@@ -523,6 +505,25 @@ export default function PlayerDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'addCredit' && (
|
||||||
|
<AddCreditModal
|
||||||
|
kind={dialog.kind}
|
||||||
|
playerName={detail.player.displayName}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onConfirm={(note) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/players/${playerId}/credits/${dialog.kind}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: note.trim() ? { note: note.trim() } : {},
|
||||||
|
}),
|
||||||
|
CREDIT_ACTIONS[dialog.kind].flash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{dialog.type === 'editPlayer' && (
|
{dialog.type === 'editPlayer' && (
|
||||||
<EditPlayerModal
|
<EditPlayerModal
|
||||||
player={detail.player}
|
player={detail.player}
|
||||||
@@ -580,10 +581,87 @@ export default function PlayerDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CREDIT_ACTIONS = {
|
||||||
|
'cc-package': {
|
||||||
|
titel: 'CC-Paket eintragen',
|
||||||
|
wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.',
|
||||||
|
hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.',
|
||||||
|
flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||||
|
},
|
||||||
|
'redesign-package': {
|
||||||
|
titel: 'Redesign-Paket eintragen',
|
||||||
|
wirkung: 'Schreibt +1 bezahltes Redesign-Guthaben gut.',
|
||||||
|
hinweis: 'Für einzeln gekaufte Redesigns, ohne CC-Guthaben.',
|
||||||
|
flash: 'Redesign-Paket eingetragen: +1 Redesign.',
|
||||||
|
},
|
||||||
|
kulanz: {
|
||||||
|
titel: 'Kulanz-Redesign eintragen',
|
||||||
|
wirkung: 'Schreibt +1 Kulanz-Redesign gut.',
|
||||||
|
hinweis: 'Für vom Team gewährte Redesigns, die nicht bezahlt wurden.',
|
||||||
|
flash: 'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function AddCreditModal({
|
||||||
|
kind,
|
||||||
|
playerName,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
kind: keyof typeof CREDIT_ACTIONS;
|
||||||
|
playerName: string;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (note: string) => void;
|
||||||
|
}) {
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const aktion = CREDIT_ACTIONS[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={aktion.titel} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onConfirm(note);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">
|
||||||
|
Für <strong>{playerName}</strong>: {aktion.wirkung}
|
||||||
|
<br />
|
||||||
|
<span className="muted">{aktion.hinweis}</span>
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="credit-note">Notiz (optional)</label>
|
||||||
|
<input
|
||||||
|
id="credit-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
placeholder="z. B. Bestellnummer oder Grund der Kulanz"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Eine Buchung lässt sich später stornieren, verschwindet aber nie aus der Historie.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Buche …' : 'Eintragen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CharacterValues {
|
interface CharacterValues {
|
||||||
name: string;
|
name: string;
|
||||||
loreText?: string;
|
loreText?: string;
|
||||||
useKind?: CreditKind;
|
useKind?: CreditKind;
|
||||||
|
forumThread?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CharacterFormModal({
|
function CharacterFormModal({
|
||||||
@@ -607,6 +685,7 @@ function CharacterFormModal({
|
|||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [loreText, setLoreText] = useState('');
|
const [loreText, setLoreText] = useState('');
|
||||||
|
const [forumThread, setForumThread] = useState('');
|
||||||
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
|
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
|
||||||
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
|
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
|
||||||
|
|
||||||
@@ -618,6 +697,7 @@ function CharacterFormModal({
|
|||||||
onSubmit({
|
onSubmit({
|
||||||
name,
|
name,
|
||||||
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
||||||
|
...(forumThread.trim() ? { forumThread: forumThread.trim() } : {}),
|
||||||
...(withKind ? { useKind } : {}),
|
...(withKind ? { useKind } : {}),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -641,6 +721,18 @@ function CharacterFormModal({
|
|||||||
onChange={(event) => setLoreText(event.target.value)}
|
onChange={(event) => setLoreText(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="cc-thread">Forums-Thread (optional)</label>
|
||||||
|
<input
|
||||||
|
id="cc-thread"
|
||||||
|
value={forumThread}
|
||||||
|
onChange={(event) => setForumThread(event.target.value)}
|
||||||
|
placeholder="Link zum Antrag oder nur die Thread-Nummer"
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Trag den Antrag hier ein, dann taucht er beim nächsten Forums-Abgleich nicht erneut
|
||||||
|
als offen auf.
|
||||||
|
</p>
|
||||||
|
|
||||||
{withKind && balances && (
|
{withKind && balances && (
|
||||||
<>
|
<>
|
||||||
<label>Welches Guthaben soll verbraucht werden?</label>
|
<label>Welches Guthaben soll verbraucht werden?</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user