From 192e4a24330107c56b7ff6b98a8ff4d529ee89dd Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 23:21:30 +0200 Subject: [PATCH] feat(admin): grant hints to a user's wallet from the console Add an "Add hints" form on the admin user card that additively tops up a player's hint wallet (1-100 per grant). The grant is raise-only by construction (an additive UPDATE never lowers the balance) and stays correct under a concurrent in-game spend; a per-grant cap bounds a fat-finger, since the console can never reduce a wallet. The in-game hint policy is unchanged and already correct: a game offers the per-seat allowance plus the wallet, spending the allowance first and the wallet only after (covered by TestHintPolicy). --- backend/internal/account/account.go | 24 +++++++ .../templates/pages/user_detail.gohtml | 4 ++ backend/internal/adminconsole/views.go | 17 +++-- backend/internal/inttest/admin_test.go | 71 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 31 +++++++- docs/ARCHITECTURE.md | 4 +- docs/FUNCTIONAL.md | 5 ++ docs/FUNCTIONAL_ru.md | 5 ++ 8 files changed, 151 insertions(+), 10 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 16d7d22..a0a7a28 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -430,6 +430,30 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { return n > 0, nil } +// GrantHints adds n hints to the account's wallet and returns the new balance. n must be +// positive: the additive update can only raise the balance, never lower it, so it enforces the +// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint. +// It returns ErrNotFound when no account matches. +func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) { + if n <= 0 { + return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n) + } + stmt := table.Accounts. + UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). + SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). + RETURNING(table.Accounts.HintBalance) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, ErrNotFound + } + return 0, fmt.Errorf("account: grant hints %s: %w", id, err) + } + return int(row.HintBalance), nil +} + // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // the account is not already flagged — the first sustained episode wins, and a // re-flag after an operator clear starts a fresh timestamp. An infra marker, not diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 89a324e..aa18410 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -21,6 +21,10 @@ {{end}} +
+ + +

Statistics

{{if .HasStats}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 036a6d5..4a3ef41 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -125,13 +125,16 @@ type UserDetailView struct { // empty for an unflagged account; the card shows it with the Clear action. FlaggedHighRateAt string HintBalance int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow - Games []GameRow - TelegramID string - ConnectorEnabled bool + // HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the + // server's maxHintGrant), passed through so the policy value lives in one place. + HintGrantMax int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + Games []GameRow + TelegramID string + ConnectorEnabled bool // MoveChart is the pre-rendered inline SVG of the account's per-move-number think // time (min/mean/max), empty when the account has no timed move. MoveChart template.HTML diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 0531685..75354cf 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -4,6 +4,7 @@ package inttest import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -273,6 +274,76 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) { } } +// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form, +// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again +// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or +// non-numeric amount is refused without changing the balance. +func TestConsoleGrantHints(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + id := provisionAccount(t) + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), + }) + h := srv.Handler() + base := "http://admin.test/_gm/users/" + id.String() + + if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") { + t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints")) + } + // The grant POST is CSRF-guarded like every console action. + if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden { + t.Fatalf("grant without origin = %d, want 403", code) + } + // A same-origin grant adds to the wallet. + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") { + t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5")) + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 { + t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err) + } + // A second grant adds again rather than replacing. + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") { + t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8")) + } + // An out-of-range or non-numeric amount is refused; the balance is left untouched. + for _, bad := range []string{"0", "-1", "101", "x", ""} { + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") { + t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount")) + } + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 { + t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err) + } + // The inclusive per-grant cap (100) is accepted. + if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK { + t.Fatalf("grant 100 = %d, want 200", code) + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 { + t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err) + } +} + +// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the +// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound. +func TestGrantHintsStore(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + id := provisionAccount(t) + if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 { + t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err) + } + if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 { + t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err) + } + if _, err := accounts.GrantHints(ctx, id, 0); err == nil { + t.Error("grant 0 should be rejected (non-positive)") + } + if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) { + t.Errorf("grant unknown account = %v, want ErrNotFound", err) + } +} + // consoleDo issues a request to h, optionally with an Origin header, and returns // the status and body. Form bodies are sent as application/x-www-form-urlencoded. func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 53dbe96..8bcbf15 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -28,6 +28,10 @@ import ( // adminPageSize is the page size of the admin console's paginated lists. const adminPageSize = 50 +// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a +// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake. +const maxHintGrant = 100 + // registerConsole mounts the server-rendered admin console under /_gm. The gateway // puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the // backend trusts the gateway (as for all of /api) and adds only a same-origin guard @@ -51,6 +55,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) + gm.POST("/users/:id/grant-hints", s.consoleGrantHints) gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.GET("/reasons", s.consoleReasons) @@ -263,8 +268,8 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view := adminconsole.UserDetailView{ ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, - PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), - HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, + PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant, + CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, } if acc.MergedInto != uuid.Nil { view.MergedInto = acc.MergedInto.String() @@ -774,6 +779,28 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) } +// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a +// player up and can never lower what they already hold, so blocking a reduction is inherent rather +// than a separate guard. A single grant is bounded by maxHintGrant. +func (s *Server) consoleGrantHints(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + n, err := strconv.Atoi(trimForm(c, "amount")) + if err != nil || n < 1 || n > maxHintGrant { + s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back) + return + } + balance, err := s.accounts.GrantHints(c.Request.Context(), id, n) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) +} + // consoleBlockUser manually blocks an account: it records the suspension (permanent or until a // parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active // games, removing them from matchmaking. The block takes effect on the player's next request. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed0d524..07a84a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -477,7 +477,9 @@ English game the Latin pool. Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary keys are application-generated **UUIDv7**. - Tables: `accounts` (durable internal accounts, carrying the away-window - columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest` + columns `away_start`/`away_end`, the hint wallet `hint_balance` (spent after a + game's per-seat allowance; an operator tops it up with an additive, raise-only + grant from the admin console), the `is_guest` flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push toggle, the `paid_account` service flag and the merge-tombstone columns `merged_into`/`merged_at`), diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index cd9475c..d8f292f 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -220,3 +220,8 @@ is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, plus the reason when one was given, and the app stops all background traffic with the server. A temporary block lifts itself when it expires; the operator can also **unblock** from the user card at any time (games already lost stay lost). + +From the user card the operator can also **top up a player's hint wallet**: an additive grant +(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — +the console can never lower a wallet (a player only loses hints by spending them in a game), so an +over-grant cannot be reversed there. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1ecdbad..95083c6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -226,3 +226,8 @@ high-rate флага. С карточки пользователя операт приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент (уже проигранные партии не возвращаются). + +С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное +начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления +**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в +партии), поэтому ошибочно начисленное через консоль там не отнять.