Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
295 lines
10 KiB
TypeScript
295 lines
10 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,
|
|
} 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(),
|
|
}),
|
|
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));
|
|
});
|
|
|
|
// --- 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));
|
|
});
|
|
}
|