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, ): Promise { 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 { 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)); }); }