HTTP-Endpunkte und Anwendungsaufbau

Eingaben werden mit zod validiert, jede aendernde Aktion schreibt ins
Audit-Log. Beim ersten Start entsteht ein Administrator samt Startwerten fuer
die Produkterkennung; solange ein Erstpasswort gilt, bleiben schreibende Wege
bis auf den Passwortwechsel gesperrt.

Nicht-API-Pfade fallen auf die SPA zurueck, damit Deep-Links funktionieren.
This commit is contained in:
2026-09-18 11:37:31 +02:00
parent f3e31cdd17
commit a7228e63d1
10 changed files with 1209 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ProductTarget, Role, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { requireAdmin } from '../auth/guard.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
import { destroyAllSessionsFor } from '../auth/session.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const userSelect = {
id: true,
username: true,
role: true,
isActive: true,
totpEnabled: true,
mustChangePassword: true,
lastLoginAt: true,
createdAt: true,
} satisfies Prisma.UserSelect;
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
async function assertNotLastAdmin(userId: number): Promise<void> {
const target = await prisma.user.findUnique({ where: { id: userId } });
if (!target || target.role !== Role.ADMIN || !target.isActive) return;
const activeAdmins = await prisma.user.count({ where: { role: Role.ADMIN, isActive: true } });
if (activeAdmins <= 1) {
throw conflict('LAST_ADMIN', 'Der letzte aktive Administrator kann nicht entfernt werden');
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', requireAdmin);
// --- Nutzerverwaltung ---
app.get('/api/admin/users', async () => {
const items = await prisma.user.findMany({
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
select: userSelect,
});
return { items };
});
app.post('/api/admin/users', async (request, reply) => {
const body = parseWith(
z.object({
username: z
.string()
.trim()
.min(3)
.max(64)
.regex(/^[a-zA-Z0-9._-]+$/, 'Erlaubt sind Buchstaben, Ziffern, Punkt, Strich, Unterstrich'),
password: z.string().min(1),
role: z.nativeEnum(Role).default(Role.EDITOR),
}),
request.body,
);
const weak = validatePasswordStrength(body.password);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const exists = await prisma.user.findUnique({
where: { username: body.username },
select: { id: true },
});
if (exists) throw conflict('USERNAME_TAKEN', 'Dieser Benutzername ist vergeben');
const created = await prisma.user.create({
data: {
username: body.username,
passwordHash: await hashPassword(body.password),
role: body.role,
mustChangePassword: true,
},
select: userSelect,
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_create',
targetType: 'user',
targetId: created.id,
details: { username: created.username, role: created.role },
});
reply.code(201);
return created;
});
app.patch('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
role: z.nativeEnum(Role).optional(),
isActive: z.boolean().optional(),
}),
request.body,
);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
const losesAdmin =
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
if (losesAdmin) await assertNotLastAdmin(id);
const updated = await prisma.user.update({
where: { id },
data: {
...(body.role !== undefined ? { role: body.role } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
},
select: userSelect,
});
// Ein gesperrter Account verliert seine offenen Sitzungen sofort.
if (body.isActive === false) await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_update',
targetType: 'user',
targetId: id,
details: { username: updated.username, role: updated.role, isActive: updated.isActive },
});
return updated;
});
app.post('/api/admin/users/:id/reset-password', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(z.object({ newPassword: z.string().min(1) }), request.body);
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
await prisma.user.update({
where: { id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: true,
},
});
await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_reset_password',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
app.delete('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
if (id === request.user?.id) {
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
await assertNotLastAdmin(id);
await prisma.user.delete({ where: { id } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_delete',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
// --- Produkt-Mappings ---
app.get('/api/admin/product-mappings', async () => {
const items = await prisma.productMapping.findMany({
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
});
return { items };
});
app.post('/api/admin/product-mappings', async (request, reply) => {
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200),
target: z.nativeEnum(ProductTarget),
priority: z.number().int().min(0).max(9999).default(100),
note: z.string().trim().max(300).optional(),
}),
request.body,
);
const created = await prisma.productMapping.create({
data: {
matchPattern: body.matchPattern,
target: body.target,
priority: body.priority,
note: body.note ?? null,
},
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_create',
targetType: 'product_mapping',
targetId: created.id,
details: { matchPattern: created.matchPattern, target: created.target },
});
reply.code(201);
return created;
});
app.patch('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200).optional(),
target: z.nativeEnum(ProductTarget).optional(),
priority: z.number().int().min(0).max(9999).optional(),
isActive: z.boolean().optional(),
note: z.string().trim().max(300).nullable().optional(),
}),
request.body,
);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
const updated = await prisma.productMapping.update({ where: { id }, data: body });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_update',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: updated.matchPattern, target: updated.target },
});
return updated;
});
app.delete('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
await prisma.productMapping.delete({ where: { id } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_delete',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: existing.matchPattern },
});
return { ok: true };
});
// --- Audit-Log ---
app.get('/api/admin/audit', async (request) => {
const query = parseWith(
z.object({
action: z.string().trim().max(80).optional(),
targetType: z.string().trim().max(40).optional(),
targetId: z.string().trim().max(40).optional(),
take: z.coerce.number().int().min(1).max(200).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
const where: Prisma.AuditLogWhereInput = {};
if (query.action) where.action = { contains: query.action, mode: 'insensitive' };
if (query.targetType) where.targetType = query.targetType;
if (query.targetId) where.targetId = query.targetId;
const [items, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: query.take,
skip: query.skip,
include: { actor: { select: { username: true } } },
}),
prisma.auditLog.count({ where }),
]);
return { items, total };
});
app.get('/api/admin/stats', async () => {
const [players, characters, activeCharacters, users, imports] = await Promise.all([
prisma.player.count(),
prisma.character.count(),
prisma.character.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { isActive: true } }),
prisma.import.count({ where: { status: 'COMMITTED' } }),
]);
return { players, characters, activeCharacters, users, imports };
});
}
+304
View File
@@ -0,0 +1,304 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest, unauthorized } from '../errors.js';
import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js';
import {
CSRF_COOKIE,
SESSION_COOKIE,
createSession,
csrfTokenFor,
destroyAllSessionsFor,
destroyOtherSessionsFor,
destroySession,
} from '../auth/session.js';
import { requireAuth } from '../auth/guard.js';
import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, parseWith } from './helpers.js';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert,
// damit kein halb angemeldeter Zustand in der Datenbank liegt.
function signChallenge(userId: number): string {
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
const payload = `${userId}.${expiresAt}`;
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
return `${payload}.${signature}`;
}
function verifyChallenge(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [rawUserId, rawExpiry, signature] = parts as [string, string, string];
const expected = createHmac('sha256', env.SESSION_SECRET)
.update(`${rawUserId}.${rawExpiry}`)
.digest('base64url');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
const expiresAt = Number(rawExpiry);
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
const userId = Number(rawUserId);
return Number.isInteger(userId) ? userId : null;
}
const loginSchema = z.object({
username: z.string().trim().min(1).max(64),
password: z.string().min(1).max(256),
});
const totpSchema = z.object({
challenge: z.string().min(1),
code: z.string().trim().min(6).max(10),
});
export async function authRoutes(app: FastifyInstance): Promise<void> {
const loginLimit = {
config: {
rateLimit: {
max: 10,
timeWindow: '5 minutes',
},
},
};
function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void {
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
// Muss fuer das Double-Submit-Verfahren lesbar sein.
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
path: '/',
httpOnly: false,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
}
app.post('/api/auth/login', loginLimit, async (request, reply) => {
const body = parseWith(loginSchema, request.body);
const user = await prisma.user.findUnique({ where: { username: body.username } });
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const passwordOk = await verifyPassword(digest, body.password);
if (!user || !passwordOk || !user.isActive) {
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
action: 'auth.login_failed',
targetType: 'user',
targetId: user?.id ?? null,
}).catch(() => undefined);
throw unauthorized('Benutzername oder Passwort ist falsch');
}
if (user.totpEnabled && user.totpSecret) {
return { totpRequired: true, challenge: signChallenge(user.id) };
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
totpRequired: false,
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/login/totp', loginLimit, async (request, reply) => {
const body = parseWith(totpSchema, request.body);
const userId = verifyChallenge(body.challenge);
if (userId === null) {
throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten');
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.isActive || !user.totpSecret) throw unauthorized();
if (!(await verifyTotp(user.totpSecret, body.code))) {
await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, {
action: 'auth.totp_failed',
targetType: 'user',
targetId: user.id,
}).catch(() => undefined);
throw unauthorized('Der Code stimmt nicht');
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/logout', async (request, reply) => {
if (request.sessionToken) {
await destroySession(request.sessionToken);
}
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
app.get('/api/auth/me', async (request) => {
if (!request.user) return { user: null };
return { user: request.user };
});
// --- Eigener Account ---
app.post(
'/api/account/password',
{ preHandler: requireAuth },
async (request) => {
const body = parseWith(
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(1),
}),
request.body,
);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.currentPassword))) {
throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch');
}
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: false,
},
});
// Andere Sitzungen beenden, die eigene bleibt bestehen.
if (request.sessionToken) {
await destroyOtherSessionsFor(user.id, request.sessionToken);
}
await recordAudit(prisma, contextOf(request), {
action: 'account.password_changed',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
},
);
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
const secret = generateSecret();
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
await prisma.user.update({
where: { id: request.user!.id },
data: { totpSecret: secret, totpEnabled: false },
});
const url = otpauthUrl(request.user!.username, secret);
return { secret, otpauth: url, qr: await qrDataUrl(url) };
});
app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user?.totpSecret) {
throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten');
}
if (!(await verifyTotp(user.totpSecret, body.code))) {
throw badRequest('WRONG_CODE', 'Der Code stimmt nicht');
}
await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } });
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_enabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ password: z.string().min(1) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.password))) {
throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch');
}
await prisma.user.update({
where: { id: user.id },
data: { totpEnabled: false, totpSecret: null },
});
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_disabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => {
await destroyAllSessionsFor(request.user!.id);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
}
+95
View File
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, InactiveReason } from '@prisma/client';
import { requireEditor } from '../auth/guard.js';
import {
createCharacter,
deactivateCharacter,
reactivateFree,
reactivatePaid,
redesignCharacter,
revertCharacter,
updateCharacter,
} from '../services/characters.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
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,
}),
request.body,
);
const result = await createCharacter(id, body, contextOf(request));
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,
}),
request.body,
);
return redesignCharacter(id, body, contextOf(request));
});
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]),
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);
});
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 Begruendung ist Pflicht').max(500) }),
request.body,
);
return revertCharacter(id, body.reason, contextOf(request));
});
}
+60
View File
@@ -0,0 +1,60 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import {
bookAdjustment,
bookCcPackage,
bookKulanz,
bookRedesignPackage,
reverseGroup,
} from '../services/credits.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const noteBody = z.object({ note: optionalNote });
export async function creditRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookCcPackage(id, contextOf(request), { note: body.note });
});
app.post(
'/api/players/:id/credits/redesign-package',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookRedesignPackage(id, contextOf(request), { note: body.note });
},
);
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookKulanz(id, contextOf(request), { note: body.note });
});
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
kind: z.nativeEnum(CreditKind),
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500),
}),
request.body,
);
return bookAdjustment(id, body.kind, body.amount, body.note, contextOf(request));
});
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
request.body,
);
return reverseGroup(id, body.reason, contextOf(request));
});
}
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyRequest } from 'fastify';
import { z } from 'zod';
import { badRequest, unauthorized } from '../errors.js';
import type { ActionContext } from '../services/audit.js';
// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt.
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
const result = schema.safeParse(input);
if (!result.success) {
const message = result.error.issues
.map((issue) => {
const path = issue.path.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
throw badRequest('VALIDATION_FAILED', message);
}
return result.data;
}
export function contextOf(request: FastifyRequest): ActionContext {
if (!request.user) throw unauthorized();
return {
actorId: request.user.id,
actorName: request.user.username,
ip: request.ip,
};
}
export const idParam = z.object({ id: z.coerce.number().int().positive() });
export const optionalNote = z.string().trim().max(500).optional();
+71
View File
@@ -0,0 +1,71 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
commitImport,
createImport,
discardImport,
getImport,
listImports,
parseUpload,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const mappingBody = z.object({
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
playerName: z.string().optional(),
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
orderId: z.string().optional(),
purchasedAt: z.string().optional(),
createMissingPlayers: z.boolean().default(false),
});
export async function importRoutes(app: FastifyInstance): Promise<void> {
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
const file = await request.file();
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
const content = await file.toBuffer();
const parsed = parseUpload(file.filename, content);
const importId = await createImport(file.filename, parsed, contextOf(request));
reply.code(201);
return {
importId,
columns: parsed.columns,
rowCount: parsed.rows.length,
sample: parsed.rows.slice(0, 5),
};
});
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
return { items: await listImports() };
});
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getImport(id);
});
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(mappingBody, request.body);
const summary = await applyMapping(id, body, contextOf(request));
const record = await getImport(id);
return { summary, import: record };
});
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
return commitImport(id, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
return { ok: true };
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { listLedger } from '../services/credits.js';
import {
createPlayer,
deletePlayer,
getPlayerDetail,
searchPlayers,
updatePlayer,
} from '../services/players.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const playerBody = z.object({
displayName: z.string().trim().min(1).max(120),
steamId: z.string().trim().max(64).nullish(),
forumName: z.string().trim().max(120).nullish(),
notes: z.string().trim().max(2000).nullish(),
});
export async function playerRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/players', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({
q: z.string().trim().max(120).default(''),
take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
return searchPlayers(query.q, query.take, query.skip);
});
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getPlayerDetail(id);
});
app.get('/api/players/:id/ledger', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
const query = parseWith(
z.object({
kind: z.nativeEnum(CreditKind).optional(),
take: z.coerce.number().int().min(1).max(500).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
return listLedger({ playerId: id, ...query });
});
app.post('/api/players', { preHandler: requireEditor }, async (request, reply) => {
const body = parseWith(playerBody, request.body);
const created = await createPlayer(body, contextOf(request));
reply.code(201);
return created;
});
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(playerBody.partial(), request.body);
return updatePlayer(id, body, contextOf(request));
});
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await deletePlayer(id, contextOf(request));
return { ok: true };
});
}