diff --git a/README.md b/README.md index 6c15e33..46e5b09 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,21 @@ 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. +### Nachtragen, was längst passiert ist + +Beim Nacherfassen kommt heraus, dass ein Spieler schon vor drei Redesigns bei einem fremden +Gruppen-CC dabei war. Dafür gilt: **An einem stillgelegten CC wird nachgetragen, nicht +beigetreten.** Der Knopf heißt dort *Nutzer nachtragen*, und die Guthaben-Angabe ist +freigestellt - die Buchung kann längst im Konto stehen. Ein Zeitraum lässt sich mitgeben. + +Wer weiter unten in derselben Kette schon steht, kommt auch an einem **laufenden** CC ohne neue +Buchung dazu: Sein Platz wurde einmal bezahlt, und ein Redesign tauscht ihn aus, statt einen +zweiten zu verlangen. Genau das tut ein Redesign im laufenden Betrieb auch - es nimmt die Gruppe +mit. Für alle anderen bleibt die Angabe Pflicht. + +Damit das nicht vier Klicks für vier Kettenglieder bedeutet, trägt ein Aufruf den Spieler ab +einem Glied in die **ganze weitere Kette** ein. Gebucht wird dabei genau einmal, beim Einstieg. + ### Einzelner Nutzer oder ganze Gruppe Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist: @@ -403,6 +418,16 @@ Der Lauf geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat. +## Die Spielerliste eingrenzen + +Über der Tabelle stehen zwei Auswahlfelder. Eingegrenzt wird auf Spieler mit aktivem CC, auf +solche mit ausschließlich stillgelegten, auf die Beteiligten eines Gruppen-CCs, auf Spieler ganz +ohne CC oder auf die offenen Problemfälle. Sortiert wird nach Namen, nach der Zahl der +CC-Einträge oder nach dem Zeitpunkt der Anlage. + +Maßgeblich ist überall die **Mitgliedschaft**, nicht wer den CC angelegt hat: Wer einem +Gruppen-CC angehört, zählt dazu, auch ohne ihn erstellt zu haben. + ## Problemfälle Nicht jeder Fall lässt sich beim Eintragen sauber klären: ein CC-Antrag, zu dem sich kein Paket diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 95e8447..6b6144e 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -25,6 +25,7 @@ import { moveInChain, detachFromChain, changeRedesignKind, + addMemberToChain, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -232,10 +233,16 @@ export async function characterRoutes(app: FastifyInstance): Promise { const body = parseWith( z.object({ playerId: z.number().int().positive(), - useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), + /// Fehlt nur bei einer Nacherfassung an einem inaktiven CC. + useKind: z + .enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]) + .optional(), /// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der /// Platz über ein Redesign bezahlt wird. predecessorId: z.number().int().positive().optional(), + /// Zeitraum beim Nachtragen. + joinedAt: z.coerce.date().optional(), + leftAt: z.coerce.date().optional(), note: optionalNote, }), request.body, @@ -243,7 +250,13 @@ export async function characterRoutes(app: FastifyInstance): Promise { return addMember( id, body.playerId, - { useKind: body.useKind, predecessorId: body.predecessorId, note: body.note }, + { + useKind: body.useKind, + predecessorId: body.predecessorId, + joinedAt: body.joinedAt, + leftAt: body.leftAt, + note: body.note, + }, contextOf(request), ); }); @@ -351,6 +364,39 @@ export async function characterRoutes(app: FastifyInstance): Promise { }, ); + /** + * Trägt einen Spieler ab diesem Glied in die ganze weitere Kette ein - + * für die Nacherfassung, wenn jemand schon vor mehreren Redesigns dabei + * war. Gebucht wird einmal, beim Einstieg. + */ + app.post('/api/characters/:id/members/chain', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + playerId: z.number().int().positive(), + useKind: z + .enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]) + .optional(), + predecessorId: z.number().int().positive().optional(), + joinedAt: z.coerce.date().optional(), + note: optionalNote, + }), + request.body, + ); + + return addMemberToChain( + id, + body.playerId, + { + useKind: body.useKind, + predecessorId: body.predecessorId, + joinedAt: body.joinedAt, + note: body.note, + }, + contextOf(request), + ); + }); + app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => { const params = parseWith( z.object({ diff --git a/server/src/routes/players.routes.ts b/server/src/routes/players.routes.ts index cbdc703..d6ca741 100644 --- a/server/src/routes/players.routes.ts +++ b/server/src/routes/players.routes.ts @@ -32,11 +32,18 @@ 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'), + filter: z + .enum(['all', 'problems', 'group', 'active', 'onlyInactive', 'none']) + .default('all'), + sort: z.enum(['name', 'characters', 'newest']).default('name'), + /// Alte Schreibweise, damit gespeicherte Adressen weiter greifen. + problems: z.enum(['true', 'false']).optional(), }), request.query, ); - return searchPlayers(query.q, query.take, query.skip, query.problems === 'true'); + + const filter = query.problems === 'true' ? 'problems' : query.filter; + return searchPlayers(query.q, query.take, query.skip, filter, query.sort); }); app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => { diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index a6451f1..3104ef0 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -1106,11 +1106,11 @@ const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.RE * Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger. */ async function ladeAufgegebenenCC( - input: { useKind: JoinKind; predecessorId?: number | undefined }, + input: { useKind?: JoinKind | undefined; predecessorId?: number | undefined }, playerId: number, characterId: number, ): Promise { - if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) { + if (input.useKind === undefined || !JOIN_REDESIGN_KINDS.includes(input.useKind)) { if (input.predecessorId !== undefined) { throw badRequest( 'NO_PREDECESSOR_NEEDED', @@ -1149,6 +1149,27 @@ async function ladeAufgegebenenCC( return vorgaenger; } +/** + * Ob der Spieler in einem früheren Glied derselben Kette geführt wird. + * + * Für die Nacherfassung entscheidend: Wer bei einem CC dabei war, der später + * redesignt wurde, ist beim Nachfolger dabei, ohne ein zweites Mal zu zahlen. + * Sein Platz wurde einmal gebucht und ist derselbe geblieben. + */ +async function warSchonInDerKette(character: Character, playerId: number): Promise { + const treffer = await prisma.characterMember.findFirst({ + where: { + playerId, + character: { + lineageId: character.lineageId, + chainPosition: { lt: character.chainPosition }, + }, + }, + select: { id: true }, + }); + return treffer !== null; +} + /** * 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 @@ -1157,17 +1178,42 @@ async function ladeAufgegebenenCC( export async function addMember( characterId: number, playerId: number, - input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined }, + input: { + /// Fehlt bei einer reinen Nacherfassung an einem inaktiven CC: dort wird + /// eingetragen, was war, und die Buchung steht längst im Konto. + useKind?: JoinKind | undefined; + predecessorId?: number | undefined; + note?: string | undefined; + /// Zeitraum, wenn nachgetragen wird statt jetzt beizutreten. + joinedAt?: Date | undefined; + leftAt?: Date | undefined; + }, ctx: ActionContext, ): Promise { const character = await loadCharacter(characterId); + const historisch = character.status !== CharacterStatus.ACTIVE; - if (character.status !== CharacterStatus.ACTIVE) { - throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten'); + // Wer weiter unten in derselben Kette schon dabei war, setzt hier nur + // fort: sein Platz wurde damals bezahlt, und ein Redesign tauscht einen + // Platz aus, statt einen zweiten zu verlangen. Genau das tut ein Redesign + // im laufenden Betrieb auch - es nimmt die Gruppe mit. + const schonInDerKette = await warSchonInDerKette(character, playerId); + + // Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem + // inaktiven wird nachgetragen, was einmal war - dort ist die Angabe + // freigestellt, weil die Buchung längst im Konto stehen kann. + if (!historisch && !schonInDerKette && input.useKind === undefined) { + throw badRequest( + 'NEED_KIND', + 'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches', + ); } - if (!JOIN_KINDS.includes(input.useKind)) { + if (input.useKind !== undefined && !JOIN_KINDS.includes(input.useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt'); } + if (input.leftAt && input.joinedAt && input.leftAt < input.joinedAt) { + throw badRequest('BAD_RANGE', 'Das Ende liegt vor dem Anfang'); + } const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }); if (!player) throw notFound('Spieler'); @@ -1204,9 +1250,9 @@ export async function addMember( await tx.characterMember.update({ where: { id: vorhanden.id }, data: { - leftAt: null, - joinedAt: new Date(), - paidWith: input.useKind, + leftAt: input.leftAt ?? null, + joinedAt: input.joinedAt ?? new Date(), + paidWith: input.useKind ?? null, predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, @@ -1217,30 +1263,35 @@ export async function addMember( data: { characterId, playerId, - paidWith: input.useKind, + paidWith: input.useKind ?? null, predecessorId: vorgaenger?.id ?? null, note: input.note ?? null, createdById: ctx.actorId, + ...(input.joinedAt ? { joinedAt: input.joinedAt } : {}), + ...(input.leftAt ? { leftAt: input.leftAt } : {}), }, }); } - await tx.creditLedger.create({ - data: { - playerId, - kind: input.useKind, - amount: -1, - source: CreditSource.CONSUME, - groupId: randomUUID(), - characterId, - note: - input.note ?? - (vorgaenger - ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` - : `Beitritt zu "${character.name}"`), - createdById: ctx.actorId, - }, - }); + if (input.useKind !== undefined) { + await tx.creditLedger.create({ + data: { + playerId, + kind: input.useKind, + amount: -1, + source: CreditSource.CONSUME, + groupId: randomUUID(), + characterId, + note: + input.note ?? + (vorgaenger + ? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"` + : `Beitritt zu "${character.name}"`), + createdById: ctx.actorId, + ...(input.joinedAt ? { occurredAt: input.joinedAt } : {}), + }, + }); + } await assertSlotsReichen(tx, playerId, 'Der Beitritt'); @@ -1251,7 +1302,8 @@ export async function addMember( details: { playerId, name: character.name, - useKind: input.useKind, + useKind: input.useKind ?? null, + nachgetragen: historisch, predecessorId: vorgaenger?.id ?? null, predecessorName: vorgaenger?.name ?? null, }, @@ -1543,6 +1595,74 @@ export async function reactivateMember( return { memberCount }; } +/** + * Trägt einen Spieler ab einem Kettenglied in die gesamte weitere Kette ein. + * + * Für die Nacherfassung: Stellt sich heraus, dass jemand schon beim dritten + * von sechs Gliedern dabei war, wäre es Unfug, ihn viermal einzeln + * einzutragen. Gebucht wird genau einmal - beim Einstieg -, denn ein + * Redesign tauscht seinen Platz aus, statt einen weiteren zu verlangen. + * + * Wer schon irgendwo in der Kette steht, wird dort nicht angerührt. + */ +export async function addMemberToChain( + characterId: number, + playerId: number, + input: { + useKind?: JoinKind | undefined; + predecessorId?: number | undefined; + note?: string | undefined; + joinedAt?: Date | undefined; + }, + ctx: ActionContext, +): Promise<{ added: number; memberCount: number }> { + const einstieg = await loadCharacter(characterId); + + const kette = await prisma.character.findMany({ + where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition } }, + orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], + select: { id: true, name: true }, + }); + + const vorhandene = new Set( + ( + await prisma.characterMember.findMany({ + where: { playerId, characterId: { in: kette.map((c) => c.id) } }, + select: { characterId: true }, + }) + ).map((m) => m.characterId), + ); + + let added = 0; + for (const [index, glied] of kette.entries()) { + if (vorhandene.has(glied.id)) continue; + + // Nur der Einstieg kostet. Alles Weitere ist dieselbe Mitgliedschaft, + // durch die Kette fortgeschrieben. + await addMember( + glied.id, + playerId, + { + ...(index === 0 ? { useKind: input.useKind } : {}), + ...(index === 0 && input.predecessorId !== undefined + ? { predecessorId: input.predecessorId } + : {}), + ...(input.note !== undefined ? { note: input.note } : {}), + ...(input.joinedAt !== undefined ? { joinedAt: input.joinedAt } : {}), + }, + ctx, + ); + added += 1; + } + + const letztes = kette[kette.length - 1]; + const memberCount = letztes + ? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null } }) + : 0; + + return { added, 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 diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index c3883f6..a4103dc 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -591,7 +591,7 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) { throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an'); } - return prisma.$transaction(async (tx) => { + const ergebnis = await prisma.$transaction(async (tx) => { let booked = 0; let skipped = 0; const erledigt: number[] = []; @@ -677,6 +677,10 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) { return { booked, skipped }; }); + + // Bleibt von einer Vorschau nichts übrig, ist sie erledigt. + await schliesseLeere(); + return ergebnis; } /** @@ -704,6 +708,7 @@ export async function dismissRows(rowIds: number[], ctx: ActionContext) { details: { count: result.count, rowIds }, }); + await schliesseLeere(); return { dismissed: result.count }; } @@ -730,7 +735,33 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi }); } +/** + * Schliesst Importe ab, an denen nichts mehr zu tun ist. + * + * Wer die Zeilen eines Spielers direkt auf dessen Seite bucht, lässt eine + * leere Vorschau zurück. Sie stünde weiter in der Liste, liesse sich nicht + * bestätigen ("nichts zu buchen") und wäre nur im Weg. Also gilt sie als + * erledigt - sie ist es ja. + */ +async function schliesseLeere(): Promise { + const leer = await prisma.import.findMany({ + where: { + status: ImportStatus.PENDING, + rows: { none: { plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] } } }, + }, + select: { id: true }, + }); + if (leer.length === 0) return 0; + + const result = await prisma.import.updateMany({ + where: { id: { in: leer.map((i) => i.id) } }, + data: { status: ImportStatus.COMMITTED, committedAt: new Date() }, + }); + return result.count; +} + export async function listImports(take = 50) { + await schliesseLeere(); return prisma.import.findMany({ orderBy: { createdAt: 'desc' }, take, diff --git a/server/src/services/players.ts b/server/src/services/players.ts index e0811b3..d6aa961 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -17,19 +17,68 @@ export interface PlayerInput { problemNote?: string | null; } +/// Womit die Liste eingegrenzt wird. +export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none'; +/// Wonach sie sortiert wird. +export type PlayerSort = 'name' | 'characters' | 'newest'; + +/** + * Spieler, die an einem CC mit mehr als einem Nutzer beteiligt sind. + * + * Das lässt sich nicht in einer Bedingung ausdrücken, weil dabei ein CC mit + * sich selbst verglichen würde: erst stehen die Gruppen-CCs fest, dann ihre + * Nutzer. + */ +async function spielerMitGruppenCC(): Promise { + const gruppen = await prisma.characterMember.groupBy({ + by: ['characterId'], + where: { leftAt: null }, + having: { characterId: { _count: { gt: 1 } } }, + }); + if (gruppen.length === 0) return []; + + const beteiligte = await prisma.characterMember.findMany({ + where: { characterId: { in: gruppen.map((g) => g.characterId) }, leftAt: null }, + select: { playerId: true }, + }); + return [...new Set(beteiligte.map((b) => b.playerId))]; +} + export async function searchPlayers( query: string, take = 50, skip = 0, - onlyProblems = false, + filter: PlayerFilter = 'all', + sort: PlayerSort = 'name', ) { 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; + // Massgeblich ist die Mitgliedschaft, nicht wer den CC angelegt hat: wer + // einem Gruppen-CC angehört, hat dort einen Platz, auch ohne ihn erstellt + // zu haben. + const eingrenzung: Prisma.PlayerWhereInput = + filter === 'problems' + ? { isProblem: true } + : filter === 'group' + ? { id: { in: await spielerMitGruppenCC() } } + : filter === 'active' + ? { memberships: { some: { leftAt: null, character: { status: 'ACTIVE' } } } } + : filter === 'onlyInactive' + ? { + AND: [ + { memberships: { some: {} } }, + { memberships: { none: { leftAt: null, character: { status: 'ACTIVE' } } } }, + ], + } + : filter === 'none' + ? { memberships: { none: {} } } + : {}; + const where: Prisma.PlayerWhereInput = { - ...(onlyProblems ? { isProblem: true } : {}), + ...eingrenzung, ...(trimmed.length === 0 ? {} : { @@ -41,10 +90,17 @@ export async function searchPlayers( }), }; + const orderBy: Prisma.PlayerOrderByWithRelationInput[] = + sort === 'characters' + ? [{ characters: { _count: 'desc' } }, { displayName: 'asc' }] + : sort === 'newest' + ? [{ createdAt: 'desc' }] + : [{ displayName: 'asc' }]; + const [players, total] = await Promise.all([ prisma.player.findMany({ where, - orderBy: { displayName: 'asc' }, + orderBy, take, skip, include: { _count: { select: { characters: true } } }, diff --git a/server/test/antraege.test.ts b/server/test/antraege.test.ts index 9d426d5..4e6e2de 100644 --- a/server/test/antraege.test.ts +++ b/server/test/antraege.test.ts @@ -277,14 +277,14 @@ describe('Problemfälle', () => { }); it('listet sie auf Wunsch für sich', async () => { - const offen = await players.searchPlayers('', 100, 0, true); + const offen = await players.searchPlayers('', 100, 0, 'problems'); assert.ok(offen.total >= 3); assert.equal( offen.items.every((p) => p.isProblem), true, ); - const alle = await players.searchPlayers('', 100, 0, false); + const alle = await players.searchPlayers('', 100, 0, 'all'); assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei'); }); }); @@ -415,3 +415,73 @@ describe('Ersteller ohne Spieler', () => { ); }); }); + +describe('Spielerliste eingrenzen und sortieren', () => { + before(async () => { + // Ein Gruppen-CC, ein stillgelegter CC und ein Spieler ohne alles. + const wirt = await players.createPlayer( + { displayName: 'Filter-Wirt', steamId: '76561198000040001' }, + ctx, + ); + const gast = await players.createPlayer( + { displayName: 'Filter-Gast', steamId: '76561198000040002' }, + ctx, + ); + for (const id of [wirt.id, gast.id]) { + await db.prisma.creditLedger.create({ + data: { playerId: id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, + }); + } + + const gruppe = await characters.createCharacter(wirt.id, { name: 'Filter-Runde' }, ctx); + await characters.addMember(gruppe.character.id, gast.id, { useKind: 'CC' }, ctx); + + const einsam = await players.createPlayer( + { displayName: 'Filter-Einsam', steamId: '76561198000040003' }, + ctx, + ); + await db.prisma.creditLedger.create({ + data: { playerId: einsam.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' }, + }); + const stillgelegt = await characters.createCharacter(einsam.id, { name: 'Ruht' }, ctx); + await characters.deactivateCharacter(stillgelegt.character.id, 'PLAYER_INACTIVITY', ctx); + + await players.createPlayer( + { displayName: 'Filter-Leer', steamId: '76561198000040004' }, + ctx, + ); + }); + + const namen = async (filter: Parameters[3]) => + (await players.searchPlayers('Filter-', 100, 0, filter)).items.map((p) => p.displayName).sort(); + + it('findet die Beteiligten eines Gruppen-CCs', async () => { + assert.deepEqual(await namen('group'), ['Filter-Gast', 'Filter-Wirt']); + }); + + it('findet, wer einen aktiven CC hat', async () => { + assert.deepEqual(await namen('active'), ['Filter-Gast', 'Filter-Wirt']); + }); + + it('findet, wer nur noch stillgelegte CCs hat', async () => { + assert.deepEqual( + await namen('onlyInactive'), + ['Filter-Einsam'], + 'wer gar keinen CC hat, gehört nicht dazu', + ); + }); + + it('findet, wer gar keinen CC hat', async () => { + assert.deepEqual(await namen('none'), ['Filter-Leer']); + }); + + it('sortiert nach der Zahl der CC-Einträge', async () => { + const liste = await players.searchPlayers('Filter-', 100, 0, 'all', 'characters'); + const zahlen = liste.items.map((p) => p.characterCount); + assert.deepEqual( + [...zahlen].sort((a, b) => b - a), + zahlen, + 'die längste Liste steht oben', + ); + }); +}); diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index 741c4e9..a7f5223 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -258,16 +258,33 @@ describe('Gruppen-CCs', () => { ); }); - it('lässt niemanden einem inaktiven CC beitreten', async () => { + it('lässt an einem inaktiven CC nachtragen, was damals war', async () => { + // Beitreten kann man einem stillgelegten CC nicht - eintragen, wer + // damals dabei war, dagegen schon. Beim Nacherfassen geht es nicht um + // jetzt, sondern um eine Geschichte, die längst gelaufen ist. const gruender = await spielerMitPaket('Inaktiv-Chef'); - const spaet = await spielerMitPaket('Zu spät'); + const damals = await spielerMitPaket('War damals dabei'); const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx); await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx); - await assert.rejects( - () => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx), - (error: Error & { code?: string }) => error.code === 'NOT_ACTIVE', + await characters.addMember( + character.id, + damals, + { useKind: 'CC', joinedAt: new Date('2023-05-01'), leftAt: new Date('2023-09-01') }, + ctx, ); + + const eintrag = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: damals }, + }); + assert.equal(eintrag.joinedAt.toISOString().slice(0, 10), '2023-05-01'); + assert.ok(eintrag.leftAt, 'mit Ende, denn vorbei ist es'); + + // Ein stillgelegter CC belegt keinen Platz - auch nicht rückwirkend. + const belegt = (await characters.listCharacters(damals)).filter( + (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === damals && !m.leftAt), + ); + assert.deepEqual(belegt, []); }); }); diff --git a/server/test/imports.test.ts b/server/test/imports.test.ts index 791101e..3201b8f 100644 --- a/server/test/imports.test.ts +++ b/server/test/imports.test.ts @@ -357,6 +357,56 @@ describe('Einzelne Zeilen beim Spieler buchen', () => { await imports.discardImport(zweiterImport, ctx); }); + it('lässt keine leere Vorschau in der Liste zurück', async () => { + // Genau der Fall aus dem Betrieb: alle Zeilen beim Spieler gebucht, die + // Vorschau bleibt als leere Hülle stehen und lässt sich nicht bestätigen. + const importId = await vorschau('LEER'); + + const offen = [ + ...(await imports.listPendingRowsFor(eule)), + ...(await imports.listPendingRowsFor(specht)), + ].filter((row) => row.importId === importId); + assert.equal(offen.length, 3, 'alle drei Zeilen dieses Imports'); + + await imports.bookRows(offen.map((row) => row.id), ctx); + + const danach = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } }); + assert.equal(danach.status, 'COMMITTED', 'nichts mehr zu tun heisst erledigt'); + + const liste = await imports.listImports(); + assert.equal( + liste.some((i) => i.id === importId && i.status === 'PENDING'), + false, + 'und damit aus der Liste offener Importe verschwunden', + ); + }); + + it('räumt auch Vorschauen ab, die schon leer herumlagen', async () => { + const importId = await vorschau('ALTLAST'); + const offen = [ + ...(await imports.listPendingRowsFor(eule)), + ...(await imports.listPendingRowsFor(specht)), + ].filter((row) => row.importId === importId); + + // Von Hand leergeräumt, ohne den Import anzufassen - so sieht der + // Bestand aus, der vor dieser Korrektur entstanden ist. + await db.prisma.importRow.updateMany({ + where: { id: { in: offen.map((row) => row.id) } }, + data: { plan: 'SKIP_DUPLICATE' }, + }); + assert.equal( + (await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status, + 'PENDING', + ); + + await imports.listImports(); + + assert.equal( + (await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status, + 'COMMITTED', + ); + }); + it('weist eine leere Auswahl ab', async () => { await assert.rejects( () => imports.bookRows([], ctx), diff --git a/server/test/nacherfassung.test.ts b/server/test/nacherfassung.test.ts new file mode 100644 index 0000000..d73f506 --- /dev/null +++ b/server/test/nacherfassung.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Characters = typeof import('../src/services/characters.js'); +type Credits = typeof import('../src/services/credits.js'); +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let characters: Characters; +let credits: Credits; +let db: Db; +let ctx: { actorId: number; actorName: string }; + +before(async () => { + database = await startTestDatabase(54341); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + + db = await import('../src/db.js'); + characters = await import('../src/services/characters.js'); + credits = await import('../src/services/credits.js'); + + const user = await db.prisma.user.create({ + data: { username: 'nacherfasser', passwordHash: 'x', role: 'ADMIN' }, + }); + ctx = { actorId: user.id, actorName: user.username }; +}); + +after(async () => { + await db.prisma.$disconnect(); + await database.stop(); +}); + +async function spieler(name: string, pakete = 2) { + const p = await db.prisma.player.create({ data: { displayName: name } }); + for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(p.id, ctx); + return p.id; +} + +/** Die aktiven Mitglieder eines CCs, nach Namen. */ +async function mitglieder(characterId: number): Promise { + const liste = await db.prisma.characterMember.findMany({ + where: { characterId, leftAt: null }, + include: { player: { select: { displayName: true } } }, + orderBy: { id: 'asc' }, + }); + return liste.map((m) => m.player.displayName).sort(); +} + +/** + * Der Fall aus dem Betrieb: Spieler B ist längst eingetragen, und erst beim + * Durcharbeiten von Spieler A stellt sich heraus, dass die beiden einen + * Gruppen-CC teilten - über mehrere Redesigns hinweg, mit wechselnder + * Besetzung. + */ +describe('Nachträglich aufräumen, was längst passiert ist', () => { + let b = 0; + let a = 0; + let c = 0; + let d = 0; + let alpha = 0; + let beta = 0; + let gamma = 0; + + before(async () => { + b = await spieler('Spieler B'); + a = await spieler('Spieler A'); + c = await spieler('Spieler C'); + d = await spieler('Spieler D'); + + // B wurde zuerst durchgearbeitet: ein CC, zweimal redesignt. + const ersterCC = await characters.createCharacter(b, { name: 'Alpha' }, ctx); + alpha = ersterCC.character.id; + + await credits.bookRedesignPackage(b, ctx); + const zweiter = await characters.redesignCharacter( + alpha, + { name: 'Beta', useKind: 'REDESIGN_PAID' }, + ctx, + ); + beta = zweiter.character.id; + + await credits.bookRedesignPackage(b, ctx); + const dritter = await characters.redesignCharacter( + beta, + { name: 'Gamma', useKind: 'REDESIGN_PAID' }, + ctx, + ); + gamma = dritter.character.id; + }); + + it('trägt A rückwirkend in ein längst redesigntes Glied ein', async () => { + // Alpha ist inaktiv - im laufenden Betrieb tritt dort niemand mehr bei. + // Beim Nachtragen geht es aber nicht um jetzt, sondern um damals. + const alt = await db.prisma.character.findUniqueOrThrow({ where: { id: alpha } }); + assert.equal(alt.status, 'INACTIVE'); + + await characters.addMember( + alpha, + a, + { useKind: 'CC', joinedAt: new Date('2024-03-01') }, + ctx, + ); + + assert.deepEqual(await mitglieder(alpha), ['Spieler A', 'Spieler B']); + }); + + it('zieht ihn durch die Kette, ohne ein zweites Mal zu buchen', async () => { + const vorher = await credits.getBalances(a); + + const ergebnis = await characters.addMemberToChain(beta, a, {}, ctx); + assert.equal(ergebnis.added, 2, 'Beta und Gamma kommen dazu'); + + assert.deepEqual( + await credits.getBalances(a), + vorher, + 'sein Platz wurde einmal bezahlt und ist derselbe geblieben', + ); + assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B']); + }); + + it('belegt dabei genau einen Platz', async () => { + const belegt = (await characters.listCharacters(a)).filter( + (cc) => cc.status === 'ACTIVE' && cc.members.some((m) => m.playerId === a && !m.leftAt), + ); + assert.deepEqual( + belegt.map((cc) => cc.name), + ['Gamma'], + 'die früheren Glieder sind inaktiv und belegen nichts', + ); + }); + + it('lässt C mittendrin ein- und wieder aussteigen', async () => { + // C war nur bei Beta dabei - davor und danach nicht. + await characters.addMember(beta, c, { useKind: 'CC', joinedAt: new Date('2024-06-01') }, ctx); + assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B', 'Spieler C']); + + await characters.removeMember(beta, c, 'Ausgestiegen vor dem nächsten Redesign', ctx); + assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B']); + + // Bei Gamma steht er gar nicht erst. + assert.equal((await mitglieder(gamma)).includes('Spieler C'), false); + }); + + it('nimmt D erst beim letzten Glied auf', async () => { + await characters.addMember(gamma, d, { useKind: 'CC' }, ctx); + + assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B', 'Spieler D']); + assert.equal((await mitglieder(alpha)).includes('Spieler D'), false, 'damals war er nicht da'); + }); + + it('zeigt jedem Beteiligten seine eigene Geschichte', async () => { + const seins = async (playerId: number) => + (await characters.listCharacters(playerId)).map((cc) => cc.name).sort(); + + assert.deepEqual(await seins(a), ['Alpha', 'Beta', 'Gamma'], 'A war von Anfang an dabei'); + assert.deepEqual(await seins(b), ['Alpha', 'Beta', 'Gamma']); + assert.deepEqual(await seins(c), ['Beta'], 'C nur in der Mitte'); + assert.deepEqual(await seins(d), ['Gamma'], 'D nur am Ende'); + }); + + it('verlangt an einem laufenden CC weiterhin eine Guthaben-Angabe', async () => { + const fremder = await spieler('Fremder', 1); + + await assert.rejects( + () => characters.addMember(gamma, fremder, {}, ctx), + (error: Error & { code?: string }) => error.code === 'NEED_KIND', + 'wer neu dazukommt, zahlt - die Ausnahme gilt nur der eigenen Kette', + ); + }); + + it('weist einen verdrehten Zeitraum ab', async () => { + const spaeter = await spieler('Verdreht', 1); + + await assert.rejects( + () => + characters.addMember( + alpha, + spaeter, + { joinedAt: new Date('2025-01-01'), leftAt: new Date('2024-01-01') }, + ctx, + ), + (error: Error & { code?: string }) => error.code === 'BAD_RANGE', + ); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index 47a4f62..3725891 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -471,22 +471,37 @@ function ChainEntry({ Deaktivieren - ) : art === 'admin' ? ( - admin ? ( - - ) : ( - Nur ein Administrator - ) - ) : art === 'gratis' ? ( - ) : ( - + <> + {/* An einem längst ersetzten CC geht es nicht ums Beitreten, + sondern ums Nachtragen: wer damals dabei war. */} + + + {art === 'admin' ? ( + admin ? ( + + ) : ( + Nur ein Administrator + ) + ) : art === 'gratis' ? ( + + ) : ( + + )} + )}