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 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
+**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
+партии), поэтому ошибочно начисленное через консоль там не отнять.