diff --git a/README.md b/README.md index cf8cb07..c794e9d 100644 --- a/README.md +++ b/README.md @@ -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 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 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 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 Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. diff --git a/server/prisma/migrations/20250107000000_problemfaelle_und_beitritt/migration.sql b/server/prisma/migrations/20250107000000_problemfaelle_und_beitritt/migration.sql new file mode 100644 index 0000000..87bd6fc --- /dev/null +++ b/server/prisma/migrations/20250107000000_problemfaelle_und_beitritt/migration.sql @@ -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 $$; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a946f22..9e7933a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -123,6 +123,14 @@ model Player { createdAt DateTime @default(now()) 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, /// wird nie automatisch mit dem Ledger verrechnet. gextensionUsage Int? @@ -137,6 +145,7 @@ model Player { memberships CharacterMember[] @@index([displayName]) + @@index([isProblem]) } /// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner @@ -223,6 +232,8 @@ model Character { ledgerEntries CreditLedger[] forumThreads ForumThread[] members CharacterMember[] + /// Beitritte, für die dieser CC aufgegeben wurde. + givenUpFor CharacterMember[] @relation("MemberPredecessor") createdById Int? createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) @@ -414,6 +425,12 @@ model CharacterMember { paidWith CreditKind? 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? /// Ein Spieler kann demselben CC nicht zweimal angehören. diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index fc42661..2f69160 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -148,11 +148,19 @@ export async function characterRoutes(app: FastifyInstance): Promise { z.object({ playerId: z.number().int().positive(), useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), + /// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der + /// Platz über ein Redesign bezahlt wird. + predecessorId: z.number().int().positive().optional(), note: optionalNote, }), request.body, ); - return addMember(id, body.playerId, { useKind: body.useKind, 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) => { diff --git a/server/src/routes/players.routes.ts b/server/src/routes/players.routes.ts index 2787e1f..cbdc703 100644 --- a/server/src/routes/players.routes.ts +++ b/server/src/routes/players.routes.ts @@ -9,6 +9,7 @@ import { createPlayer, deletePlayer, getPlayerDetail, + markProblem, removeAlias, searchPlayers, updatePlayer, @@ -20,6 +21,8 @@ const playerBody = z.object({ steamId: z.string().trim().max(64).nullish(), forumName: z.string().trim().max(120).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 { @@ -29,10 +32,11 @@ export async function playerRoutes(app: FastifyInstance): Promise { 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), + problems: z.enum(['true', 'false']).default('false'), }), 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) => { @@ -62,10 +66,28 @@ export async function playerRoutes(app: FastifyInstance): Promise { app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => { 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)); }); + /** + * 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. app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => { const { id } = parseWith(idParam, request.params); diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts index 1d0a416..f858f47 100644 --- a/server/src/routes/scraper.routes.ts +++ b/server/src/routes/scraper.routes.ts @@ -5,6 +5,7 @@ import { notFound } from '../errors.js'; import { prisma } from '../db.js'; import { knownSteamIds, + steamIdsOf, listUnmatched, resolveUnmatched, scrapePlayers, @@ -32,20 +33,26 @@ export async function scraperRoutes(app: FastifyInstance): Promise { 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) => { const { id } = parseWith(idParam, request.params); - const player = await prisma.player.findUnique({ - where: { id }, - select: { id: true, steamId: true }, - }); + const player = await prisma.player.findUnique({ where: { id }, select: { id: true } }); if (!player) throw notFound('Spieler'); - if (!player.steamId) { + + const steamIds = await steamIdsOf(id); + if (steamIds.length === 0) { 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) => { diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index eff562a..b74dfd4 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -563,6 +563,61 @@ export async function reviveDeadCharacter( const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; 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 { + 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 * 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( characterId: number, playerId: number, - input: { useKind: JoinKind; note?: string | undefined }, + input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined }, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); @@ -593,7 +648,25 @@ export async function addMember( 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) => { + 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) { // Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die // Zugehörigkeit nicht doppelt in der Liste steht. @@ -603,6 +676,7 @@ export async function addMember( leftAt: null, joinedAt: new Date(), paidWith: input.useKind, + predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, }, @@ -613,6 +687,7 @@ export async function addMember( characterId, playerId, paidWith: input.useKind, + predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, }, @@ -627,7 +702,11 @@ export async function addMember( source: CreditSource.CONSUME, groupId: randomUUID(), 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, }, }); @@ -636,7 +715,13 @@ export async function addMember( action: 'character.member_added', targetType: 'character', 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, + }, }); }); diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 5bcfa91..b0fbab6 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -12,16 +12,25 @@ export interface PlayerInput { steamId?: string | null; forumName?: 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(); // Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die // 64-Bit-Form gespeichert ist. const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null; - const where: Prisma.PlayerWhereInput = - trimmed.length === 0 + const where: Prisma.PlayerWhereInput = { + ...(onlyProblems ? { isProblem: true } : {}), + ...(trimmed.length === 0 ? {} : { OR: [ @@ -29,7 +38,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) { { steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } }, { forumName: { contains: trimmed, mode: 'insensitive' } }, ], - }; + }), + }; const [players, total] = await Promise.all([ prisma.player.findMany({ @@ -57,6 +67,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) { displayName: player.displayName, steamId: player.steamId, forumName: player.forumName, + isProblem: player.isProblem, + problemNote: player.problemNote, characterCount: player._count.characters, activeCharacterCount: activeByPlayer.get(player.id) ?? 0, balances: balances.get(player.id) as Balances, @@ -131,6 +143,13 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) { steamId, forumName: input.forumName?.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', targetType: 'player', targetId: created.id, - details: { displayName: created.displayName, steamId: created.steamId }, + details: { + displayName: created.displayName, + steamId: created.steamId, + isProblem: created.isProblem, + }, }); 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 * 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( playerId: number, type: AliasType, diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index 1f335ce..212891d 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -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 { + 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 { importId: number | null; scannedPlayers: number; diff --git a/server/test/antraege.test.ts b/server/test/antraege.test.ts index cec7634..82a21fa 100644 --- a/server/test/antraege.test.ts +++ b/server/test/antraege.test.ts @@ -5,12 +5,14 @@ import { startTestDatabase, type TestDatabase } from './harness.js'; type Forum = typeof import('../src/services/forum.js'); type Players = typeof import('../src/services/players.js'); type Characters = typeof import('../src/services/characters.js'); +type Scraper = typeof import('../src/services/scraper.js'); type Db = typeof import('../src/db.js'); let database: TestDatabase; let forum: Forum; let players: Players; let characters: Characters; +let scraper: Scraper; let db: Db; let ctx: { actorId: number; actorName: string }; @@ -24,6 +26,7 @@ before(async () => { forum = await import('../src/services/forum.js'); players = await import('../src/services/players.js'); characters = await import('../src/services/characters.js'); + scraper = await import('../src/services/scraper.js'); const user = await db.prisma.user.create({ data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' }, @@ -196,3 +199,115 @@ describe('Antrag am CC', () => { 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), []); + }); +}); diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index 99c61a6..2e152f5 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -69,22 +69,113 @@ describe('Gruppen-CCs', () => { 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 zweiter = await spielerMitPaket('Nachzügler'); const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx); // 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( character.id, zweiter, - { useKind: 'REDESIGN_PAID' }, + { useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id }, ctx, ); assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); 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 () => { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b537e79..f8105cf 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -36,6 +36,8 @@ export interface PlayerSummary { displayName: string; steamId: string | null; forumName: string | null; + isProblem: boolean; + problemNote: string | null; characterCount: number; activeCharacterCount: number; balances: Balances; @@ -47,6 +49,10 @@ export interface Player { steamId: string | null; forumName: 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; gextensionUsage: number | null; gextensionSyncedAt: string | null; @@ -105,6 +111,8 @@ export interface CharacterMember { joinedAt: string; leftAt: string | null; paidWith: CreditKind | null; + /// Der eigene CC, den der Spieler für den Platz aufgegeben hat. + predecessorId: number | null; note: string | null; } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 8f2bffe..3d9ffbd 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -29,11 +29,14 @@ export default function Dashboard() { const [flagged, setFlagged] = useState({ total: 0, players: [] }); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(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); try { const params = new URLSearchParams({ q: search, take: '100' }); + if (probleme) params.set('problems', 'true'); const data = await api(`/api/players?${params}`, signal ? { signal } : {}); setResult(data); setError(null); @@ -78,7 +81,7 @@ export default function Dashboard() { ? '. Unter Import bestätigen, damit die Guthaben entstehen.' : '.'), ); - await Promise.all([load(query), loadFlagged()]); + await Promise.all([load(query, nurProbleme), loadFlagged()]); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); } finally { @@ -89,12 +92,12 @@ export default function Dashboard() { // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst. useEffect(() => { const controller = new AbortController(); - const timer = setTimeout(() => void load(query, controller.signal), 250); + const timer = setTimeout(() => void load(query, nurProbleme, controller.signal), 250); return () => { clearTimeout(timer); controller.abort(); }; - }, [query, load]); + }, [query, nurProbleme, load]); return (
@@ -123,6 +126,15 @@ export default function Dashboard() { autoFocus /> + + {error && {error}} {syncResult && setSyncResult(null)}>{syncResult}} @@ -162,6 +174,11 @@ export default function Dashboard() { {player.displayName} + {player.isProblem && ( + + Problem + + )} {player.forumName && · {player.forumName}} {player.steamId ?? '-'} @@ -203,7 +220,7 @@ export default function Dashboard() { onClose={() => setCreating(false)} onCreated={() => { 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 [steamId, setSteamId] = useState(''); const [forumName, setForumName] = useState(''); + const [istProblem, setIstProblem] = useState(false); + const [problemNote, setProblemNote] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -229,6 +248,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea displayName, steamId: steamId.trim() || null, forumName: forumName.trim() || null, + ...(istProblem ? { isProblem: true, problemNote: problemNote.trim() || null } : {}), }, }); onCreated(); @@ -272,6 +292,33 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea onChange={(event) => setForumName(event.target.value)} /> + +

+ 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. +

+ + {istProblem && ( + <> + + setProblemNote(event.target.value)} + placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar" + /> + + )} +
+ {can('ADMIN') && (
{editor && (
- {free ? ( + {art === 'admin' ? ( + admin ? ( + + ) : ( + Nur ein Administrator + ) + ) : free ? (
+ {ueberRedesign && ( + <> + + {eigeneCCs.length === 0 ? ( + + {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. + + ) : ( + <> + +

+ Ein aktiver CC wird dabei inaktiv gesetzt (redesignt). Ein bereits inaktiver + bleibt, wie er ist - aufgegeben wurde er schon. +

+ + )} + + )} + setGewaehlt(null)}> Anderen wählen -
@@ -1517,3 +1646,95 @@ function ReviveDeadModal({ ); } + +/** + * 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 ( + + + Vermerkt ist: {player.problemNote ?? 'ohne nähere Angabe'} + {player.problemSince && ` (seit ${formatDate(player.problemSince)})`}. + + + + setNote(event.target.value)} + placeholder="z. B. Paket liegt unter einem früheren Konto" + /> + +
+ + +
+
+ ); + } + + return ( + +
{ + event.preventDefault(); + onSubmit({ isProblem: true, note: note.trim() || null }); + }} + > + + 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. + + + + setNote(event.target.value)} + placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar" + autoFocus + /> + +
+ + +
+
+
+ ); +}