From ae5c67eea6b1e1d2d1c9ddb352386be45ac854b5 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Mon, 21 Sep 2026 23:43:26 +0200 Subject: [PATCH] Von Hand vergebenes CC-Guthaben ohne Redesign Der Knopf trug bisher die Regel des Shops: ein CC-Paket ist ein Platz plus ein Redesign. Fuer Einzelfaelle - jemand bekommt einen Platz als Ausgleich, aus einer Absprache, weil etwas schiefging - war das zu viel. "+ CC-Guthaben" schreibt jetzt nur den Platz gut. Wer ein volles Paket nachtragen will, klickt zusaetzlich auf "+ Redesign-Paket". Der Abgleich mit dem Shop bleibt unberuehrt: dort ist ein Paket weiterhin beides, und die Regel dafuer steht unveraendert an einer Stelle. In der Historie steht "CC-Guthaben (von Hand)" statt "CC-Paket" - eine eigene Quelle, damit zu sehen ist, dass kein Kauf dahintersteht. Das Minus daneben nimmt entsprechend diese Zugabe zurueck. --- README.md | 12 +++-- .../migration.sql | 9 ++++ server/prisma/schema.prisma | 5 ++ server/src/routes/credits.routes.ts | 11 ++-- server/src/services/credits.ts | 54 +++++++++++++++++-- server/test/http.test.ts | 11 ++-- server/test/rules.test.ts | 48 +++++++++++++++++ web/src/lib/format.ts | 2 + web/src/lib/types.ts | 1 + web/src/pages/PlayerDetail.tsx | 15 +++--- 10 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 server/prisma/migrations/20250113000000_cc_platz_ohne_paket/migration.sql diff --git a/README.md b/README.md index 2e946bc..be8f115 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,15 @@ Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*. ## Guthaben von Hand eintragen -Auf der Spielerseite stehen drei Paare: **+ CC-Paket**, **+ Redesign-Paket**, **+ Kulanz** - und -daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. Für -den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. +Auf der Spielerseite stehen drei Paare: **+ CC-Guthaben**, **+ Redesign-Paket**, **+ Kulanz** - +und daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. +Für den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. + +**Von Hand vergeben heißt nur der Platz.** Ein im Shop gekauftes Paket bringt ein Redesign mit, +und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand einen Platz +bekommt, ohne dafür gekauft zu haben. Wer ein volles Paket nachtragen will, klickt zusätzlich +auf *+ Redesign-Paket*. In der Historie steht *CC-Guthaben (von Hand)*, damit zu sehen ist, dass +kein Kauf dahintersteht. Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign. diff --git a/server/prisma/migrations/20250113000000_cc_platz_ohne_paket/migration.sql b/server/prisma/migrations/20250113000000_cc_platz_ohne_paket/migration.sql new file mode 100644 index 0000000..445ac5b --- /dev/null +++ b/server/prisma/migrations/20250113000000_cc_platz_ohne_paket/migration.sql @@ -0,0 +1,9 @@ +-- Ein von Hand vergebener CC-Platz, ohne das Redesign, das in einem gekauften +-- Paket steckt. Fuer Einzelfaelle; der Abgleich mit dem Shop bleibt, wie er +-- ist - dort ist ein Paket weiterhin beides. +DO $$ +BEGIN + ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CC_GRANT'; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d063ac0..e997ef2 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -21,8 +21,13 @@ enum CreditKind { } enum CreditSource { + /// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit. CC_PACKAGE REDESIGN_PACKAGE + /// Ein von Hand vergebener Platz, ohne das Redesign aus einem Paket. + /// Für Einzelfälle, in denen jemand einen CC bekommt, ohne dafür gekauft + /// zu haben - der Abgleich mit dem Shop bleibt davon unberührt. + CC_GRANT KULANZ ADJUSTMENT CONSUME diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index 463c2e3..bb7d305 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -4,9 +4,9 @@ import { CreditKind, CreditSource } from '@prisma/client'; import { requireAdmin, requireEditor } from '../auth/guard.js'; import { bookAdjustment, - bookCcPackage, bookKulanz, bookRedesignPackage, + grantCcSlot, removeLastGrant, reverseGroup, } from '../services/credits.js'; @@ -15,10 +15,15 @@ import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; const noteBody = z.object({ note: optionalNote }); export async function creditRoutes(app: FastifyInstance): Promise { + /** + * Von Hand einen CC-Platz vergeben - ohne das Redesign, das in einem + * gekauften Paket steckt. Der Abgleich mit dem Shop bleibt davon + * unberührt: dort ist ein Paket weiterhin beides. + */ app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith(noteBody, request.body ?? {}); - return bookCcPackage(id, contextOf(request), { note: body.note }); + return grantCcSlot(id, contextOf(request), { note: body.note }); }); app.post( @@ -50,7 +55,7 @@ export async function creditRoutes(app: FastifyInstance): Promise { { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); - return removeLastGrant(id, CreditSource.CC_PACKAGE, contextOf(request)); + return removeLastGrant(id, CreditSource.CC_GRANT, contextOf(request)); }, ); diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index c0258ba..479e924 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -181,6 +181,47 @@ export async function bookCcPackage( return { groupId, balances: await getBalances(playerId) }; } +/** + * Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket. + * + * Ein gekauftes CC-Paket bringt beides mit - so ist es im Shop, und so + * bucht es der Abgleich. Von Hand gibt es aber Einzelfälle, in denen jemand + * nur einen Platz bekommen soll: als Ausgleich, aus einer Absprache, weil + * etwas schiefging. Dafür ist das hier da, und deshalb trägt es eine eigene + * Quelle - in der Historie soll zu sehen sein, dass kein Kauf dahintersteht. + */ +export async function grantCcSlot( + playerId: number, + ctx: ActionContext, + options: { note?: string | undefined } = {}, +): Promise<{ groupId: string; balances: Balances }> { + await requirePlayer(playerId); + const groupId = randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.creditLedger.create({ + data: { + playerId, + kind: CreditKind.CC, + amount: 1, + source: CreditSource.CC_GRANT, + groupId, + note: options.note ?? null, + createdById: ctx.actorId, + }, + }); + + await recordAudit(tx, ctx, { + action: 'credit.cc_grant', + targetType: 'player', + targetId: playerId, + details: { groupId, note: options.note ?? null }, + }); + }); + + return { groupId, balances: await getBalances(playerId) }; +} + export async function bookRedesignPackage( playerId: number, ctx: ActionContext, @@ -289,6 +330,7 @@ export async function bookAdjustment( */ /// Was sich über die Knöpfe auf der Spielerseite eintragen lässt. const HANDEINTRAEGE = [ + CreditSource.CC_GRANT, CreditSource.CC_PACKAGE, CreditSource.REDESIGN_PACKAGE, CreditSource.KULANZ, @@ -326,11 +368,13 @@ export async function removeLastGrant( if (!juengste) { const was = - source === CreditSource.CC_PACKAGE - ? 'CC-Paket' - : source === CreditSource.REDESIGN_PACKAGE - ? 'Redesign-Paket' - : 'Kulanz'; + source === CreditSource.CC_GRANT + ? 'CC-Guthaben' + : source === CreditSource.CC_PACKAGE + ? 'CC-Paket' + : source === CreditSource.REDESIGN_PACKAGE + ? 'Redesign-Paket' + : 'Kulanz'; throw conflict( 'NOTHING_TO_REMOVE', `Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`, diff --git a/server/test/http.test.ts b/server/test/http.test.ts index d8ae015..af5829e 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -361,7 +361,7 @@ describe('Erstpasswort', () => { }); describe('Guthaben über die API', () => { - it('ein CC-Paket schlägt in beiden Töpfen auf', async () => { + it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => { const credentials = await login('team'); const created = await app.inject({ @@ -380,13 +380,14 @@ describe('Guthaben über die API', () => { }); assert.equal(booked.statusCode, 200); + // Ein im Shop gekauftes Paket bringt ein Redesign mit; hier geht es um + // den Einzelfall, in dem jemand nur einen Platz bekommt. assert.deepEqual(booked.json().balances, { CC: 1, - REDESIGN_PAID: 1, + REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, - redesignTotal: 1, - // Was offen ist, und wovon: "1 von 1". - granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 }, + redesignTotal: 0, + granted: { CC: 1, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 }, }); }); diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts index dd8a6bd..f819dd4 100644 --- a/server/test/rules.test.ts +++ b/server/test/rules.test.ts @@ -404,3 +404,51 @@ describe('Von Hand eingetragene Guthaben zurücknehmen', () => { ); }); }); + +describe('CC-Guthaben von Hand vergeben', () => { + it('schreibt nur das CC-Guthaben gut, kein Redesign', async () => { + // Ein gekauftes Paket bringt beides mit. Von Hand gibt es Einzelfälle, + // in denen jemand nur einen Platz bekommen soll. + const playerId = await freshPlayer('Einzelfall'); + const { balances } = await credits.grantCcSlot(playerId, ctx, { note: 'Ausgleich' }); + + assert.equal(balances.CC, 1); + assert.equal(balances.REDESIGN_PAID, 0, 'kein Redesign obendrauf'); + assert.equal(balances.granted.CC, 1); + }); + + it('macht in der Historie sichtbar, dass kein Kauf dahintersteht', async () => { + const playerId = await freshPlayer('Nachvollziehbar'); + await credits.grantCcSlot(playerId, ctx); + + const zeile = await db.prisma.creditLedger.findFirstOrThrow({ where: { playerId } }); + assert.equal(zeile.source, 'CC_GRANT'); + }); + + it('lässt den Abgleich mit dem Shop unberührt', async () => { + // Ein Paket aus dem Shop bleibt, was es ist: Platz plus Redesign. + const playerId = await freshPlayer('Aus dem Shop'); + const { balances } = await credits.bookCcPackage(playerId, ctx); + + assert.equal(balances.CC, 1); + assert.equal(balances.REDESIGN_PAID, 1, 'das Redesign gehört zum Paket'); + }); + + it('nimmt einen von Hand vergebenen Platz wieder zurück', async () => { + const playerId = await freshPlayer('Doch nicht'); + await credits.grantCcSlot(playerId, ctx); + + const { balances } = await credits.removeLastGrant(playerId, 'CC_GRANT', ctx); + assert.equal(balances.CC, 0); + assert.equal(balances.granted.CC, 0); + }); + + it('reicht für einen neuen CC', async () => { + const playerId = await freshPlayer('Baut damit'); + await credits.grantCcSlot(playerId, ctx); + + const ergebnis = await characters.createCharacter(playerId, { name: 'Daraus gebaut' }, ctx); + assert.equal(ergebnis.balances.CC, 0); + assert.equal(ergebnis.overdrawn, false, 'der Platz war gedeckt'); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 498bbb0..0014484 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -9,6 +9,8 @@ export const KIND_LABEL: Record = { export const SOURCE_LABEL: Record = { CC_PACKAGE: 'CC-Paket', REDESIGN_PACKAGE: 'Redesign-Paket', + /// Von Hand vergeben, ohne Kauf dahinter. + CC_GRANT: 'CC-Guthaben (von Hand)', KULANZ: 'Kulanz', ADJUSTMENT: 'Korrektur', CONSUME: 'Verbrauch', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index b13f570..08b80fe 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -3,6 +3,7 @@ export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ'; export type CreditSource = | 'CC_PACKAGE' | 'REDESIGN_PACKAGE' + | 'CC_GRANT' | 'KULANZ' | 'ADJUSTMENT' | 'CONSUME' diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index a356239..7662525 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -318,14 +318,14 @@ export default function PlayerDetail() { disabled={busy} onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })} > - + CC-Paket + + CC-Guthaben @@ -837,10 +837,11 @@ export default function PlayerDetail() { const CREDIT_ACTIONS = { 'cc-package': { - titel: 'CC-Paket eintragen', - wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.', - hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.', - flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.', + titel: 'CC-Guthaben eintragen', + wirkung: 'Schreibt +1 CC-Guthaben gut - einen Platz für einen aktiven CC.', + hinweis: + 'Ohne Redesign. Ein im Shop gekauftes Paket bringt beides mit, und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand nur einen Platz bekommt. Wer ein volles Paket nachtragen will, klickt zusätzlich auf „+ Redesign-Paket".', + flash: 'CC-Guthaben eingetragen: +1 CC.', }, 'redesign-package': { titel: 'Redesign-Paket eintragen',