Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle

Vier Punkte aus dem Betrieb.

Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie
angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt
reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch
einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde
schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer
Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet.

Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID
ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit
unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt
hat. Jetzt werden alle Kennungen des Spielers abgefragt.

Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben.
Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade
nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden,
welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv
gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt
weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt
am Beitritt und bleibt nachvollziehbar.

Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne
auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben
- koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit
Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist
folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die
Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen.

Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim
Test.
This commit is contained in:
2026-09-21 00:19:48 +02:00
parent 15d683c59e
commit e65fdfc0ad
14 changed files with 794 additions and 34 deletions
+30
View File
@@ -54,6 +54,19 @@ Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird üb
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg. den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg.
Welcher Weg hineinführt, macht einen Unterschied:
| Bezahlt über | Was passiert |
| --- | --- |
| **CC-Paket** | Ein Platz kommt hinzu. Bestehende CCs des Spielers bleiben unberührt. |
| **Redesign** (bezahlt oder Kulanz) | Der Platz **ersetzt** einen eigenen CC, genau wie jedes andere Redesign. Welcher aufgegeben wird, muss angegeben werden. |
Ein Redesign lässt nie einen CC entstehen, es tauscht einen aus - das gilt auch beim Beitritt zu
einer Gruppe. Der aufgegebene CC muss dem Spieler gehören, **aktiv sein muss er nicht**: auch ein
längst stillgelegter CC lässt sich redesignen. Ist er noch aktiv, wird er dabei inaktiv gesetzt
(Grund *Redesignt*); war er es schon nicht mehr, bleibt er, wie er ist. Wer gar keinen eigenen CC
hat, kann nicht über ein Redesign beitreten - dafür braucht es ein CC-Paket.
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert. hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
@@ -252,6 +265,23 @@ Deshalb läuft der Abgleich **im Hintergrund**. Der Knopf zeigt den Fortschritt,
geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis
dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat. dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat.
## Problemfälle
Nicht jeder Fall lässt sich beim Eintragen sauber klären: ein CC-Antrag, zu dem sich kein Paket
finden lässt. Ein Spieler ohne GExtension-Eintrag. Angaben, die einander widersprechen.
Solche Spieler werden trotzdem angelegt und dabei **als Problemfall vorgemerkt**, mit einer
Notiz, was offen ist. Der Vermerk lässt sich auch nachträglich setzen, über *Als Problem merken*
auf der Spielerseite, und ebenso wieder abräumen.
Die Markierung ist bewusst **folgenlos**: an Guthaben, CCs und Buchungen ändert sie nichts, sie
sperrt nichts und verhindert nichts. Sie ist ein Merkzettel mit Griff. Der Gedanke dahinter:
geklärt wird, wenn es darauf ankommt - und wenn der Spieler nie wiederkommt, kommt es nie darauf
an. Bis dahin geht die Spur wenigstens nicht verloren.
Auf der Übersicht tragen betroffene Spieler ein Kennzeichen, und über *Nur offene Problemfälle*
lässt sich die Liste darauf eingrenzen.
## Rollen ## Rollen
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
@@ -0,0 +1,22 @@
-- Spieler, deren Lage sich gerade nicht klaeren laesst, werden trotzdem
-- angelegt und als Problemfall markiert.
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "isProblem" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemNote" TEXT;
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemSince" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Player_isProblem_idx" ON "Player"("isProblem");
-- Wer ueber ein Redesign in einen Gruppen-CC kommt, gibt dafuer einen eigenen
-- CC auf. Welcher das war, haengt am Beitritt.
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "predecessorId" INTEGER;
CREATE INDEX IF NOT EXISTS "CharacterMember_predecessorId_idx" ON "CharacterMember"("predecessorId");
DO $$
BEGIN
ALTER TABLE "CharacterMember"
ADD CONSTRAINT "CharacterMember_predecessorId_fkey"
FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+17
View File
@@ -123,6 +123,14 @@ model Player {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// Ein Fall, der sich gerade nicht sauber klären lässt: ein Antrag ohne
/// auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
/// Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht
/// verloren geht - geklärt wird, wenn es darauf ankommt.
isProblem Boolean @default(false)
problemNote String?
problemSince DateTime?
/// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich, /// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich,
/// wird nie automatisch mit dem Ledger verrechnet. /// wird nie automatisch mit dem Ledger verrechnet.
gextensionUsage Int? gextensionUsage Int?
@@ -137,6 +145,7 @@ model Player {
memberships CharacterMember[] memberships CharacterMember[]
@@index([displayName]) @@index([displayName])
@@index([isProblem])
} }
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner /// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
@@ -223,6 +232,8 @@ model Character {
ledgerEntries CreditLedger[] ledgerEntries CreditLedger[]
forumThreads ForumThread[] forumThreads ForumThread[]
members CharacterMember[] members CharacterMember[]
/// Beitritte, für die dieser CC aufgegeben wurde.
givenUpFor CharacterMember[] @relation("MemberPredecessor")
createdById Int? createdById Int?
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@ -414,6 +425,12 @@ model CharacterMember {
paidWith CreditKind? paidWith CreditKind?
note String? note String?
/// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur
/// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe
/// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu.
predecessorId Int?
predecessor Character? @relation("MemberPredecessor", fields: [predecessorId], references: [id], onDelete: SetNull)
createdById Int? createdById Int?
/// Ein Spieler kann demselben CC nicht zweimal angehören. /// Ein Spieler kann demselben CC nicht zweimal angehören.
+9 -1
View File
@@ -148,11 +148,19 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
z.object({ z.object({
playerId: z.number().int().positive(), playerId: z.number().int().positive(),
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), 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, note: optionalNote,
}), }),
request.body, request.body,
); );
return addMember(id, body.playerId, { useKind: body.useKind, note: body.note }, contextOf(request)); return addMember(
id,
body.playerId,
{ useKind: body.useKind, predecessorId: body.predecessorId, note: body.note },
contextOf(request),
);
}); });
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => { app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
+24 -2
View File
@@ -9,6 +9,7 @@ import {
createPlayer, createPlayer,
deletePlayer, deletePlayer,
getPlayerDetail, getPlayerDetail,
markProblem,
removeAlias, removeAlias,
searchPlayers, searchPlayers,
updatePlayer, updatePlayer,
@@ -20,6 +21,8 @@ const playerBody = z.object({
steamId: z.string().trim().max(64).nullish(), steamId: z.string().trim().max(64).nullish(),
forumName: z.string().trim().max(120).nullish(), forumName: z.string().trim().max(120).nullish(),
notes: z.string().trim().max(2000).nullish(), notes: z.string().trim().max(2000).nullish(),
isProblem: z.boolean().optional(),
problemNote: z.string().trim().max(1000).nullish(),
}); });
export async function playerRoutes(app: FastifyInstance): Promise<void> { export async function playerRoutes(app: FastifyInstance): Promise<void> {
@@ -29,10 +32,11 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
q: z.string().trim().max(120).default(''), q: z.string().trim().max(120).default(''),
take: z.coerce.number().int().min(1).max(200).default(50), take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0), skip: z.coerce.number().int().min(0).default(0),
problems: z.enum(['true', 'false']).default('false'),
}), }),
request.query, request.query,
); );
return searchPlayers(query.q, query.take, query.skip); return searchPlayers(query.q, query.take, query.skip, query.problems === 'true');
}); });
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => { app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
@@ -62,10 +66,28 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => { app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
const body = parseWith(playerBody.partial(), request.body); // Die Problem-Markierung läuft über die eigene Route, damit ein Klären
// oder Markieren als solches im Protokoll steht und nicht als "geändert".
const body = parseWith(playerBody.omit({ isProblem: true, problemNote: true }).partial(), request.body);
return updatePlayer(id, body, contextOf(request)); return updatePlayer(id, body, contextOf(request));
}); });
/**
* Einen Spieler als Problemfall markieren oder die Markierung aufheben.
* Reine Merkhilfe - an Guthaben und CCs ändert sich dadurch nichts.
*/
app.post('/api/players/:id/problem', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
isProblem: z.boolean(),
note: z.string().trim().max(1000).nullish(),
}),
request.body,
);
return markProblem(id, body, contextOf(request));
});
// Frühere Steam- und Forumskonten eines Spielers. // Frühere Steam- und Forumskonten eines Spielers.
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => { app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
+14 -7
View File
@@ -5,6 +5,7 @@ import { notFound } from '../errors.js';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { import {
knownSteamIds, knownSteamIds,
steamIdsOf,
listUnmatched, listUnmatched,
resolveUnmatched, resolveUnmatched,
scrapePlayers, scrapePlayers,
@@ -32,20 +33,26 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
return scrapePlayers(targets, contextOf(request)); return scrapePlayers(targets, contextOf(request));
}); });
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */ /**
* Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus.
* Abgefragt werden alle Konten des Spielers, nicht nur das hinterlegte -
* sonst fehlte das Guthaben, das unter einem früheren Konto gekauft wurde.
*/
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => { app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
const player = await prisma.player.findUnique({ const player = await prisma.player.findUnique({ where: { id }, select: { id: true } });
where: { id },
select: { id: true, steamId: true },
});
if (!player) throw notFound('Spieler'); if (!player) throw notFound('Spieler');
if (!player.steamId) {
const steamIds = await steamIdsOf(id);
if (steamIds.length === 0) {
throw notFound('SteamID des Spielers'); throw notFound('SteamID des Spielers');
} }
return scrapePlayers([{ playerId: player.id, steamId: player.steamId }], contextOf(request)); return scrapePlayers(
steamIds.map((steamId) => ({ playerId: id, steamId })),
contextOf(request),
);
}); });
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => { app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
+88 -3
View File
@@ -563,6 +563,61 @@ export async function reviveDeadCharacter(
const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
export type JoinKind = (typeof JOIN_KINDS)[number]; export type JoinKind = (typeof JOIN_KINDS)[number];
/// Beitritte, die auf einem Redesign beruhen und deshalb einen CC kosten.
const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ];
/**
* Prüft den CC, den ein Spieler für seinen Platz aufgibt.
*
* Über ein Redesign gibt es keinen zusätzlichen CC, sondern einen anderen:
* ohne einen eigenen CC als Grundlage gibt es nichts zu ersetzen. Aktiv muss
* dieser nicht sein - auch ein längst inaktiver CC lässt sich redesignen.
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
*/
async function ladeAufgegebenenCC(
input: { useKind: JoinKind; predecessorId?: number | undefined },
playerId: number,
characterId: number,
): Promise<Character | null> {
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
if (input.predecessorId !== undefined) {
throw badRequest(
'NO_PREDECESSOR_NEEDED',
'Ein Platz aus dem CC-Guthaben ersetzt keinen bestehenden CC',
);
}
return null;
}
if (input.predecessorId === undefined) {
throw badRequest(
'NEED_PREDECESSOR',
'Ein Beitritt über ein Redesign ersetzt einen eigenen CC - bitte angeben, welchen',
);
}
if (input.predecessorId === characterId) {
throw conflict('SAME_CHARACTER', 'Ein CC kann nicht für sich selbst aufgegeben werden');
}
const vorgaenger = await prisma.character.findUnique({
where: { id: input.predecessorId },
include: { members: { where: { playerId, leftAt: null }, select: { id: true } } },
});
if (!vorgaenger) throw notFound('Aufgegebener CC');
// Aufgeben kann nur, wem der CC gehört. Bei einem aktiven CC zählt die
// Mitgliedschaft, bei einem inaktiven der Eintrag als Inhaber - dort ist
// die Mitgliedschaft beim Ausscheiden beendet worden.
const gehoertIhm =
vorgaenger.members.length > 0 ||
(vorgaenger.status !== CharacterStatus.ACTIVE && vorgaenger.playerId === playerId);
if (!gehoertIhm) {
throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht');
}
return vorgaenger;
}
/** /**
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für * Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder * einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
@@ -571,7 +626,7 @@ export type JoinKind = (typeof JOIN_KINDS)[number];
export async function addMember( export async function addMember(
characterId: number, characterId: number,
playerId: number, playerId: number,
input: { useKind: JoinKind; note?: string | undefined }, input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
ctx: ActionContext, ctx: ActionContext,
): Promise<CharacterResult & { memberCount: number }> { ): Promise<CharacterResult & { memberCount: number }> {
const character = await loadCharacter(characterId); const character = await loadCharacter(characterId);
@@ -593,7 +648,25 @@ export async function addMember(
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits'); throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
} }
// Ein Redesign ersetzt einen CC, es kommt nie einer dazu. Wer über ein
// Redesign beitritt, gibt dafür einen eigenen CC auf - genau wie bei jedem
// anderen Redesign. Nur ein CC-Guthaben kauft einen zusätzlichen Platz.
const vorgaenger = await ladeAufgegebenenCC(input, playerId, characterId);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) {
await tx.character.update({
where: { id: vorgaenger.id },
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
});
// Der Spieler nutzt den aufgegebenen CC nicht mehr; bei einem Gruppen-CC
// laufen die übrigen Mitglieder dort unberührt weiter.
await tx.characterMember.updateMany({
where: { characterId: vorgaenger.id, playerId, leftAt: null },
data: { leftAt: new Date() },
});
}
if (vorhanden) { if (vorhanden) {
// Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die // Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die
// Zugehörigkeit nicht doppelt in der Liste steht. // Zugehörigkeit nicht doppelt in der Liste steht.
@@ -603,6 +676,7 @@ export async function addMember(
leftAt: null, leftAt: null,
joinedAt: new Date(), joinedAt: new Date(),
paidWith: input.useKind, paidWith: input.useKind,
predecessorId: vorgaenger?.id ?? null,
note: input.note ?? null, note: input.note ?? null,
createdById: ctx.actorId, createdById: ctx.actorId,
}, },
@@ -613,6 +687,7 @@ export async function addMember(
characterId, characterId,
playerId, playerId,
paidWith: input.useKind, paidWith: input.useKind,
predecessorId: vorgaenger?.id ?? null,
note: input.note ?? null, note: input.note ?? null,
createdById: ctx.actorId, createdById: ctx.actorId,
}, },
@@ -627,7 +702,11 @@ export async function addMember(
source: CreditSource.CONSUME, source: CreditSource.CONSUME,
groupId: randomUUID(), groupId: randomUUID(),
characterId, characterId,
note: input.note ?? `Beitritt zu "${character.name}"`, note:
input.note ??
(vorgaenger
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
: `Beitritt zu "${character.name}"`),
createdById: ctx.actorId, createdById: ctx.actorId,
}, },
}); });
@@ -636,7 +715,13 @@ export async function addMember(
action: 'character.member_added', action: 'character.member_added',
targetType: 'character', targetType: 'character',
targetId: characterId, targetId: characterId,
details: { playerId, name: character.name, useKind: input.useKind }, details: {
playerId,
name: character.name,
useKind: input.useKind,
predecessorId: vorgaenger?.id ?? null,
predecessorName: vorgaenger?.name ?? null,
},
}); });
}); });
+69 -4
View File
@@ -12,16 +12,25 @@ export interface PlayerInput {
steamId?: string | null; steamId?: string | null;
forumName?: string | null; forumName?: string | null;
notes?: string | null; notes?: string | null;
/// Anlegen, obwohl etwas nicht stimmt - siehe markProblem().
isProblem?: boolean;
problemNote?: string | null;
} }
export async function searchPlayers(query: string, take = 50, skip = 0) { export async function searchPlayers(
query: string,
take = 50,
skip = 0,
onlyProblems = false,
) {
const trimmed = query.trim(); const trimmed = query.trim();
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die // Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
// 64-Bit-Form gespeichert ist. // 64-Bit-Form gespeichert ist.
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null; const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
const where: Prisma.PlayerWhereInput = const where: Prisma.PlayerWhereInput = {
trimmed.length === 0 ...(onlyProblems ? { isProblem: true } : {}),
...(trimmed.length === 0
? {} ? {}
: { : {
OR: [ OR: [
@@ -29,6 +38,7 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
{ steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } }, { steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } },
{ forumName: { contains: trimmed, mode: 'insensitive' } }, { forumName: { contains: trimmed, mode: 'insensitive' } },
], ],
}),
}; };
const [players, total] = await Promise.all([ const [players, total] = await Promise.all([
@@ -57,6 +67,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
displayName: player.displayName, displayName: player.displayName,
steamId: player.steamId, steamId: player.steamId,
forumName: player.forumName, forumName: player.forumName,
isProblem: player.isProblem,
problemNote: player.problemNote,
characterCount: player._count.characters, characterCount: player._count.characters,
activeCharacterCount: activeByPlayer.get(player.id) ?? 0, activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
balances: balances.get(player.id) as Balances, balances: balances.get(player.id) as Balances,
@@ -131,6 +143,13 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
steamId, steamId,
forumName: input.forumName?.trim() || null, forumName: input.forumName?.trim() || null,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
...(input.isProblem
? {
isProblem: true,
problemNote: input.problemNote?.trim() || null,
problemSince: new Date(),
}
: {}),
}, },
}); });
@@ -138,7 +157,11 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
action: 'player.create', action: 'player.create',
targetType: 'player', targetType: 'player',
targetId: created.id, targetId: created.id,
details: { displayName: created.displayName, steamId: created.steamId }, details: {
displayName: created.displayName,
steamId: created.steamId,
isProblem: created.isProblem,
},
}); });
return created; return created;
@@ -247,6 +270,48 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
* Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe * Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe
* Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht. * Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht.
*/ */
/**
* Markiert einen Spieler als Problemfall - oder hebt die Markierung auf.
*
* Gedacht für alles, was sich im Moment nicht sauber klären lässt: ein Antrag
* ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
* Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht verloren
* geht. Geklärt wird, wenn es darauf ankommt - kommt der Spieler nie wieder,
* kommt es nie darauf an.
*
* Die Markierung hat bewusst **keine** Wirkung auf Guthaben oder CCs. Sie ist
* eine Notiz mit Griff, kein Sperrvermerk.
*/
export async function markProblem(
playerId: number,
input: { isProblem: boolean; note?: string | null },
ctx: ActionContext,
) {
const existing = await prisma.player.findUnique({ where: { id: playerId } });
if (!existing) throw notFound('Spieler');
const updated = await prisma.player.update({
where: { id: playerId },
data: input.isProblem
? {
isProblem: true,
problemNote: input.note?.trim() || null,
// Ein bereits offener Fall behält seinen Anfang.
problemSince: existing.problemSince ?? new Date(),
}
: { isProblem: false, problemNote: null, problemSince: null },
});
await recordAudit(prisma, ctx, {
action: input.isProblem ? 'player.problem_marked' : 'player.problem_cleared',
targetType: 'player',
targetId: playerId,
details: { displayName: updated.displayName, note: input.note?.trim() || null },
});
return updated;
}
export async function addAlias( export async function addAlias(
playerId: number, playerId: number,
type: AliasType, type: AliasType,
+22
View File
@@ -43,6 +43,28 @@ export async function knownSteamIds(): Promise<{ playerId: number; steamId: stri
}); });
} }
/**
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
*/
export async function steamIdsOf(playerId: number): Promise<string[]> {
const [player, aliases] = await Promise.all([
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
prisma.playerAlias.findMany({
where: { playerId, type: 'STEAM_ID' },
select: { value: true },
orderBy: { id: 'asc' },
}),
]);
const alle = [
...(player?.steamId ? [player.steamId] : []),
...aliases.map((alias) => alias.value),
];
return [...new Set(alle)];
}
export interface ScrapeSummary { export interface ScrapeSummary {
importId: number | null; importId: number | null;
scannedPlayers: number; scannedPlayers: number;
+115
View File
@@ -5,12 +5,14 @@ import { startTestDatabase, type TestDatabase } from './harness.js';
type Forum = typeof import('../src/services/forum.js'); type Forum = typeof import('../src/services/forum.js');
type Players = typeof import('../src/services/players.js'); type Players = typeof import('../src/services/players.js');
type Characters = typeof import('../src/services/characters.js'); type Characters = typeof import('../src/services/characters.js');
type Scraper = typeof import('../src/services/scraper.js');
type Db = typeof import('../src/db.js'); type Db = typeof import('../src/db.js');
let database: TestDatabase; let database: TestDatabase;
let forum: Forum; let forum: Forum;
let players: Players; let players: Players;
let characters: Characters; let characters: Characters;
let scraper: Scraper;
let db: Db; let db: Db;
let ctx: { actorId: number; actorName: string }; let ctx: { actorId: number; actorName: string };
@@ -24,6 +26,7 @@ before(async () => {
forum = await import('../src/services/forum.js'); forum = await import('../src/services/forum.js');
players = await import('../src/services/players.js'); players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js'); characters = await import('../src/services/characters.js');
scraper = await import('../src/services/scraper.js');
const user = await db.prisma.user.create({ const user = await db.prisma.user.create({
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' }, data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
@@ -196,3 +199,115 @@ describe('Antrag am CC', () => {
assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/); assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/);
}); });
}); });
describe('Problemfälle', () => {
it('legt einen Spieler an, obwohl etwas offen ist', async () => {
const spieler = await players.createPlayer(
{
displayName: 'Fall ohne Paket',
isProblem: true,
problemNote: 'CC-Antrag da, aber kein Paket auffindbar',
},
ctx,
);
assert.equal(spieler.isProblem, true);
assert.equal(spieler.problemNote, 'CC-Antrag da, aber kein Paket auffindbar');
assert.ok(spieler.problemSince, 'wann es aufgefallen ist, gehört dazu');
});
it('markiert auch im Nachhinein', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Erst später auffällig', steamId: '76561198000011111' },
ctx,
);
assert.equal(spieler.isProblem, false);
const markiert = await players.markProblem(
spieler.id,
{ isProblem: true, note: 'Kein GExtension-Eintrag' },
ctx,
);
assert.equal(markiert.isProblem, true);
assert.equal(markiert.problemNote, 'Kein GExtension-Eintrag');
});
it('behält den Anfang, wenn die Notiz nachgeschärft wird', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Langer Fall', isProblem: true, problemNote: 'unklar' },
ctx,
);
const anfang = spieler.problemSince;
const nachgeschaerft = await players.markProblem(
spieler.id,
{ isProblem: true, note: 'Paket liegt unter altem Konto' },
ctx,
);
assert.deepEqual(nachgeschaerft.problemSince, anfang, 'der Fall ist derselbe, nur klarer');
});
it('räumt die Markierung restlos ab', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Geklärt', isProblem: true, problemNote: 'war ein Tippfehler' },
ctx,
);
const geklaert = await players.markProblem(spieler.id, { isProblem: false }, ctx);
assert.equal(geklaert.isProblem, false);
assert.equal(geklaert.problemNote, null);
assert.equal(geklaert.problemSince, null);
});
it('ändert an Guthaben und CCs nichts', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Trotzdem berechtigt', steamId: '76561198000012222' },
ctx,
);
await db.prisma.creditLedger.create({
data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
});
const { character } = await characters.createCharacter(spieler.id, { name: 'Bleibt' }, ctx);
await players.markProblem(spieler.id, { isProblem: true, note: 'Herkunft unklar' }, ctx);
const detail = await players.getPlayerDetail(spieler.id);
assert.equal(detail.balances.CC, 0, 'das Paket ist verbraucht, nicht verschwunden');
assert.equal(detail.characters.find((c) => c.id === character.id)?.status, 'ACTIVE');
});
it('listet sie auf Wunsch für sich', async () => {
const offen = await players.searchPlayers('', 100, 0, true);
assert.ok(offen.total >= 3);
assert.equal(
offen.items.every((p) => p.isProblem),
true,
);
const alle = await players.searchPlayers('', 100, 0, false);
assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei');
});
});
describe('Konten beim Abgleich', () => {
it('sammelt alle Steam-Kennungen eines Spielers, nicht nur die hinterlegte', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Kontowechsler', steamId: '76561198000013333' },
ctx,
);
await players.addAlias(spieler.id, 'STEAM_ID', '76561198000014444', 'Erstes Konto', ctx);
await players.addAlias(spieler.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Ganz altes Konto', ctx);
const kennungen = await scraper.steamIdsOf(spieler.id);
assert.deepEqual(
kennungen.sort(),
['76561198000013333', '76561198000014444', '76561198000000999'].sort(),
'auch in der Legacy-Schreibweise nachgetragene Konten gehören dazu',
);
});
it('kommt mit einem Spieler ohne jede Kennung klar', async () => {
const spieler = await players.createPlayer({ displayName: 'Nur ein Name' }, ctx);
assert.deepEqual(await scraper.steamIdsOf(spieler.id), []);
});
});
+94 -3
View File
@@ -69,22 +69,113 @@ describe('Gruppen-CCs', () => {
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich'); assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
}); });
it('lässt den Beitritt auch über ein Redesign zu', async () => { it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => {
const gruender = await spielerMitPaket('Chef'); const gruender = await spielerMitPaket('Chef');
const zweiter = await spielerMitPaket('Nachzügler'); const zweiter = await spielerMitPaket('Nachzügler');
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx); const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht. // Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx); const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
const result = await characters.addMember( const result = await characters.addMember(
character.id, character.id,
zweiter, zweiter,
{ useKind: 'REDESIGN_PAID' }, { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
ctx, ctx,
); );
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
assert.equal(result.memberCount, 2); assert.equal(result.memberCount, 2);
// Ein Redesign ersetzt einen CC, es kommt keiner dazu.
const aufgegeben = await db.prisma.character.findUniqueOrThrow({
where: { id: eigener.character.id },
});
assert.equal(aufgegeben.status, 'INACTIVE');
assert.equal(aufgegeben.inactiveReason, 'REWORKED');
const eigene = await characters.listCharacters(zweiter);
assert.deepEqual(
eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name),
['Redesign-Bande'],
'der alte CC ist weg, der Gruppen-CC an seiner Stelle',
);
const beitritt = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: zweiter },
});
assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar');
});
it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => {
const gruender = await spielerMitPaket('Chefin');
const zweiter = await spielerMitPaket('Ohne CC');
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx);
await assert.rejects(
() => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR',
'ohne Vorgänger gäbe es nichts zu ersetzen',
);
});
it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => {
const gruender = await spielerMitPaket('Anführer');
const zweiter = await spielerMitPaket('Rückkehrer');
const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx);
const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx);
await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx);
const result = await characters.addMember(
character.id,
zweiter,
{ useKind: 'REDESIGN_PAID', predecessorId: alt.character.id },
ctx,
);
assert.equal(result.memberCount, 2);
// Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben.
const unveraendert = await db.prisma.character.findUniqueOrThrow({
where: { id: alt.character.id },
});
assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY');
});
it('lässt niemanden einen fremden CC aufgeben', async () => {
const gruender = await spielerMitPaket('Wirt');
const fremder = await spielerMitPaket('Fremder');
const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx);
await assert.rejects(
() =>
characters.addMember(
character.id,
fremder,
{ useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER',
);
});
it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => {
const gruender = await spielerMitPaket('Gastgeber');
const zweiter = await spielerMitPaket('Gast');
const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx);
const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx);
await assert.rejects(
() =>
characters.addMember(
character.id,
zweiter,
{ useKind: 'CC', predecessorId: eigener.character.id },
ctx,
),
(error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED',
'ein CC-Paket kauft einen Platz, es ersetzt nichts',
);
}); });
it('nimmt denselben Spieler nicht zweimal auf', async () => { it('nimmt denselben Spieler nicht zweimal auf', async () => {
+8
View File
@@ -36,6 +36,8 @@ export interface PlayerSummary {
displayName: string; displayName: string;
steamId: string | null; steamId: string | null;
forumName: string | null; forumName: string | null;
isProblem: boolean;
problemNote: string | null;
characterCount: number; characterCount: number;
activeCharacterCount: number; activeCharacterCount: number;
balances: Balances; balances: Balances;
@@ -47,6 +49,10 @@ export interface Player {
steamId: string | null; steamId: string | null;
forumName: string | null; forumName: string | null;
notes: string | null; notes: string | null;
/// Ein Fall, der sich gerade nicht klären lässt. Reine Merkhilfe.
isProblem: boolean;
problemNote: string | null;
problemSince: string | null;
createdAt: string; createdAt: string;
gextensionUsage: number | null; gextensionUsage: number | null;
gextensionSyncedAt: string | null; gextensionSyncedAt: string | null;
@@ -105,6 +111,8 @@ export interface CharacterMember {
joinedAt: string; joinedAt: string;
leftAt: string | null; leftAt: string | null;
paidWith: CreditKind | null; paidWith: CreditKind | null;
/// Der eigene CC, den der Spieler für den Platz aufgegeben hat.
predecessorId: number | null;
note: string | null; note: string | null;
} }
+52 -5
View File
@@ -29,11 +29,14 @@ export default function Dashboard() {
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] }); const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null); const [syncResult, setSyncResult] = useState<string | null>(null);
/// Nur die Fälle zeigen, bei denen noch etwas offen ist.
const [nurProbleme, setNurProbleme] = useState(false);
const load = useCallback(async (search: string, signal?: AbortSignal) => { const load = useCallback(async (search: string, probleme: boolean, signal?: AbortSignal) => {
setLoading(true); setLoading(true);
try { try {
const params = new URLSearchParams({ q: search, take: '100' }); const params = new URLSearchParams({ q: search, take: '100' });
if (probleme) params.set('problems', 'true');
const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {}); const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {});
setResult(data); setResult(data);
setError(null); setError(null);
@@ -78,7 +81,7 @@ export default function Dashboard() {
? '. Unter Import bestätigen, damit die Guthaben entstehen.' ? '. Unter Import bestätigen, damit die Guthaben entstehen.'
: '.'), : '.'),
); );
await Promise.all([load(query), loadFlagged()]); await Promise.all([load(query, nurProbleme), loadFlagged()]);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally { } finally {
@@ -89,12 +92,12 @@ export default function Dashboard() {
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst. // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => void load(query, controller.signal), 250); const timer = setTimeout(() => void load(query, nurProbleme, controller.signal), 250);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
controller.abort(); controller.abort();
}; };
}, [query, load]); }, [query, nurProbleme, load]);
return ( return (
<div className="page"> <div className="page">
@@ -123,6 +126,15 @@ export default function Dashboard() {
autoFocus autoFocus
/> />
<label className="checkbox" style={{ marginBottom: '1rem' }}>
<input
type="checkbox"
checked={nurProbleme}
onChange={(event) => setNurProbleme(event.target.checked)}
/>
<span>Nur offene Problemfälle</span>
</label>
{error && <Notice kind="error">{error}</Notice>} {error && <Notice kind="error">{error}</Notice>}
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>} {syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
@@ -162,6 +174,11 @@ export default function Dashboard() {
<Link to={`/players/${player.id}`} className="link-strong"> <Link to={`/players/${player.id}`} className="link-strong">
{player.displayName} {player.displayName}
</Link> </Link>
{player.isProblem && (
<span className="badge badge-dead" title={player.problemNote ?? 'Offener Fall'}>
Problem
</span>
)}
{player.forumName && <span className="muted"> · {player.forumName}</span>} {player.forumName && <span className="muted"> · {player.forumName}</span>}
</td> </td>
<td className="mono muted">{player.steamId ?? '-'}</td> <td className="mono muted">{player.steamId ?? '-'}</td>
@@ -203,7 +220,7 @@ export default function Dashboard() {
onClose={() => setCreating(false)} onClose={() => setCreating(false)}
onCreated={() => { onCreated={() => {
setCreating(false); setCreating(false);
void load(query); void load(query, nurProbleme);
}} }}
/> />
)} )}
@@ -215,6 +232,8 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [steamId, setSteamId] = useState(''); const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState(''); const [forumName, setForumName] = useState('');
const [istProblem, setIstProblem] = useState(false);
const [problemNote, setProblemNote] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -229,6 +248,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
displayName, displayName,
steamId: steamId.trim() || null, steamId: steamId.trim() || null,
forumName: forumName.trim() || null, forumName: forumName.trim() || null,
...(istProblem ? { isProblem: true, problemNote: problemNote.trim() || null } : {}),
}, },
}); });
onCreated(); onCreated();
@@ -272,6 +292,33 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
onChange={(event) => setForumName(event.target.value)} onChange={(event) => setForumName(event.target.value)}
/> />
<label className="checkbox">
<input
type="checkbox"
checked={istProblem}
onChange={(event) => setIstProblem(event.target.checked)}
/>
<span>Als Problemfall vormerken</span>
</label>
<p className="muted small">
Für alles, was sich gerade nicht sauber klären lässt: ein Antrag ohne auffindbares
Paket, kein GExtension-Eintrag, widersprüchliche Angaben. Der Spieler wird trotzdem
angelegt, damit die Spur nicht verloren geht. An Guthaben und CCs ändert die
Markierung nichts.
</p>
{istProblem && (
<>
<label htmlFor="problemNote">Was ist offen?</label>
<input
id="problemNote"
value={problemNote}
onChange={(event) => setProblemNote(event.target.value)}
placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar"
/>
</>
)}
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}> <button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen Abbrechen
+227 -6
View File
@@ -17,6 +17,7 @@ import {
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
import type { import type {
Character, Character,
Player,
PlayerSummary, PlayerSummary,
CreditKind, CreditKind,
LedgerEntry, LedgerEntry,
@@ -59,7 +60,8 @@ type DialogState =
| { type: 'deletePlayer' } | { type: 'deletePlayer' }
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' } | { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' }
| { type: 'addMember'; character: Character } | { type: 'addMember'; character: Character }
| { type: 'reviveDead'; character: Character }; | { type: 'reviveDead'; character: Character }
| { type: 'problem' };
export default function PlayerDetail() { export default function PlayerDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -148,6 +150,7 @@ export default function PlayerDetail() {
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>; if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
const editor = can('EDITOR'); const editor = can('EDITOR');
const admin = can('ADMIN');
const active = detail.characters.filter((c) => c.status === 'ACTIVE'); const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
@@ -175,6 +178,13 @@ export default function PlayerDetail() {
> >
Bearbeiten Bearbeiten
</button> </button>
<button
type="button"
className="btn"
onClick={() => setDialog({ type: 'problem' })}
>
{detail.player.isProblem ? 'Problem klären' : 'Als Problem merken'}
</button>
{can('ADMIN') && ( {can('ADMIN') && (
<button <button
type="button" type="button"
@@ -191,6 +201,19 @@ export default function PlayerDetail() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>} {error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>} {flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{detail.player.isProblem && (
<Notice kind="warn">
<strong>Offener Problemfall.</strong>{' '}
{detail.player.problemNote ?? 'Ohne nähere Angabe vermerkt.'}
{detail.player.problemSince && (
<span className="muted"> · vermerkt {formatDate(detail.player.problemSince)}</span>
)}
<br />
Guthaben und CCs sind davon unberührt - die Markierung ist nur ein Merkzettel, bis es
darauf ankommt.
</Notice>
)}
<BalanceCards balances={detail.balances} /> <BalanceCards balances={detail.balances} />
{detail.player.gextensionUsage !== null && ( {detail.player.gextensionUsage !== null && (
@@ -361,14 +384,28 @@ export default function PlayerDetail() {
<p className="lore">{character.inactiveNote}</p> <p className="lore">{character.inactiveNote}</p>
)} )}
<p className="muted small"> <p className="muted small">
{free {art === 'admin'
? 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator.'
: free
? 'Kommt gratis zurück - kostet kein Guthaben.' ? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'} : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p> </p>
</div> </div>
{editor && ( {editor && (
<div className="cc-actions"> <div className="cc-actions">
{free ? ( {art === 'admin' ? (
admin ? (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setDialog({ type: 'reviveDead', character })}
>
Zurückholen
</button>
) : (
<span className="muted small">Nur ein Administrator</span>
)
) : free ? (
<button <button
type="button" type="button"
className="btn btn-small btn-primary" className="btn btn-small btn-primary"
@@ -576,6 +613,23 @@ export default function PlayerDetail() {
/> />
)} )}
{dialog.type === 'problem' && (
<ProblemModal
player={detail.player}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/players/${playerId}/problem`, { method: 'POST', body: values }),
values.isProblem
? 'Als Problemfall vorgemerkt.'
: 'Problemfall als geklärt abgehakt.',
)
}
/>
)}
{dialog.type === 'addMember' && ( {dialog.type === 'addMember' && (
<AddMemberModal <AddMemberModal
character={dialog.character} character={dialog.character}
@@ -1302,13 +1356,24 @@ function AddMemberModal({
character: Character; character: Character;
busy: boolean; busy: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (values: { playerId: number; useKind: CreditKind; note?: string }) => void; onSubmit: (values: {
playerId: number;
useKind: CreditKind;
predecessorId?: number;
note?: string;
}) => void;
}) { }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<PlayerSummary[]>([]); const [results, setResults] = useState<PlayerSummary[]>([]);
const [gewaehlt, setGewaehlt] = useState<PlayerSummary | null>(null); const [gewaehlt, setGewaehlt] = useState<PlayerSummary | null>(null);
const [useKind, setUseKind] = useState<CreditKind>('CC'); const [useKind, setUseKind] = useState<CreditKind>('CC');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [eigeneCCs, setEigeneCCs] = useState<Character[]>([]);
const [vorgaenger, setVorgaenger] = useState<number | null>(null);
// Ein Redesign ersetzt einen CC, es kommt keiner dazu - also muss feststehen,
// welchen der Spieler dafür aufgibt.
const ueberRedesign = useKind === 'REDESIGN_PAID' || useKind === 'REDESIGN_KULANZ';
useEffect(() => { useEffect(() => {
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
@@ -1323,11 +1388,31 @@ function AddMemberModal({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [query, character.playerId]); }, [query, character.playerId]);
// Die eigenen CCs des Gewählten, sobald er feststeht. Auch die inaktiven:
// ein längst stillgelegter CC lässt sich ebenso redesignen.
useEffect(() => {
if (!gewaehlt) {
setEigeneCCs([]);
setVorgaenger(null);
return;
}
void (async () => {
try {
const detail = await api<Detail>(`/api/players/${gewaehlt.id}`);
setEigeneCCs(detail.characters.filter((c) => c.id !== character.id));
} catch {
// Ohne Liste bleibt nur ein Platz aus dem CC-Guthaben.
setEigeneCCs([]);
}
})();
}, [gewaehlt, character.id]);
return ( return (
<Modal title={`Nutzer zu "${character.name}" hinzufügen`} onClose={onClose}> <Modal title={`Nutzer zu "${character.name}" hinzufügen`} onClose={onClose}>
<Notice kind="info"> <Notice kind="info">
Für den neuen Nutzer gilt dasselbe wie für einen eigenen CC: der Platz kostet ihn ein Für den neuen Nutzer gilt dasselbe wie für einen eigenen CC: der Platz kostet ihn ein
eigenes Guthaben. Ob über sein CC-Paket oder ein Redesign, entscheidest du hier. eigenes Guthaben. Über ein CC-Paket kommt ein Platz hinzu; über ein Redesign tritt der
Platz an die Stelle eines seiner CCs, genau wie bei jedem anderen Redesign.
</Notice> </Notice>
{!gewaehlt ? ( {!gewaehlt ? (
@@ -1370,6 +1455,7 @@ function AddMemberModal({
onSubmit({ onSubmit({
playerId: gewaehlt.id, playerId: gewaehlt.id,
useKind, useKind,
...(ueberRedesign && vorgaenger !== null ? { predecessorId: vorgaenger } : {}),
...(note.trim() ? { note: note.trim() } : {}), ...(note.trim() ? { note: note.trim() } : {}),
}); });
}} }}
@@ -1403,6 +1489,45 @@ function AddMemberModal({
))} ))}
</div> </div>
{ueberRedesign && (
<>
<label htmlFor="member-predecessor">Welchen eigenen CC gibt er dafür auf?</label>
{eigeneCCs.length === 0 ? (
<Notice kind="warn">
{gewaehlt.displayName} hat keinen eigenen CC. Ein Redesign ersetzt einen CC -
ohne einen gibt es nichts zu ersetzen. Für einen zusätzlichen Platz braucht es
ein CC-Paket.
</Notice>
) : (
<>
<select
id="member-predecessor"
value={vorgaenger ?? ''}
onChange={(event) =>
setVorgaenger(event.target.value ? Number(event.target.value) : null)
}
>
<option value="">Bitte wählen</option>
{eigeneCCs.map((cc) => (
<option key={cc.id} value={cc.id}>
{cc.name}
{cc.status === 'INACTIVE'
? ` (inaktiv${
cc.inactiveReason ? `: ${REASON_LABEL[cc.inactiveReason]}` : ''
})`
: ''}
</option>
))}
</select>
<p className="muted small">
Ein aktiver CC wird dabei inaktiv gesetzt (redesignt). Ein bereits inaktiver
bleibt, wie er ist - aufgegeben wurde er schon.
</p>
</>
)}
</>
)}
<label htmlFor="member-note">Notiz (optional)</label> <label htmlFor="member-note">Notiz (optional)</label>
<input <input
id="member-note" id="member-note"
@@ -1415,7 +1540,11 @@ function AddMemberModal({
<button type="button" className="btn btn-ghost" onClick={() => setGewaehlt(null)}> <button type="button" className="btn btn-ghost" onClick={() => setGewaehlt(null)}>
Anderen wählen Anderen wählen
</button> </button>
<button type="submit" className="btn btn-primary" disabled={busy}> <button
type="submit"
className="btn btn-primary"
disabled={busy || (ueberRedesign && vorgaenger === null)}
>
{busy ? 'Speichere …' : 'Aufnehmen'} {busy ? 'Speichere …' : 'Aufnehmen'}
</button> </button>
</div> </div>
@@ -1517,3 +1646,95 @@ function ReviveDeadModal({
</Modal> </Modal>
); );
} }
/**
* Markiert einen Spieler als Problemfall oder hakt ihn ab. Bewusst ohne jede
* Wirkung auf Guthaben und CCs - hier wird nur notiert, was offen ist.
*/
function ProblemModal({
player,
busy,
onClose,
onSubmit,
}: {
player: Player;
busy: boolean;
onClose: () => void;
onSubmit: (values: { isProblem: boolean; note?: string | null }) => void;
}) {
const [note, setNote] = useState(player.problemNote ?? '');
if (player.isProblem) {
return (
<Modal title={`Problemfall "${player.displayName}"`} onClose={onClose}>
<Notice kind="info">
Vermerkt ist: {player.problemNote ?? 'ohne nähere Angabe'}
{player.problemSince && ` (seit ${formatDate(player.problemSince)})`}.
</Notice>
<label htmlFor="problem-note">Vermerk nachschärfen</label>
<input
id="problem-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Paket liegt unter einem früheren Konto"
/>
<div className="form-actions">
<button
type="button"
className="btn"
disabled={busy}
onClick={() => onSubmit({ isProblem: true, note: note.trim() || null })}
>
Vermerk aktualisieren
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onSubmit({ isProblem: false })}
>
Geklärt - Markierung entfernen
</button>
</div>
</Modal>
);
}
return (
<Modal title={`"${player.displayName}" als Problem merken`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({ isProblem: true, note: note.trim() || null });
}}
>
<Notice kind="info">
Für alles, was sich gerade nicht klären lässt: ein Antrag ohne auffindbares Paket, kein
GExtension-Eintrag, widersprüchliche Angaben. Der Fall bleibt so auffindbar, bis es
darauf ankommt - kommt der Spieler nie wieder, kommt es nie darauf an. An Guthaben und
CCs ändert sich nichts.
</Notice>
<label htmlFor="problem-note">Was ist offen?</label>
<input
id="problem-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar"
autoFocus
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Vormerken'}
</button>
</div>
</form>
</Modal>
);
}