diff --git a/README.md b/README.md index 418ec49..9b7251e 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,31 @@ 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. +### Einzelner Nutzer oder ganze Gruppe + +Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist: + +| Knopf | Wirkung | +| --- | --- | +| **Anteil redesignen** | Dieser Spieler steigt aus und bekommt einen eigenen CC. Die Gruppe läuft unberührt weiter. | +| **Gruppe redesignen** | Der ganze CC wandert in einen neuen; alle bisherigen Nutzer sind auch dort dabei. | +| **Nur diesen Nutzer** | Dieser Platz fällt weg - geputzt, entzogen oder im Rollenspiel gestorben. Der CC bleibt bestehen. | +| **Deaktivieren** | Der ganze CC wird inaktiv, für alle. | + +Ein Ausstieg per Redesign ist ein Tausch wie jeder andere: Der Platz in der Gruppe geht weg, der +eigene CC kommt an seine Stelle - **es kommt keiner hinzu**. Wohin jemand gewechselt ist, steht +danach bei den früheren Nutzern („jetzt …"). + +Einzelne Nutzer können **unabhängig voneinander** ausfallen. Bei einem Trio kann einer im +Rollenspiel sterben, während die beiden anderen weiterspielen; ebenso können alle nacheinander +wegfallen. Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC: geputzt kommt +gratis zurück, entzogen kostet ein Guthaben, und über einen Gestorbenen entscheidet ein +Administrator mit Begründung. Wer ausgefallen ist, sieht seinen CC weiterhin auf seiner +Spielerseite - sonst gäbe es nichts, worüber er zurückkehren könnte. + +**Ein freiwilliger Austritt ist etwas anderes.** Wer über das ✕ ausgetragen wird, hat keinen +Anspruch auf Rückkehr; ein Wiedereintritt ist ein neuer Beitritt und kostet ein Guthaben. + Ein Redesign nimmt die Gruppe mit: Wer zum Zeitpunkt des Redesigns dabei war, ist es danach auch. Bezahlt wird es von dem, der es beantragt. @@ -218,7 +243,9 @@ automatisch auch das inklusive Redesign-Guthaben. Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe zum Abgleich mit dem eigenen Ledger, mehr nicht. -Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. +Der Abgleich läuft **im Hintergrund**, mit Fortschrittsbalken und einem Protokoll, das Profil +für Profil mitschreibt - sowohl beim Abgleich aller Spieler als auch beim einzelnen. Er hält eine +Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. Weist GExtension eine Anfrage wegen zu vieler Zugriffe ab, wird gewartet und erneut versucht, statt den Spieler zu überspringen; das Tempo bleibt für den Rest des Laufs gedrosselt. Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der diff --git a/server/prisma/migrations/20250109000000_mitglieder_einzeln/migration.sql b/server/prisma/migrations/20250109000000_mitglieder_einzeln/migration.sql new file mode 100644 index 0000000..1b62df6 --- /dev/null +++ b/server/prisma/migrations/20250109000000_mitglieder_einzeln/migration.sql @@ -0,0 +1,20 @@ +-- Bei einem Gruppen-CC kann ein einzelner Spieler ausfallen, waehrend die +-- uebrigen weiterspielen: geputzt, entzogen oder im Rollenspiel gestorben. +-- Der Grund haengt deshalb auch an der Mitgliedschaft, nicht nur am CC. +ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveReason" "InactiveReason"; +ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveNote" TEXT; + +-- Wer seinen Anteil am Gruppen-CC redesignt, steigt damit aus und bekommt +-- einen eigenen CC. Wohin er gewechselt ist, bleibt nachvollziehbar. +ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "successorId" INTEGER; + +CREATE INDEX IF NOT EXISTS "CharacterMember_successorId_idx" ON "CharacterMember"("successorId"); + +DO $$ +BEGIN + ALTER TABLE "CharacterMember" + ADD CONSTRAINT "CharacterMember_successorId_fkey" + FOREIGN KEY ("successorId") 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 b9b4f1b..97f82cc 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -241,6 +241,8 @@ model Character { members CharacterMember[] /// Beitritte, für die dieser CC aufgegeben wurde. givenUpFor CharacterMember[] @relation("MemberPredecessor") + /// Austritte, aus denen dieser CC hervorgegangen ist. + grewOutOf CharacterMember[] @relation("MemberSuccessor") createdById Int? createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) @@ -426,10 +428,18 @@ model CharacterMember { player Player @relation(fields: [playerId], references: [id], onDelete: Cascade) joinedAt DateTime @default(now()) - /// Gesetzt, sobald der Spieler ausgestiegen ist. Der Eintrag bleibt stehen, - /// damit nachvollziehbar ist, wer den CC wann genutzt hat. + /// Gesetzt, sobald der Spieler den CC nicht mehr nutzt - ob freiwillig + /// ausgetreten, geputzt oder im Rollenspiel gestorben. Der Eintrag bleibt + /// stehen, damit nachvollziehbar ist, wer den CC wann genutzt hat. leftAt DateTime? + /// Warum er nicht mehr dabei ist. Leer bei einem einfachen Austritt; bei + /// einem Gruppen-CC kann ein einzelner Spieler sterben oder geputzt werden, + /// während die übrigen weiterspielen. Die Rückkehr folgt denselben Regeln + /// wie bei einem eigenen CC. + inactiveReason InactiveReason? + inactiveNote String? + /// Womit der Platz bezahlt wurde. Leer beim Ersteller, dessen Buchung am /// CC selbst hängt. paidWith CreditKind? @@ -441,6 +451,12 @@ model CharacterMember { predecessorId Int? predecessor Character? @relation("MemberPredecessor", fields: [predecessorId], references: [id], onDelete: SetNull) + /// Der CC, in den der Spieler von hier aus gewechselt ist. Gesetzt, wenn er + /// seinen Anteil am Gruppen-CC redesignt hat und damit ausgestiegen ist - + /// die Gruppe läuft dabei unberührt weiter. + successorId Int? + successor Character? @relation("MemberSuccessor", fields: [successorId], 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 2f69160..35b1da7 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -2,6 +2,8 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { CreditKind, InactiveReason } from '@prisma/client'; import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js'; +import { forbidden } from '../errors.js'; +import { prisma } from '../db.js'; import { addMember, createCharacter, @@ -14,6 +16,9 @@ import { redesignCharacter, revertCharacter, updateCharacter, + deactivateMember, + reactivateMember, + redesignMembership, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -163,6 +168,109 @@ export async function characterRoutes(app: FastifyInstance): Promise { ); }); + /** + * Der Anteil eines Spielers an einem Gruppen-CC wird redesignt: er steigt + * aus, bekommt einen eigenen CC, die Gruppe läuft weiter. + */ + app.post( + '/api/characters/:id/members/:playerId/redesign', + { preHandler: requireEditor }, + async (request) => { + const params = parseWith( + z.object({ + id: z.coerce.number().int().positive(), + playerId: z.coerce.number().int().positive(), + }), + request.params, + ); + const body = parseWith( + z.object({ + name: z.string().trim().min(1).max(160), + loreText: z.string().trim().max(5000).optional(), + useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), + note: optionalNote, + }), + request.body, + ); + return redesignMembership(params.id, params.playerId, body, contextOf(request)); + }, + ); + + /** Ein einzelnes Mitglied fällt aus, der CC bleibt bestehen. */ + app.post( + '/api/characters/:id/members/:playerId/deactivate', + { preHandler: requireEditor }, + async (request) => { + const params = parseWith( + z.object({ + id: z.coerce.number().int().positive(), + playerId: z.coerce.number().int().positive(), + }), + request.params, + ); + const body = parseWith( + z.object({ + reason: z.enum([ + InactiveReason.PLAYER_INACTIVITY, + InactiveReason.OTHER, + InactiveReason.NO_FREE_CLAIM, + InactiveReason.DEAD, + ]), + note: optionalNote, + }), + request.body, + ); + + // Ein Tod im Rollenspiel ist eine Ausnahmeentscheidung, genau wie beim + // ganzen CC - und bleibt deshalb Administratoren vorbehalten. + if (body.reason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') { + throw forbidden('Einen Tod im Rollenspiel trägt nur ein Administrator ein'); + } + + return deactivateMember( + params.id, + params.playerId, + body.reason, + contextOf(request), + body.note, + ); + }, + ); + + /** Rückkehr eines ausgefallenen Mitglieds. */ + app.post( + '/api/characters/:id/members/:playerId/reactivate', + { preHandler: requireEditor }, + async (request) => { + const params = parseWith( + z.object({ + id: z.coerce.number().int().positive(), + playerId: z.coerce.number().int().positive(), + }), + request.params, + ); + const body = parseWith( + z.object({ + useKind: reviveKind.optional(), + reason: z.string().trim().max(500).optional(), + }), + request.body ?? {}, + ); + + // Wer im Rollenspiel gestorben ist, kehrt nur durch einen Administrator + // zurück. Geprüft wird am tatsächlichen Grund, nicht an der Eingabe. + const mitglied = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId: params.id, playerId: params.playerId } }, + select: { inactiveReason: true }, + }); + if (mitglied?.inactiveReason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') { + throw forbidden('Über die Rückkehr eines Gestorbenen entscheidet ein Administrator'); + } + + return reactivateMember(params.id, params.playerId, body, contextOf(request)); + }, + ); + app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => { const params = parseWith( z.object({ diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts index f858f47..6fcc06d 100644 --- a/server/src/routes/scraper.routes.ts +++ b/server/src/routes/scraper.routes.ts @@ -4,7 +4,9 @@ import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js'; import { notFound } from '../errors.js'; import { prisma } from '../db.js'; import { + getScrapeState, knownSteamIds, + startScrape, steamIdsOf, listUnmatched, resolveUnmatched, @@ -30,7 +32,11 @@ export async function scraperRoutes(app: FastifyInstance): Promise { ? all.filter((entry) => body.playerIds?.includes(entry.playerId)) : all; - return scrapePlayers(targets, contextOf(request)); + return startScrape(targets, contextOf(request)); + }); + + app.get('/api/scrape/status', { preHandler: requireAuth }, async () => { + return getScrapeState(); }); /** @@ -49,7 +55,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise { throw notFound('SteamID des Spielers'); } - return scrapePlayers( + return startScrape( steamIds.map((steamId) => ({ playerId: id, steamId })), contextOf(request), ); diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 15937af..92d8f86 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -524,10 +524,19 @@ export async function revertCharacter( /** * Die CCs eines Spielers. Maßgeblich ist die Mitgliedschaft, nicht wer den CC * angelegt hat - sonst fehlten Gruppen-CCs, denen der Spieler beigetreten ist. + * + * Mit dabei sind auch CCs, bei denen seine Mitgliedschaft ruht: wer geputzt + * wurde oder im Rollenspiel gestorben ist, kann zurückkehren und muss den CC + * dafür sehen. Wer freiwillig ausgetreten ist, sieht ihn nicht mehr - dort + * gibt es nichts zurückzuholen, ein Wiedereintritt kostet neu. */ export async function listCharacters(playerId: number) { return prisma.character.findMany({ - where: { members: { some: { playerId, leftAt: null } } }, + where: { + members: { + some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] }, + }, + }, orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], include: { predecessor: { select: { id: true, name: true } }, @@ -539,7 +548,10 @@ export async function listCharacters(playerId: number) { }, members: { orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], - include: { player: { select: { id: true, displayName: true } } }, + include: { + player: { select: { id: true, displayName: true } }, + successor: { select: { id: true, name: true } }, + }, }, }, }); @@ -800,6 +812,283 @@ export async function addMember( return { character, balances, overdrawn: isOverdrawn(balances), memberCount }; } +/** + * Redesignt den Anteil eines Spielers an einem Gruppen-CC. + * + * Er steigt damit aus und bekommt einen eigenen CC an dessen Stelle; die + * Gruppe läuft unberührt weiter. Für ihn ist es ein Redesign wie jedes + * andere: ein Platz wird getauscht, keiner kommt hinzu. + * + * Ist er der letzte Nutzer, gibt es keine Gruppe mehr, die weiterlaufen + * könnte - dann ist es das gewöhnliche Redesign des ganzen CCs, und der läuft + * über redesignCharacter(). Ein Gruppen-Redesign, bei dem alle mitwandern, + * ebenfalls: das ist die Sache des ganzen CCs, nicht die eines Mitglieds. + */ +export async function redesignMembership( + characterId: number, + playerId: number, + input: { + name: string; + loreText?: string | undefined; + useKind: RedesignKind; + note?: string | undefined; + }, + ctx: ActionContext, +): Promise { + const gruppe = await loadCharacter(characterId); + + if (gruppe.status !== CharacterStatus.ACTIVE) { + throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); + } + if (!REDESIGN_KINDS.includes(input.useKind)) { + throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); + } + const name = input.name.trim(); + if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht'); + + const mitglied = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId, playerId } }, + }); + if (!mitglied || mitglied.leftAt !== null) { + throw notFound('Mitgliedschaft'); + } + + const verbleibend = await prisma.characterMember.count({ + where: { characterId, leftAt: null }, + }); + if (verbleibend <= 1) { + throw conflict( + 'LAST_MEMBER', + 'Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.', + ); + } + + const neuerCC = await prisma.$transaction(async (tx) => { + const erstellt = await tx.character.create({ + data: { + playerId, + name, + loreText: input.loreText ?? null, + status: CharacterStatus.ACTIVE, + // Eine eigene Linie: der Gruppen-CC lebt weiter, hier beginnt ein + // neuer Strang. Woher er kommt, steht an der Mitgliedschaft. + predecessorId: gruppe.id, + createdById: ctx.actorId, + members: { create: [{ playerId, paidWith: input.useKind, createdById: ctx.actorId }] }, + }, + }); + + // Kein Grund für eine Rückkehr: sein Platz wurde getauscht, nicht + // entzogen. Wohin er gegangen ist, steht im Nachfolger - und genau daran + // hängt auch, dass der Gruppen-CC nicht länger als seiner gilt. + await tx.characterMember.update({ + where: { id: mitglied.id }, + data: { + leftAt: new Date(), + successorId: erstellt.id, + note: input.note ?? `Anteil redesignt zu "${erstellt.name}"`, + }, + }); + + await tx.creditLedger.create({ + data: { + playerId, + kind: input.useKind, + amount: -1, + source: CreditSource.CONSUME, + groupId: randomUUID(), + characterId: erstellt.id, + note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, + createdById: ctx.actorId, + }, + }); + + await assertSlotsReichen(tx, playerId, 'Dieses Redesign'); + + await recordAudit(tx, ctx, { + action: 'character.membership_redesigned', + targetType: 'character', + targetId: erstellt.id, + details: { + playerId, + from: gruppe.name, + fromId: gruppe.id, + to: erstellt.name, + useKind: input.useKind, + remaining: verbleibend - 1, + }, + }); + + return erstellt; + }); + + const balances = await getBalances(playerId); + return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true }; +} + +/** + * Setzt eine einzelne Mitgliedschaft inaktiv - der CC selbst bleibt, wie er + * ist. Bei einem Gruppen-CC kann genau ein Spieler wegfallen, ohne dass die + * anderen etwas davon merken: einer stirbt im Rollenspiel, einer wird + * geputzt, die übrigen spielen weiter. + * + * Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC - gratis, + * bezahlt oder nur durch einen Administrator. + */ +export async function deactivateMember( + characterId: number, + playerId: number, + reason: InactiveReason, + ctx: ActionContext, + note?: string, +): Promise<{ memberCount: number }> { + if (reason === InactiveReason.REWORKED) { + throw badRequest( + 'USE_REDESIGN', + 'Ein Rework entsteht über das Redesign des Anteils, nicht über eine Deaktivierung', + ); + } + if (reason === InactiveReason.DEAD && !note?.trim()) { + throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht'); + } + + const mitglied = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId, playerId } }, + include: { character: { select: { name: true } } }, + }); + if (!mitglied || mitglied.leftAt !== null) { + throw notFound('Mitgliedschaft'); + } + + const verbleibend = await prisma.characterMember.count({ + where: { characterId, leftAt: null }, + }); + if (verbleibend <= 1) { + throw conflict( + 'LAST_MEMBER', + 'Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.', + ); + } + + await prisma.$transaction(async (tx) => { + await tx.characterMember.update({ + where: { id: mitglied.id }, + data: { + leftAt: new Date(), + inactiveReason: reason, + inactiveNote: note?.trim() || null, + }, + }); + + await recordAudit(tx, ctx, { + action: 'character.member_deactivated', + targetType: 'character', + targetId: characterId, + details: { playerId, name: mitglied.character.name, reason, note: note ?? null }, + }); + }); + + return { memberCount: verbleibend - 1 }; +} + +/** + * Holt eine einzelne Mitgliedschaft zurück. Die Regeln sind dieselben wie bei + * einem eigenen CC: was unverändert weiterbesteht, kommt gratis zurück; was + * ersetzt oder entzogen wurde, kostet; und über einen im Rollenspiel + * Gestorbenen entscheidet ein Administrator. + */ +export async function reactivateMember( + characterId: number, + playerId: number, + input: { useKind?: ReviveKind | undefined; reason?: string | undefined }, + ctx: ActionContext, +): Promise<{ memberCount: number }> { + const gruppe = await loadCharacter(characterId); + if (gruppe.status !== CharacterStatus.ACTIVE) { + throw conflict('NOT_ACTIVE', 'Dieser CC ist selbst inaktiv - erst ihn zurückholen'); + } + + const mitglied = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId, playerId } }, + }); + if (!mitglied) throw notFound('Mitgliedschaft'); + if (mitglied.leftAt === null) { + throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits'); + } + + const grund = mitglied.inactiveReason; + + // Dass hierfür ein Administrator nötig ist, sichert die Route ab - so wie + // bei einem im Rollenspiel gestorbenen CC. Hier steht nur, was der Vorgang + // selbst verlangt. + if (grund === InactiveReason.DEAD && !input.reason?.trim()) { + throw badRequest( + 'NEED_REASON', + 'Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht', + ); + } + + // Ein Austritt ohne Grund war freiwillig: der Platz ist weg und muss neu + // bezahlt werden. Dafür gibt es den gewöhnlichen Beitritt. + if (grund === null) { + throw conflict( + 'WAS_VOLUNTARY', + 'Dieser Spieler ist ausgetreten, nicht ausgefallen. Eine Rückkehr ist ein neuer Beitritt und kostet ein Guthaben.', + ); + } + + const kostenpflichtig = + grund === InactiveReason.REWORKED || grund === InactiveReason.NO_FREE_CLAIM; + if (kostenpflichtig && input.useKind === undefined) { + throw badRequest('NEED_KIND', 'Diese Rückkehr kostet ein Guthaben - bitte angeben, welches'); + } + if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) { + throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); + } + + await prisma.$transaction(async (tx) => { + await tx.characterMember.update({ + where: { id: mitglied.id }, + data: { leftAt: null, inactiveReason: null, inactiveNote: null }, + }); + + if (input.useKind !== undefined) { + await tx.creditLedger.create({ + data: { + playerId, + kind: input.useKind, + amount: -1, + source: CreditSource.CONSUME, + groupId: randomUUID(), + characterId, + note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, + createdById: ctx.actorId, + }, + }); + } + + await assertSlotsReichen(tx, playerId, 'Die Rückkehr'); + + await recordAudit(tx, ctx, { + action: 'character.member_reactivated', + targetType: 'character', + targetId: characterId, + details: { + playerId, + name: gruppe.name, + previousReason: grund, + useKind: input.useKind ?? null, + reason: input.reason ?? null, + }, + }); + }); + + const memberCount = await prisma.characterMember.count({ + where: { characterId, leftAt: null }, + }); + return { memberCount }; +} + /** * Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht - * wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die @@ -851,6 +1140,10 @@ export async function listMembers(characterId: number) { return prisma.characterMember.findMany({ where: { characterId }, orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], - include: { player: { select: { id: true, displayName: true, steamId: true } } }, + include: { + player: { select: { id: true, displayName: true, steamId: true } }, + successor: { select: { id: true, name: true } }, + predecessor: { select: { id: true, name: true } }, + }, }); } diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index 53c65f5..f652c22 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -165,34 +165,22 @@ export function getScanState(): ScanState { return { ...scanState }; } -/** Stößt einen Lauf an und kehrt sofort zurück. */ +/** + * Stößt einen Lauf an und kehrt sofort zurück. + * + * Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied: + * die Sperre gegen zwei gleichzeitige Läufe sitzt in scanForum selbst. Zwei + * Läufe nebeneinander würden dieselben Seiten doppelt abrufen - und das + * Forum bremst schon bei einem. + */ export function startScan(ctx: ActionContext): ScanState { if (scanState.running) { throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich'); } - scanState.running = true; - scanState.startedAt = new Date().toISOString(); - scanState.finishedAt = null; - scanState.currentBoard = null; - scanState.pagesRead = 0; - scanState.totalPages = 0; - scanState.log = []; - scanState.summary = null; - scanState.error = null; - - void scanForum(ctx) - .then((summary) => { - scanState.summary = summary; - }) - .catch((error: unknown) => { - scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen'; - }) - .finally(() => { - scanState.running = false; - scanState.currentBoard = null; - scanState.finishedAt = new Date().toISOString(); - }); + void scanForum(ctx).catch(() => { + // Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet. + }); return getScanState(); } @@ -203,6 +191,35 @@ export function startScan(ctx: ActionContext): ScanState { * eine Buchung abzuleiten. Jeder neue Thread wartet auf eine Entscheidung. */ export async function scanForum(ctx: ActionContext): Promise { + if (scanState.running) { + throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich'); + } + + // Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten + // await am Stück, deshalb kann sich kein zweiter dazwischenschieben. + scanState.running = true; + scanState.startedAt = new Date().toISOString(); + scanState.finishedAt = null; + scanState.currentBoard = null; + scanState.pagesRead = 0; + scanState.totalPages = 0; + scanState.log = []; + scanState.summary = null; + scanState.error = null; + + try { + return await scanIntern(ctx); + } catch (error) { + scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen'; + throw error; + } finally { + scanState.running = false; + scanState.currentBoard = null; + scanState.finishedAt = new Date().toISOString(); + } +} + +async function scanIntern(ctx: ActionContext): Promise { const summary: ForumScanSummary = { scannedBoards: 0, scannedPages: 0, @@ -411,6 +428,8 @@ export async function scanForum(ctx: ActionContext): Promise { : 'Keine neuen Anträge', ); + scanState.summary = summary; + await recordAudit(prisma, ctx, { action: 'forum.scan', targetType: 'forum', diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index 212891d..539e9d6 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -1,7 +1,7 @@ import { ImportStatus, type Prisma } from '@prisma/client'; import { prisma } from '../db.js'; import { env } from '../env.js'; -import { badRequest } from '../errors.js'; +import { badRequest, conflict } from '../errors.js'; import { classifyPackage, fetchProfile, @@ -81,6 +81,73 @@ export interface ScrapeSummary { // in die Quere kommen. const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`; +export interface ScrapeState { + running: boolean; + startedAt: string | null; + finishedAt: string | null; + /// Wessen Profil gerade gelesen wird. + currentPlayer: string | null; + playersRead: number; + totalPlayers: number; + log: string[]; + summary: ScrapeSummary | null; + error: string | null; +} + +/** + * Ein Abgleich über alle Spieler geht über dutzende Profile, zwischen denen + * bewusst gewartet wird - das überlebt keine HTTP-Anfrage. Deshalb läuft er + * im Hintergrund, und die Oberfläche fragt den Stand ab. + */ +const scrapeState: ScrapeState = { + running: false, + startedAt: null, + finishedAt: null, + currentPlayer: null, + playersRead: 0, + totalPlayers: 0, + log: [], + summary: null, + error: null, +}; + +const MAX_LOG_LINES = 200; + +function protokoll(zeile: string): void { + scrapeState.log.push(zeile); + if (scrapeState.log.length > MAX_LOG_LINES) scrapeState.log.shift(); +} + +export function getScrapeState(): ScrapeState { + return { ...scrapeState }; +} + +/** + * Stößt einen Abgleich an und kehrt sofort zurück. + * + * Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied: + * die Sperre gegen zwei gleichzeitige Läufe sitzt in scrapePlayers selbst. + * Zwei Läufe nebeneinander würden dieselben Profile doppelt abrufen und den + * fremden Server ohne Not belasten. + */ +export function startScrape( + targets: { playerId: number; steamId: string }[], + ctx: ActionContext, +): ScrapeState { + if (scrapeState.running) { + throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich'); + } + if (targets.length === 0) { + throw badRequest('NO_TARGETS', 'Es gibt niemanden abzugleichen'); + } + + void scrapePlayers(targets, ctx).catch(() => { + // Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet. + }); + + return getScrapeState(); +} + /** * Holt die Profile der übergebenen Spieler und legt daraus einen Import an, * der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts @@ -89,6 +156,38 @@ const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`; export async function scrapePlayers( targets: { playerId: number; steamId: string }[], ctx: ActionContext, +): Promise { + if (scrapeState.running) { + throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich'); + } + + // Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten + // await am Stück, deshalb kann sich kein zweiter dazwischenschieben. + scrapeState.running = true; + scrapeState.startedAt = new Date().toISOString(); + scrapeState.finishedAt = null; + scrapeState.currentPlayer = null; + scrapeState.playersRead = 0; + scrapeState.totalPlayers = targets.length; + scrapeState.log = []; + scrapeState.summary = null; + scrapeState.error = null; + + try { + return await scrapeIntern(targets, ctx); + } catch (error) { + scrapeState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen'; + throw error; + } finally { + scrapeState.running = false; + scrapeState.currentPlayer = null; + scrapeState.finishedAt = new Date().toISOString(); + } +} + +async function scrapeIntern( + targets: { playerId: number; steamId: string }[], + ctx: ActionContext, ): Promise { const summary: ScrapeSummary = { importId: null, @@ -117,16 +216,30 @@ export async function scrapePlayers( }); const alreadyPending = new Set(pending.map((row) => row.externalId as string)); + // Die Namen einmal holen, damit das Protokoll lesbar ist statt SteamIDs + // aneinanderzureihen. + const namen = new Map( + ( + await prisma.player.findMany({ + where: { id: { in: [...new Set(targets.map((t) => t.playerId))] } }, + select: { id: true, displayName: true }, + }) + ).map((p) => [p.id, p.displayName]), + ); + for (const target of targets) { + const name = namen.get(target.playerId) ?? target.steamId; + scrapeState.currentPlayer = name; + let profile: ScrapedProfile; try { profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL }); summary.scannedPlayers += 1; } catch (error) { - summary.failedPlayers.push({ - steamId: target.steamId, - reason: error instanceof Error ? error.message : 'Unbekannter Fehler', - }); + const grund = error instanceof Error ? error.message : 'Unbekannter Fehler'; + summary.failedPlayers.push({ steamId: target.steamId, reason: grund }); + scrapeState.playersRead += 1; + protokoll(`${name}: nicht abrufbar (${grund})`); continue; } @@ -204,8 +317,22 @@ export async function scrapePlayers( where: { id: target.playerId }, data: { gextensionSyncedAt: new Date() }, }); + + scrapeState.playersRead += 1; + protokoll( + `${name}: ${profile.packages.length} ${ + profile.packages.length === 1 ? 'Eintrag' : 'Einträge' + } gelesen`, + ); } + scrapeState.currentPlayer = null; + protokoll( + summary.newRows > 0 + ? `${summary.newRows} neue Pakete warten auf die Bestätigung` + : 'Keine neuen Pakete', + ); + if (rows.length > 0) { const created = await prisma.import.create({ data: { @@ -220,6 +347,8 @@ export async function scrapePlayers( summary.importId = created.id; } + scrapeState.summary = summary; + await recordAudit(prisma, ctx, { action: 'scrape.run', targetType: 'import', diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index 9f2dd73..db38056 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -440,3 +440,248 @@ describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => { ); }); }); + +describe('Aus einem Gruppen-CC herausredesignen', () => { + /** Ein Gruppen-CC mit drei Nutzern, jeder mit eigenem Paket bezahlt. */ + async function dreierGruppe(praefix: string) { + const chef = await spielerMitPaket(`${praefix}-Chef`); + const zwei = await spielerMitPaket(`${praefix}-Zwei`); + const drei = await spielerMitPaket(`${praefix}-Drei`); + const { character } = await characters.createCharacter(chef, { name: `${praefix}-Bande` }, ctx); + await characters.addMember(character.id, zwei, { useKind: 'CC' }, ctx); + await characters.addMember(character.id, drei, { useKind: 'CC' }, ctx); + return { chef, zwei, drei, character }; + } + + it('lässt ein Mitglied aussteigen, ohne die Gruppe anzutasten', async () => { + const { zwei, drei, chef, character } = await dreierGruppe('Austritt'); + + const ergebnis = await characters.redesignMembership( + character.id, + zwei, + { name: 'Eigener Weg', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + assert.equal(ergebnis.character.name, 'Eigener Weg'); + assert.equal(ergebnis.character.status, 'ACTIVE'); + assert.equal(ergebnis.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht'); + + // Die Gruppe läuft unberührt weiter. + const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } }); + assert.equal(gruppe.status, 'ACTIVE'); + + const dabei = await db.prisma.characterMember.findMany({ + where: { characterId: character.id, leftAt: null }, + select: { playerId: true }, + }); + assert.deepEqual( + dabei.map((m) => m.playerId).sort((a, b) => a - b), + [chef, drei].sort((a, b) => a - b), + 'die beiden anderen bleiben', + ); + }); + + it('hält fest, wohin der Ausgestiegene gewechselt ist', async () => { + const { zwei, character } = await dreierGruppe('Spur'); + + const ergebnis = await characters.redesignMembership( + character.id, + zwei, + { name: 'Nachfolger', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const mitglied = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: zwei }, + }); + assert.equal(mitglied.successorId, ergebnis.character.id); + assert.equal( + mitglied.inactiveReason, + null, + 'sein Platz wurde getauscht, nicht entzogen - es gibt nichts zurückzuholen', + ); + assert.equal(ergebnis.character.predecessorId, character.id, 'die Herkunft bleibt sichtbar'); + }); + + it('erschleicht dabei keinen Platz', async () => { + const { zwei, character } = await dreierGruppe('Platz'); + + await characters.redesignMembership( + character.id, + zwei, + { name: 'Getauscht', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const aktive = (await characters.listCharacters(zwei)).filter((c) => c.status === 'ACTIVE'); + assert.deepEqual( + aktive.map((c) => c.name), + ['Getauscht'], + 'ein Paket, ein aktiver CC - der Gruppenplatz ist dafür weg', + ); + }); + + it('weist den letzten Nutzer auf das gewöhnliche Redesign hin', async () => { + const allein = await spielerMitPaket('Einzelgänger'); + const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx); + + await assert.rejects( + () => + characters.redesignMembership( + character.id, + allein, + { name: 'Solo II', useKind: 'REDESIGN_PAID' }, + ctx, + ), + (error: Error & { code?: string }) => error.code === 'LAST_MEMBER', + ); + }); + + it('lässt das Redesign der ganzen Gruppe unberührt weiter zu', async () => { + const { chef, zwei, drei, character } = await dreierGruppe('Gemeinsam'); + + const neu = await characters.redesignCharacter( + character.id, + { name: 'Die Bande 2.0', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const dabei = await db.prisma.characterMember.findMany({ + where: { characterId: neu.character.id, leftAt: null }, + select: { playerId: true }, + }); + assert.deepEqual( + dabei.map((m) => m.playerId).sort((a, b) => a - b), + [chef, zwei, drei].sort((a, b) => a - b), + 'wer dabei war, ist auch danach dabei', + ); + assert.equal(neu.predecessor.inactiveReason, 'REWORKED'); + }); +}); + +describe('Einzelne Mitglieder fallen aus', () => { + async function paar(praefix: string) { + const chef = await spielerMitPaket(`${praefix}-A`); + const partner = await spielerMitPaket(`${praefix}-B`); + const { character } = await characters.createCharacter(chef, { name: `${praefix}-Duo` }, ctx); + await characters.addMember(character.id, partner, { useKind: 'CC' }, ctx); + return { chef, partner, character }; + } + + it('lässt einen einzelnen Nutzer sterben, während der CC weiterläuft', async () => { + const { chef, partner, character } = await paar('Tod'); + + await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'Von SCP-096 erwischt'); + + const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } }); + assert.equal(gruppe.status, 'ACTIVE', 'der CC selbst lebt weiter'); + + const uebrig = await db.prisma.characterMember.findMany({ + where: { characterId: character.id, leftAt: null }, + }); + assert.deepEqual(uebrig.map((m) => m.playerId), [chef]); + + const toter = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: partner }, + }); + assert.equal(toter.inactiveReason, 'DEAD'); + assert.equal(toter.inactiveNote, 'Von SCP-096 erwischt'); + }); + + it('zeigt dem Ausgefallenen seinen CC weiter an', async () => { + const { partner, character } = await paar('Sichtbar'); + await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx); + + const seine = await characters.listCharacters(partner); + assert.ok( + seine.some((c) => c.id === character.id), + 'sonst könnte er nie zurückkehren', + ); + }); + + it('verlangt für einen Tod eine Begründung', async () => { + const { partner, character } = await paar('Begruendung'); + await assert.rejects( + () => characters.deactivateMember(character.id, partner, 'DEAD', ctx), + (error: Error & { code?: string }) => error.code === 'NEED_NOTE', + ); + }); + + it('holt einen geputzten Nutzer gratis zurück', async () => { + const { partner, character } = await paar('Gratis'); + await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx); + + const vorher = await credits.getBalances(partner); + await characters.reactivateMember(character.id, partner, {}, ctx); + + const dabei = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: partner }, + }); + assert.equal(dabei.leftAt, null); + assert.equal(dabei.inactiveReason, null); + assert.deepEqual(await credits.getBalances(partner), vorher, 'gratis heißt gratis'); + }); + + it('lässt einen entzogenen Platz nur gegen Guthaben zurückkommen', async () => { + const { partner, character } = await paar('Entzogen'); + await characters.deactivateMember(character.id, partner, 'NO_FREE_CLAIM', ctx, 'Regelverstoß'); + + await assert.rejects( + () => characters.reactivateMember(character.id, partner, {}, ctx), + (error: Error & { code?: string }) => error.code === 'NEED_KIND', + ); + + const vorher = await credits.getBalances(partner); + await characters.reactivateMember(character.id, partner, { useKind: 'REDESIGN_PAID' }, ctx); + + assert.equal( + (await credits.getBalances(partner)).REDESIGN_PAID, + vorher.REDESIGN_PAID - 1, + 'bezahlt', + ); + }); + + it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => { + const { partner, character } = await paar('Rueckkehr'); + await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'gestorben'); + + await assert.rejects( + () => characters.reactivateMember(character.id, partner, {}, ctx), + (error: Error & { code?: string }) => error.code === 'NEED_REASON', + ); + + await characters.reactivateMember( + character.id, + partner, + { reason: 'Klon aus der Lagerhalle' }, + ctx, + ); + const dabei = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: partner }, + }); + assert.equal(dabei.leftAt, null); + }); + + it('macht aus einem freiwilligen Austritt keine Rückkehr auf Kosten des Hauses', async () => { + const { partner, character } = await paar('Freiwillig'); + await characters.removeMember(character.id, partner, 'Keine Lust mehr', ctx); + + await assert.rejects( + () => characters.reactivateMember(character.id, partner, {}, ctx), + (error: Error & { code?: string }) => error.code === 'WAS_VOLUNTARY', + 'wer freiwillig geht, tritt neu bei und zahlt neu', + ); + }); + + it('lässt den letzten Nutzer nicht einzeln ausfallen', async () => { + const allein = await spielerMitPaket('Letzter'); + const { character } = await characters.createCharacter(allein, { name: 'Alleingang' }, ctx); + + await assert.rejects( + () => characters.deactivateMember(character.id, allein, 'PLAYER_INACTIVITY', ctx), + (error: Error & { code?: string }) => error.code === 'LAST_MEMBER', + 'dann ist der ganze CC betroffen', + ); + }); +}); diff --git a/server/test/zeitplan.test.ts b/server/test/zeitplan.test.ts index 83e49d7..5dd0f5e 100644 --- a/server/test/zeitplan.test.ts +++ b/server/test/zeitplan.test.ts @@ -3,10 +3,14 @@ import { after, before, describe, it } from 'node:test'; import { startTestDatabase, type TestDatabase } from './harness.js'; type Schedule = typeof import('../src/services/schedule.js'); +type Scraper = typeof import('../src/services/scraper.js'); +type Forum = typeof import('../src/services/forum.js'); type Db = typeof import('../src/db.js'); let database: TestDatabase; let schedule: Schedule; +let scraper: Scraper; +let forum: Forum; let db: Db; const JETZT = new Date('2026-09-21T12:00:00Z'); @@ -22,6 +26,8 @@ before(async () => { db = await import('../src/db.js'); schedule = await import('../src/services/schedule.js'); + scraper = await import('../src/services/scraper.js'); + forum = await import('../src/services/forum.js'); }); after(async () => { @@ -166,3 +172,36 @@ describe('Der nächtliche Forumslauf', () => { assert.equal(schedule.forumLaufFaellig(heuteFrueh, nachStart, nacht), false); }); }); + +describe('Zwei Läufe zugleich', () => { + it('lässt einen zweiten GExtension-Abgleich nicht daneben laufen', async () => { + // Ein Ziel, das es nicht gibt: der Lauf beginnt, scheitert am Abruf und + // endet - aber solange er läuft, ist die Tür zu. + const ziele = [{ playerId: 1, steamId: '76561198000029999' }]; + const ctx = { actorId: null, actorName: 'Test' }; + + const laufend = scraper.scrapePlayers(ziele, ctx); + assert.equal(scraper.getScrapeState().running, true); + + await assert.rejects( + () => scraper.scrapePlayers(ziele, ctx), + (error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING', + 'sonst riefen beide dieselben Profile ab', + ); + assert.throws( + () => scraper.startScrape(ziele, ctx), + (error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING', + ); + + await laufend.catch(() => {}); + assert.equal(scraper.getScrapeState().running, false, 'danach ist wieder offen'); + }); + + it('gilt für den Zeitplan genauso wie für einen Klick', () => { + // Beide Wege gehen durch dieselbe Funktion - der Zeitplan ruft + // scanForum direkt, die Oberfläche startScan. Die Sperre sitzt deshalb + // unten, nicht im Starter. + assert.equal(typeof forum.scanForum, 'function'); + assert.equal(forum.getScanState().running, false); + }); +}); diff --git a/web/src/components/RunProgress.tsx b/web/src/components/RunProgress.tsx new file mode 100644 index 0000000..0eec85e --- /dev/null +++ b/web/src/components/RunProgress.tsx @@ -0,0 +1,54 @@ +/** + * Fortschritt eines laufenden Abgleichs. + * + * Steht der Umfang noch nicht fest - beim Forum, solange nicht jedes Board + * seine Seitenzahl gemeldet hat -, läuft der Balken durch, statt einen + * erfundenen Anteil zu behaupten. + */ +export default function RunProgress({ + done, + total, + current, + log, + hint, +}: { + done: number; + total: number; + /// Woran gerade gearbeitet wird, fertig formuliert. + current: string | null; + log: string[]; + hint: string; +}) { + const bekannt = total > 0; + const anteil = bekannt ? Math.min(100, Math.round((done / total) * 100)) : 0; + const letzte = log.slice(-6); + + return ( +
+
+ {bekannt ? `${done} von ${total}` : `Umfang wird ermittelt … (${done})`} + + {current ?? 'wird gespeichert'} + {bekannt && ` · ${anteil} %`} + +
+ +
+
+
+ + {letzte.length > 0 && ( +
    + {letzte.map((zeile, index) => ( +
  • {zeile}
  • + ))} +
+ )} + +

{hint}

+
+ ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 85e5165..7c239f8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -113,6 +113,12 @@ export interface CharacterMember { paidWith: CreditKind | null; /// Der eigene CC, den der Spieler für den Platz aufgegeben hat. predecessorId: number | null; + /// Der CC, in den er von hier aus gewechselt ist. + successorId: number | null; + successor?: CharacterRef | null; + /// Warum er den CC nicht mehr nutzt. Leer bei einem freiwilligen Austritt. + inactiveReason: InactiveReason | null; + inactiveNote: string | null; note: string | null; } @@ -347,3 +353,15 @@ export interface PendingOverview { withoutPlayer: number; players: { playerId: number; displayName: string; count: number }[]; } + +export interface ScrapeState { + running: boolean; + startedAt: string | null; + finishedAt: string | null; + currentPlayer: string | null; + playersRead: number; + totalPlayers: number; + log: string[]; + summary: ScrapeSummary | null; + error: string | null; +} diff --git a/web/src/lib/useScrape.ts b/web/src/lib/useScrape.ts new file mode 100644 index 0000000..2204ea2 --- /dev/null +++ b/web/src/lib/useScrape.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ApiError, api } from './api'; +import type { ScrapeState, ScrapeSummary } from './types'; + +/** Was am Ende eines Laufs in der Meldung steht. */ +export function scrapeBericht(result: ScrapeSummary): string { + const parts = [`${result.scannedPlayers} Profile geprüft`]; + if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`); + if (result.awaitingConfirmation > 0) parts.push(`${result.awaitingConfirmation} warten schon`); + if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`); + if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); + if (result.failedPlayers.length > 0) { + parts.push(`${result.failedPlayers.length} nicht erreichbar`); + } + + const offen = result.newRows + result.awaitingConfirmation; + return ( + parts.join(', ') + + (offen > 0 ? '. Beim Spieler oder unter Import buchen, damit die Guthaben entstehen.' : '.') + ); +} + +/** + * Stößt einen GExtension-Abgleich an und verfolgt ihn. + * + * Der Lauf geht über dutzende Profile, zwischen denen gewartet wird - er + * läuft deshalb im Hintergrund weiter, auch wenn die Seite gewechselt wird. + * Hier wird nur nachgefragt, wie weit er ist. + */ +export function useScrape(options: { + onDone: (bericht: string) => void; + onError: (message: string) => void; + onFinished?: () => void | Promise; +}) { + const { onDone, onError, onFinished } = options; + const [state, setState] = useState(null); + const [running, setRunning] = useState(false); + + // Ein Lauf aus einer anderen Sitzung - oder vom Zeitplan - soll hier + // ebenfalls sichtbar sein. + useEffect(() => { + void (async () => { + try { + const aktuell = await api('/api/scrape/status'); + setState(aktuell); + if (aktuell.running) setRunning(true); + } catch { + // Ohne Stand bleibt der Knopf einfach bedienbar. + } + })(); + }, []); + + useEffect(() => { + if (!running) return; + + const abfragen = async () => { + try { + const aktuell = await api('/api/scrape/status'); + setState(aktuell); + if (aktuell.running) return; + + setRunning(false); + if (aktuell.error) onError(aktuell.error); + else if (aktuell.summary) onDone(scrapeBericht(aktuell.summary)); + await onFinished?.(); + } catch (err) { + setRunning(false); + onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } + }; + + const timer = window.setInterval(() => void abfragen(), 1500); + return () => window.clearInterval(timer); + }, [running, onDone, onError, onFinished]); + + const start = useCallback( + async (pfad: string) => { + try { + setState(await api(pfad, { method: 'POST', body: {} })); + setRunning(true); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } + }, + [onError], + ); + + return { state, running, start }; +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 9a1ba1e..713ff1a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -3,11 +3,12 @@ import { Link, useSearchParams } from 'react-router-dom'; import Modal from '../components/Modal'; import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; +import RunProgress from '../components/RunProgress'; +import { useScrape } from '../lib/useScrape'; import { useSession } from '../lib/session'; import type { PendingOverview, PlayerSummary, - ScrapeSummary, UnmatchedOverview, } from '../lib/types'; @@ -41,7 +42,6 @@ export default function Dashboard() { withoutPlayer: 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); @@ -79,33 +79,19 @@ export default function Dashboard() { void loadFlagged(); }, [loadFlagged]); - const runSync = async () => { - setSyncing(true); - setSyncResult(null); - try { - const result = await api('/api/scrape/run', { method: 'POST', body: {} }); - const parts = [`${result.scannedPlayers} Profile geprüft`]; - if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`); - if (result.awaitingConfirmation > 0) { - parts.push(`${result.awaitingConfirmation} warten schon`); - } - if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`); - if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); - if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`); + const nachlesen = useCallback(async () => { + await Promise.all([load(query, nurProbleme), loadFlagged()]); + }, [load, loadFlagged, query, nurProbleme]); - const offen = result.newRows + result.awaitingConfirmation; - setSyncResult( - parts.join(', ') + - (offen > 0 - ? '. Unter Import bestätigen, damit die Guthaben entstehen.' - : '.'), - ); - await Promise.all([load(query, nurProbleme), loadFlagged()]); - } catch (err) { - setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); - } finally { - setSyncing(false); - } + const scrape = useScrape({ + onDone: (bericht) => setSyncResult(bericht), + onError: setError, + onFinished: nachlesen, + }); + + const runSync = async () => { + setSyncResult(null); + await scrape.start('/api/scrape/run'); }; // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst. @@ -124,8 +110,15 @@ export default function Dashboard() {

Spieler

{can('EDITOR') && ( - )} {can('EDITOR') && ( @@ -157,6 +150,16 @@ export default function Dashboard() { {error && {error}} {syncResult && setSyncResult(null)}>{syncResult}} + {scrape.running && ( + + )} + {offenePakete.total > 0 && ( diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx index a40888b..d47f2ce 100644 --- a/web/src/pages/Forum.tsx +++ b/web/src/pages/Forum.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import Notice from '../components/Notice'; +import RunProgress from '../components/RunProgress'; import { AssignModal, ClassifyModal } from '../components/ThreadModals'; import { ApiError, api } from '../lib/api'; import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format'; @@ -232,7 +233,19 @@ export default function Forum() { {error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} - {busy && } + {busy && ( + + )} {notices.length > 0 && (
@@ -545,54 +558,3 @@ export default function Forum() {
); } - -/** - * Was der Abgleich gerade tut. Der Umfang steht erst fest, wenn von jedem - * Board die erste Seite gelesen ist - bis dahin läuft der Balken durch, - * statt einen erfundenen Anteil zu behaupten. - */ -function ScanProgress({ state }: { state: ScanState | null }) { - const gelesen = state?.pagesRead ?? 0; - const gesamt = state?.totalPages ?? 0; - const bekannt = gesamt > 0; - const anteil = bekannt ? Math.min(100, Math.round((gelesen / gesamt) * 100)) : 0; - const letzte = state?.log.slice(-6) ?? []; - - return ( -
-
- - {bekannt - ? `${gelesen} von ${gesamt} Seiten gelesen` - : `Umfang wird ermittelt … (${gelesen} Seiten)`} - - - {state?.currentBoard - ? `liest ${BOARD_PFAD[state.currentBoard] ?? state.currentBoard}` - : 'wird gespeichert'} - {bekannt && ` · ${anteil} %`} - -
- -
-
-
- - {letzte.length > 0 && ( -
    - {letzte.map((zeile, index) => ( -
  • {zeile}
  • - ))} -
- )} - -

- Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der - Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird. -

-
- ); -} diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index e3d5a52..0a9348d 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -16,19 +16,27 @@ import { formatDate, rueckkehrArt, } from '../lib/format'; +import RunProgress from '../components/RunProgress'; +import { useScrape } from '../lib/useScrape'; import { useSession } from '../lib/session'; import type { Character, + CharacterMember, Player, PlayerSummary, CreditKind, LedgerEntry, PlayerDetail as Detail, - ScrapeSummary, } from '../lib/types'; type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD'; +/// Ein CC mit mehr als einem aktiven Nutzer. Dort gibt es zwei Ebenen: den CC +/// als Ganzes und den Anteil eines Einzelnen. +function istGruppe(character: Character): boolean { + return (character.members ?? []).filter((m) => !m.leftAt).length > 1; +} + /** * Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt * dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei. @@ -63,7 +71,9 @@ type DialogState = | { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' } | { type: 'addMember'; character: Character } | { type: 'reviveDead'; character: Character } - | { type: 'problem' }; + | { type: 'problem' } + | { type: 'memberRedesign'; character: Character } + | { type: 'memberDeactivate'; character: Character }; export default function PlayerDetail() { const { id } = useParams<{ id: string }>(); @@ -110,42 +120,22 @@ export default function PlayerDetail() { }; // Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung - // auch sagen, sonst wirkt der Knopf wirkungslos. - const syncFromGextension = async () => { - setBusy(true); - setError(null); - try { - const result = await api(`/api/scrape/player/${playerId}`, { - method: 'POST', - body: {}, - }); - await load(); + // auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten + // des Spielers, deshalb kann auch ein einzelner Lauf dauern. + const melden = useCallback((text: string) => { + setFlash(text); + window.setTimeout(() => setFlash(null), 8000); + }, []); - if (result.newRows > 0) { - setFlash( - `${result.newRows} ${result.newRows === 1 ? 'Paket' : 'Pakete'} gefunden. ` + - 'Unter Import bestätigen, damit die Guthaben entstehen.', - ); - } else if (result.awaitingConfirmation > 0) { - setFlash( - `${result.awaitingConfirmation} ${result.awaitingConfirmation === 1 ? 'Paket wartet' : 'Pakete warten'} ` + - 'bereits unter Import auf Bestätigung.', - ); - } else if (result.unclear > 0) { - setFlash(`${result.unclear} unklare Einträge - siehe Kasten oben.`); - } else if (result.duplicates > 0) { - setFlash('Nichts Neues - alle Pakete sind bereits gebucht.'); - } else if (result.failedPlayers.length > 0) { - setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`); - } else { - setFlash('Keine CC-relevanten Pakete im Profil gefunden.'); - } - window.setTimeout(() => setFlash(null), 8000); - } catch (err) { - setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); - } finally { - setBusy(false); - } + const scrape = useScrape({ + onDone: melden, + onError: setError, + onFinished: load, + }); + + const syncFromGextension = async () => { + setError(null); + await scrape.start(`/api/scrape/player/${playerId}`); }; if (loading) return

Lade …

; @@ -156,6 +146,8 @@ export default function PlayerDetail() { const active = detail.characters.filter((c) => c.status === 'ACTIVE'); const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); + const playerName = detail.player.displayName; + return (
@@ -203,6 +195,16 @@ export default function PlayerDetail() { {error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} + {scrape.running && ( + + )} + {detail.player.isProblem && ( Offener Problemfall.{' '} @@ -285,11 +287,11 @@ export default function PlayerDetail() { )} @@ -325,13 +327,42 @@ export default function PlayerDetail() {
{editor && (
- + {istGruppe(character) ? ( + <> + + + + + ) : ( + + )} + )} + + ))} + {ehemalig.length > 0 && ( - früher: {ehemalig.map((m) => m.player.displayName).join(', ')} + früher:{' '} + {ehemalig + .map((m) => + m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName, + ) + .join(', ')} )}
diff --git a/web/src/styles.css b/web/src/styles.css index 34a3f1f..7fb3e12 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1097,3 +1097,9 @@ textarea { flex: 0 0 auto; width: auto; } + +/* Ein Nutzer, der gerade ausfällt - der CC selbst läuft weiter. */ +.member-chip.is-inactive { + opacity: 0.75; + border-style: dashed; +}