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.
457 lines
15 KiB
TypeScript
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));
|
|
});
|
|
}
|