Files
CC-Manager/server/src/routes/characters.routes.ts
T
Harlekin 2527f6a240 Erstzugang schuetzen, Kettenstatus richtigstellen, CCs nachtraeglich aendern
Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch
Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und
loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein
Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes
Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true
hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den
Namen kennt nur er, die Migration nicht.

Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt",
obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger,
der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger
hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht
dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch.

Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines
CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt -
sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst
eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign
bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben
wird nichts.

Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite
offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den
Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht,
ohne im Hintergrund dauernd zu fragen.
2026-09-21 14:36:26 +02:00

375 lines
13 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,
changeRedesignKind,
} 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);
}
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
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 Redesign bezahlt wurde. Umgebucht
* wird im Kontobuch, nicht überschrieben.
*/
app.post('/api/characters/:id/redesign-kind', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
}),
request.body,
);
return changeRedesignKind(id, body.useKind, 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(),
}),
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(),
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
/// Platz über ein Redesign bezahlt wird.
predecessorId: z.number().int().positive().optional(),
note: optionalNote,
}),
request.body,
);
return addMember(
id,
body.playerId,
{ useKind: body.useKind, predecessorId: body.predecessorId, 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(),
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
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));
},
);
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));
});
}