Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten

Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der
Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen
Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war
damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam.

Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen
Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import,
GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz
zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil
dabei Kulanz entstehen kann.

Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen.
Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung
nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht
wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
This commit is contained in:
2026-09-18 20:07:56 +02:00
parent 3344592c6e
commit 6c50d32d2b
24 changed files with 639 additions and 62 deletions
+3 -1
View File
@@ -30,7 +30,9 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
},
);
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
// Kulanz ist eine Entscheidung des Teams, kein erfasster Kauf - deshalb
// bleibt sie Administratoren vorbehalten.
app.post('/api/players/:id/credits/kulanz', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookKulanz(id, contextOf(request), { note: body.note });
+5 -5
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ForumBoard, ForumClassification } from '@prisma/client';
import { requireAdmin, requireAuth } from '../auth/guard.js';
import { requireAuth, requireEditor } from '../auth/guard.js';
import {
assignPlayer,
classifyThread,
@@ -13,7 +13,7 @@ import {
import { contextOf, idParam, parseWith } from './helpers.js';
export async function forumRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/forum/scan', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => {
return scanForum(contextOf(request));
});
@@ -40,13 +40,13 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return { items: await listBoardChangeNotices() };
});
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await dismissBoardChangeNotice(id, contextOf(request));
return { ok: true };
});
app.post('/api/forum/threads/:id/player', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/player', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ playerId: z.number().int().positive() }),
@@ -56,7 +56,7 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return { ok: true };
});
app.post('/api/forum/threads/:id/classify', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/classify', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
+7 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from '../auth/guard.js';
import { requireEditor } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
@@ -24,7 +24,7 @@ const mappingBody = z.object({
export async function importRoutes(app: FastifyInstance): Promise<void> {
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
app.post('/api/imports', { preHandler: requireEditor }, async (request, reply) => {
const file = await request.file();
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
@@ -41,16 +41,16 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
};
});
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
app.get('/api/imports', { preHandler: requireEditor }, async () => {
return { items: await listImports() };
});
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
app.get('/api/imports/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getImport(id);
});
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/mapping', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(mappingBody, request.body);
const summary = await applyMapping(id, body, contextOf(request));
@@ -58,12 +58,12 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
return { summary, import: record };
});
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/commit', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return commitImport(id, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
return { ok: true };
+26
View File
@@ -1,12 +1,15 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { AliasType } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { listLedger } from '../services/credits.js';
import {
addAlias,
createPlayer,
deletePlayer,
getPlayerDetail,
removeAlias,
searchPlayers,
updatePlayer,
} from '../services/players.js';
@@ -63,6 +66,29 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
return updatePlayer(id, body, contextOf(request));
});
// Frühere Steam- und Forumskonten eines Spielers.
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
type: z.nativeEnum(AliasType),
value: z.string().trim().min(1).max(120),
note: z.string().trim().max(200).optional(),
}),
request.body,
);
const created = await addAlias(id, body.type, body.value, body.note, contextOf(request));
reply.code(201);
return created;
});
app.delete('/api/players/aliases/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await removeAlias(id, contextOf(request));
return { ok: true };
});
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await deletePlayer(id, contextOf(request));
+3 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin, requireAuth } from '../auth/guard.js';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { notFound } from '../errors.js';
import { prisma } from '../db.js';
import {
@@ -18,7 +18,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
* Profile auf Vorrat einsammeln.
*/
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
app.post('/api/scrape/run', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
request.body ?? {},
@@ -33,7 +33,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
});
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (request) => {
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const player = await prisma.player.findUnique({