Files
CC-Manager/server/src/routes/characters.routes.ts
T
Harlekin 34819d44cc Rueckfragen im Stil der Anwendung, Kulanz-Ausgleich, Redesign aus dem CC-Topf
window.confirm und window.prompt sind raus - alle vierzehn Stellen. Sie sahen
nicht nur fremd aus: Wer sie im Browser abschaltet, bekommt stillschweigend
"nein" zurueck, und eine Aktion passiert einfach nicht, ohne dass jemand
erfaehrt warum. Stattdessen gibt es einen Dialog-Dienst mit drei Formen -
bestaetigen, abfragen, waehlen -, der sich wie der Rest der Anwendung
anfuehlt.

Die Wahl kam dazu, weil eine Stelle ein Ja/Nein fuer eine Auswahl
missbrauchte: "OK = Kulanz, Abbrechen = bezahltes Redesign". Das fuehrt jeden
in die Irre, der es zum ersten Mal sieht.

Neu: Steht der bezahlte Redesign-Topf im Minus, waehrend Kulanz uebrig ist,
erscheint "Minus mit Kulanz ausgleichen". Es bucht so viele Uebergaenge um,
wie beide Seiten hergeben - die aeltesten zuerst, damit das Ergebnis
wiederholbar ist. Unterm Strich aendert sich nichts, nur die Verteilung.

Ebenfalls neu: Ein Redesign laesst sich ausnahmsweise aus dem CC-Topf
bezahlen. Damit das Ausrichten an den Ketten so eine Entscheidung nicht beim
naechsten Umsortieren wieder einebnet, tragen von Hand gewaehlte Buchungen
jetzt einen Merker.

Dabei aufgefallen: Die Tests wurden gar nicht typgeprueft - tsconfig schloss
nur src ein. Sieben Aufrufe von bookKulanz hatten vertauschte Argumente und
liefen trotzdem durch, weil JavaScript das hinnimmt; sie pruefte damit etwas
anderes als gedacht. Tests haengen jetzt in der Typpruefung.
2026-09-22 09:39:57 +02:00

457 lines
15 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, InactiveReason } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { forbidden } from '../errors.js';
import { prisma } from '../db.js';
import {
addMember,
createCharacter,
deactivateCharacter,
listMembers,
removeMember,
reviveDeadCharacter,
reactivateFree,
reactivatePaid,
redesignCharacter,
revertCharacter,
updateCharacter,
deactivateMember,
reactivateMember,
redesignMembership,
addLink,
updateLink,
removeLink,
moveInChain,
detachFromChain,
changeFunding,
addMemberToChain,
rebuildFunding,
settleWithKulanz,
} 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';
/** 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);
}
/// Der Regelfall für ein Redesign sind die Redesign-Töpfe. Ein CC-Paket
/// geht ausnahmsweise auch - dann gilt die Wahl als von Hand getroffen.
const redesignKind = z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]);
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
export async function characterRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/players/:id/characters', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
request.body,
);
const ctx = contextOf(request);
const result = await createCharacter(id, body, ctx);
await linkThread(body.forumThread, result.character.id, 'NEW_CC', ctx);
reply.code(201);
return result;
});
app.patch('/api/characters/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160).optional(),
loreText: z.string().trim().max(5000).nullable().optional(),
/// Thread-Nummer oder volle Adresse; leer löst die Verknüpfung.
threadRef: z.string().trim().max(500).nullable().optional(),
}),
request.body,
);
return updateCharacter(id, body, contextOf(request));
});
app.post('/api/characters/:id/redesign', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
useKind: redesignKind,
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
request.body,
);
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) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
reason: z.enum([
InactiveReason.PLAYER_INACTIVITY,
InactiveReason.OTHER,
InactiveReason.NO_FREE_CLAIM,
InactiveReason.DEAD,
]),
note: optionalNote,
}),
request.body,
);
return deactivateCharacter(id, body.reason, contextOf(request), body.note);
});
app.post('/api/characters/:id/reactivate-free', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return reactivateFree(id, contextOf(request));
});
app.post('/api/characters/:id/reactivate-paid', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ useKind: reviveKind, note: optionalNote }),
request.body,
);
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
});
/**
* Rückkehr eines im Rollenspiel gestorbenen CCs. Ausnahmeentscheidung,
* deshalb Administratoren vorbehalten und nur mit Begründung.
*/
app.post('/api/characters/:id/revive-dead', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
useKind: reviveKind.optional(),
}),
request.body,
);
return reviveDeadCharacter(id, body, contextOf(request));
});
// --- Verweise ---
app.post('/api/characters/:id/links', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
label: z.string().trim().min(1).max(120),
url: z.string().trim().min(1).max(2000),
}),
request.body,
);
reply.code(201);
return addLink(id, body, contextOf(request));
});
app.patch('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
label: z.string().trim().min(1).max(120).optional(),
url: z.string().trim().min(1).max(2000).optional(),
}),
request.body,
);
return updateLink(id, body, contextOf(request));
});
app.delete('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await removeLink(id, contextOf(request));
return { ok: true };
});
/**
* Nachträglich umstellen, woraus ein CC bezahlt wurde - aus einem Paket,
* einem bezahlten Redesign oder aus Kulanz. Umgebucht wird im Kontobuch,
* nicht überschrieben.
*/
app.post('/api/characters/:id/funding', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
}),
request.body,
);
return changeFunding(id, body.useKind, contextOf(request));
});
/**
* Rechnet die Buchungen eines Spielers anhand seiner Ketten nach: jede
* Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns.
*/
app.post('/api/players/:id/credits/rebuild', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return rebuildFunding(id, contextOf(request));
});
/**
* Gleicht ein Minus im bezahlten Redesign-Topf mit vorhandener Kulanz aus,
* indem ebenso viele Übergänge auf Kulanz umgebucht werden.
*/
app.post('/api/players/:id/credits/settle', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return settleWithKulanz(id, contextOf(request));
});
// --- Ketten ---
/**
* Schiebt einen CC in eine Kette, hinter ein bestimmtes Glied oder an den
* Anfang. Für das Nachtragen von Lücken, die sich erst später auftun.
*/
app.post('/api/characters/:id/move', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
lineageId: z.string().trim().min(1).max(64),
afterId: z.number().int().positive().nullable().optional(),
/// Wessen Seite gerade geordnet wird - bei einem Gruppen-CC ist das
/// nicht zwingend sein Ersteller.
forPlayerId: z.number().int().positive().optional(),
}),
request.body,
);
return moveInChain(id, body, contextOf(request));
});
/** Löst einen CC aus seiner Kette und stellt ihn allein. */
app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return detachFromChain(id, contextOf(request));
});
// --- Gruppen-CCs ---
app.get('/api/characters/:id/members', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
return { items: await listMembers(id) };
});
app.post('/api/characters/:id/members', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
playerId: z.number().int().positive(),
/// Fehlt nur bei einer Nacherfassung an einem inaktiven CC.
useKind: z
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
.optional(),
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
/// Platz über ein Redesign bezahlt wird.
predecessorId: z.number().int().positive().optional(),
/// Zeitraum beim Nachtragen.
joinedAt: z.coerce.date().optional(),
leftAt: z.coerce.date().optional(),
note: optionalNote,
}),
request.body,
);
return addMember(
id,
body.playerId,
{
useKind: body.useKind,
predecessorId: body.predecessorId,
joinedAt: body.joinedAt,
leftAt: body.leftAt,
note: body.note,
},
contextOf(request),
);
});
/**
* Der Anteil eines Spielers an einem Gruppen-CC wird redesignt: er steigt
* aus, bekommt einen eigenen CC, die Gruppe läuft weiter.
*/
app.post(
'/api/characters/:id/members/:playerId/redesign',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
/// Der Regelfall sind die Redesign-Töpfe; ein CC-Paket geht
/// ausnahmsweise auch.
useKind: z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]),
note: optionalNote,
}),
request.body,
);
return redesignMembership(params.id, params.playerId, body, contextOf(request));
},
);
/** Ein einzelnes Mitglied fällt aus, der CC bleibt bestehen. */
app.post(
'/api/characters/:id/members/:playerId/deactivate',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
reason: z.enum([
InactiveReason.PLAYER_INACTIVITY,
InactiveReason.OTHER,
InactiveReason.NO_FREE_CLAIM,
InactiveReason.DEAD,
]),
note: optionalNote,
}),
request.body,
);
// Ein Tod im Rollenspiel ist eine Ausnahmeentscheidung, genau wie beim
// ganzen CC - und bleibt deshalb Administratoren vorbehalten.
if (body.reason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
throw forbidden('Einen Tod im Rollenspiel trägt nur ein Administrator ein');
}
return deactivateMember(
params.id,
params.playerId,
body.reason,
contextOf(request),
body.note,
);
},
);
/** Rückkehr eines ausgefallenen Mitglieds. */
app.post(
'/api/characters/:id/members/:playerId/reactivate',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
useKind: reviveKind.optional(),
reason: z.string().trim().max(500).optional(),
}),
request.body ?? {},
);
// Wer im Rollenspiel gestorben ist, kehrt nur durch einen Administrator
// zurück. Geprüft wird am tatsächlichen Grund, nicht an der Eingabe.
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId: params.id, playerId: params.playerId } },
select: { inactiveReason: true },
});
if (mitglied?.inactiveReason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
throw forbidden('Über die Rückkehr eines Gestorbenen entscheidet ein Administrator');
}
return reactivateMember(params.id, params.playerId, body, contextOf(request));
},
);
/**
* Trägt einen Spieler ab diesem Glied in die ganze weitere Kette ein -
* für die Nacherfassung, wenn jemand schon vor mehreren Redesigns dabei
* war. Gebucht wird einmal, beim Einstieg.
*/
app.post('/api/characters/:id/members/chain', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
playerId: z.number().int().positive(),
useKind: z
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
.optional(),
predecessorId: z.number().int().positive().optional(),
joinedAt: z.coerce.date().optional(),
note: optionalNote,
}),
request.body,
);
return addMemberToChain(
id,
body.playerId,
{
useKind: body.useKind,
predecessorId: body.predecessorId,
joinedAt: body.joinedAt,
note: body.note,
},
contextOf(request),
);
});
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(z.object({ note: optionalNote }), request.body ?? {});
return removeMember(params.id, params.playerId, body.note, contextOf(request));
});
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
request.body,
);
return revertCharacter(id, body.reason, contextOf(request));
});
}