feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s

Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
This commit is contained in:
Ilia Denisov
2026-06-14 21:55:59 +02:00
parent 9d85090075
commit d1ba666495
48 changed files with 2206 additions and 2 deletions
+268
View File
@@ -0,0 +1,268 @@
package account
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Suspension is an account's currently-in-force manual block, the operator's hard counterpart
// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct
// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary
// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry
// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at
// block time (both empty when no reason was cited), so editing or deleting the picklist entry
// never changes what an already-blocked player is shown.
type Suspension struct {
AccountID uuid.UUID
BlockedAt time.Time
BlockedUntil *time.Time
ReasonEn string
ReasonRu string
}
// Permanent reports whether the suspension has no expiry.
func (s Suspension) Permanent() bool { return s.BlockedUntil == nil }
// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything
// else English), or empty when no reason was cited. The two snapshots are always both set or
// both empty, since a reason is chosen from the en+ru picklist.
func (s Suspension) LocalizedReason(language string) string {
if language == "ru" {
return s.ReasonRu
}
return s.ReasonEn
}
// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English
// and Russian text shown to a blocked player in their language.
type Reason struct {
ID uuid.UUID
TextEn string
TextRu string
CreatedAt time.Time
UpdatedAt time.Time
}
// Suspend records a new manual block on the account: permanent when until is nil, otherwise in
// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both
// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if
// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked
// account simply appends another block; CurrentSuspension always reflects the strongest.
func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) {
suspensionID, err := uuid.NewV7()
if err != nil {
return Suspension{}, fmt.Errorf("account: new suspension id: %w", err)
}
stmt := table.AccountSuspensions.INSERT(
table.AccountSuspensions.SuspensionID,
table.AccountSuspensions.AccountID,
table.AccountSuspensions.BlockedUntil,
table.AccountSuspensions.ReasonEn,
table.AccountSuspensions.ReasonRu,
table.AccountSuspensions.ReasonID,
).VALUES(
suspensionID,
accountID,
nullableTimestamp(until),
nullableString(reasonEn),
nullableString(reasonRu),
nullableUUID(reasonID),
).RETURNING(table.AccountSuspensions.AllColumns)
var row model.AccountSuspensions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
}
return modelToSuspension(row), nil
}
// LiftSuspension lifts every in-force block on the account (the operator's manual unblock),
// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting
// does not un-resign games already forfeited at block time — those stay lost.
func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
now := time.Now().UTC()
stmt := table.AccountSuspensions.
UPDATE(table.AccountSuspensions.LiftedAt).
SET(postgres.TimestampzT(now)).
WHERE(
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
AND(activeSuspensionPredicate(now)),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
}
return nil
}
// CurrentSuspension returns the account's in-force block and true, or false when the account is
// not blocked. When several blocks overlap it returns the strongest — a permanent one first,
// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate
// middleware calls it on every authenticated request, served by account_suspensions_account_idx.
func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) {
// A store with no database (the zero-value store used by the routing unit tests) has no
// suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that
// errors still surfaces the error to the gate, which fails closed.
if s.db == nil {
return Suspension{}, false, nil
}
now := time.Now().UTC()
stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
FROM(table.AccountSuspensions).
WHERE(
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
AND(activeSuspensionPredicate(now)),
).
ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()).
LIMIT(1)
var row model.AccountSuspensions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Suspension{}, false, nil
}
return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err)
}
return modelToSuspension(row), true, nil
}
// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
// either permanent or not yet expired.
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {
return table.AccountSuspensions.LiftedAt.IS_NULL().
AND(
table.AccountSuspensions.BlockedUntil.IS_NULL().
OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))),
)
}
// ListReasons returns the suspension-reason picklist, oldest first.
func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) {
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
FROM(table.SuspensionReasons).
ORDER_BY(table.SuspensionReasons.CreatedAt.ASC())
var rows []model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list reasons: %w", err)
}
out := make([]Reason, 0, len(rows))
for _, r := range rows {
out = append(out, modelToReason(r))
}
return out, nil
}
// GetReason loads one picklist entry, or ErrNotFound when it is absent.
func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) {
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
FROM(table.SuspensionReasons).
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Reason{}, ErrNotFound
}
return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err)
}
return modelToReason(row), nil
}
// CreateReason inserts a new picklist entry with the given English and Russian text.
func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) {
id, err := uuid.NewV7()
if err != nil {
return Reason{}, fmt.Errorf("account: new reason id: %w", err)
}
stmt := table.SuspensionReasons.INSERT(
table.SuspensionReasons.ReasonID,
table.SuspensionReasons.TextEn,
table.SuspensionReasons.TextRu,
).VALUES(id, textEn, textRu).
RETURNING(table.SuspensionReasons.AllColumns)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Reason{}, fmt.Errorf("account: create reason: %w", err)
}
return modelToReason(row), nil
}
// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when
// it is absent. Existing suspensions keep their text snapshot, so the change only affects future
// blocks.
func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) {
stmt := table.SuspensionReasons.
UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt).
SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
RETURNING(table.SuspensionReasons.AllColumns)
var row model.SuspensionReasons
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Reason{}, ErrNotFound
}
return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err)
}
return modelToReason(row), nil
}
// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past
// suspensions is nulled by the foreign key, but their text snapshot is untouched.
func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error {
stmt := table.SuspensionReasons.
DELETE().
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: delete reason %s: %w", id, err)
}
return nil
}
// modelToSuspension projects a generated row into the public Suspension struct.
func modelToSuspension(row model.AccountSuspensions) Suspension {
s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil}
if row.ReasonEn != nil {
s.ReasonEn = *row.ReasonEn
}
if row.ReasonRu != nil {
s.ReasonRu = *row.ReasonRu
}
return s
}
// modelToReason projects a generated row into the public Reason struct.
func modelToReason(row model.SuspensionReasons) Reason {
return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
// nullableString renders an empty string as SQL NULL, otherwise the string literal.
func nullableString(v string) postgres.Expression {
if v == "" {
return postgres.NULL
}
return postgres.String(v)
}
// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal.
func nullableTimestamp(v *time.Time) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.TimestampzT(v.UTC())
}
// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal.
func nullableUUID(v *uuid.UUID) postgres.Expression {
if v == nil {
return postgres.NULL
}
return postgres.UUID(*v)
}
@@ -18,6 +18,7 @@
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Suspension reasons</h1>
{{with .Data}}
<p class="note">Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).</p>
<section class="panel"><h2>Add reason</h2>
<form class="form col" method="post" action="/_gm/reasons">
<label>English <input type="text" name="text_en" required></label>
<label>Russian <input type="text" name="text_ru" required></label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Existing reasons</h2>
{{range .Items}}
<div class="row">
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/update">
<input type="text" name="text_en" value="{{.TextEn}}" aria-label="English" required>
<input type="text" name="text_ru" value="{{.TextRu}}" aria-label="Russian" required>
<button type="submit">Save</button>
</form>
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/delete">
<button type="submit">Delete</button>
</form>
</div>
{{else}}<p class="note">no reasons yet</p>{{end}}
</section>
{{end}}
{{- end}}
@@ -34,6 +34,35 @@
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
<section class="panel"><h2>Account block</h2>
{{$uid := .ID}}
{{with .Suspension}}{{if .Blocked}}
<ul class="kv">
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
</ul>
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
<button type="submit">Unblock</button>
</form>
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
<label>Duration <select name="duration">
<option value="permanent">permanent</option>
<option value="1d">1 day</option>
<option value="3d">3 days</option>
<option value="1w">1 week</option>
<option value="1m">1 month</option>
<option value="custom">custom date (UTC)</option>
</select></label>
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
<label>Reason <select name="reason">
<option value="">&mdash; none &mdash;</option>
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
</select></label>
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form>
</section>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
+36
View File
@@ -135,6 +135,29 @@ type UserDetailView struct {
// 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
// Suspension is the account's current manual-block state, shown with the block/unblock
// form; Reasons is the operator-editable reason picklist offered in the block form.
Suspension SuspensionView
Reasons []ReasonOption
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
type SuspensionView struct {
Blocked bool
Permanent bool
Until string
BlockedAt string
ReasonEn string
ReasonRu string
}
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
type ReasonOption struct {
ID string
TextEn string
TextRu string
}
// StatsRow is an account's lifetime statistics.
@@ -317,6 +340,19 @@ type FlaggedAccountRow struct {
FlaggedAt string
}
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
type ReasonsView struct {
Items []ReasonRow
}
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
type ReasonRow struct {
ID string
TextEn string
TextRu string
CreatedAt string
}
// MessageView is the result page shown after a POST action.
type MessageView struct {
Heading string
+62
View File
@@ -378,6 +378,68 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
return MoveResult{Move: rec, Game: post}, nil
}
// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game
// it has opened but no opponent has joined, returning how many it acted on. It is the game-side
// effect of an admin block: the blocked player instantly loses each live game (the opponent
// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game.
// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to
// call while play continues and is idempotent — an already-resigned or finished game is skipped.
// A per-game failure is logged and skipped so one bad game does not strand the rest; the
// turn-timeout sweeper remains the backstop for anything missed in a race.
func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) {
games, err := svc.store.ListGamesForAccount(ctx, accountID)
if err != nil {
return 0, err
}
count := 0
for _, g := range games {
if g.Status != StatusActive && g.Status != StatusOpen {
continue // a finished game holds no turn to forfeit
}
acted, err := svc.forfeitOne(ctx, g.ID, accountID)
if err != nil {
svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err))
continue
}
if acted {
count++
}
}
return count, nil
}
// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match
// game still awaiting an opponent. It reports whether it changed the game. A game that has already
// finished, or in which the account no longer holds a seat, is a benign no-op.
func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) {
_, err := svc.Resign(ctx, gameID, accountID)
switch {
case err == nil:
return true, nil
case errors.Is(err, ErrNoOpponentYet):
// An open game with no opponent cannot be resigned (there is no one to award the win), so
// delete it to clear it from the matchmaking pool. If it filled in the race window it is
// now active, so resign the seat instead.
deleted, derr := svc.store.DeleteOpenGame(ctx, gameID)
if derr != nil {
return false, derr
}
if deleted {
svc.cache.remove(gameID)
return true, nil
}
if _, err := svc.Resign(ctx, gameID, accountID); err != nil &&
!errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) {
return false, err
}
return true, nil
case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound):
return false, nil
default:
return false, err
}
}
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
// indices to concrete letters before delegating to the letter-based play, exchange and
// word-check methods, keeping a single domain path shared with the robot.
+20
View File
@@ -354,6 +354,26 @@ func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, err
return len(rows) > 0, nil
}
// DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an
// opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade
// away with it. It no-ops (false) once the game has filled and become active, so a caller can
// fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking.
func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) {
stmt := table.Games.DELETE().WHERE(
table.Games.GameID.EQ(postgres.UUID(gameID)).
AND(table.Games.Status.EQ(postgres.String(StatusOpen))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("game: delete open game %s: %w", gameID, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err)
}
return n > 0, nil
}
// ListGamesForAccount loads every game the account is seated in (active and
// finished), newest first, each joined with its ordered seats. It backs the lobby's
// "my games" lists.
+123
View File
@@ -0,0 +1,123 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/game"
)
// TestForfeitAllResignsActiveGame checks the game-side effect of a block on a two-player game:
// ForfeitAllForAccount resigns the blocked player, finishing the game with the opponent winning.
func TestForfeitAllResignsActiveGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
gid, seats := newGameWithSeats(t, 2) // seats[0] = blocked, seats[1] = opponent
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
g, err := svc.GameByID(ctx, gid)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g.Status != game.StatusFinished {
t.Errorf("status = %q, want finished", g.Status)
}
if w := seatWinner(g, seats[0]); w {
t.Error("the blocked player must not be the winner")
}
if w := seatWinner(g, seats[1]); !w {
t.Error("the opponent must win when the blocked player forfeits")
}
}
// TestForfeitAllCancelsOpenGame checks that a block deletes the blocked player's open auto-match
// game (no opponent to resign against), clearing it from the matchmaking pool.
func TestForfeitAllCancelsOpenGame(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
n, err := svc.ForfeitAllForAccount(ctx, starter)
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
if c := gameRowCount(t, g.ID); c != 0 {
t.Errorf("open game rows after forfeit = %d, want 0 (deleted)", c)
}
}
// TestForfeitAllResignsSeatInMultiplayerGame checks that in a 3-player game the blocked player's
// seat is resigned while the game continues for the remaining players.
func TestForfeitAllResignsSeatInMultiplayerGame(t *testing.T) {
ctx := context.Background()
svc := newGameService()
gid, seats := newGameWithSeats(t, 3)
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
if err != nil {
t.Fatalf("forfeit all: %v", err)
}
if n < 1 {
t.Fatalf("forfeit count = %d, want at least 1", n)
}
g, err := svc.GameByID(ctx, gid)
if err != nil {
t.Fatalf("get game: %v", err)
}
if g.Status != game.StatusActive {
t.Errorf("status = %q, want still active (two seats remain)", g.Status)
}
if c := resignMoveCount(t, gid, 0); c != 1 {
t.Errorf("resign moves for seat 0 = %d, want 1", c)
}
}
// seatWinner reports the is_winner flag for the given account's seat in g.
func seatWinner(g game.Game, accountID uuid.UUID) bool {
for _, s := range g.Seats {
if s.AccountID == accountID {
return s.IsWinner
}
}
return false
}
// gameRowCount counts the rows in the games table for gameID (0 once it is deleted).
func gameRowCount(t *testing.T, gameID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.games WHERE game_id = $1`, gameID).Scan(&n); err != nil {
t.Fatalf("count game rows: %v", err)
}
return n
}
// resignMoveCount counts the resign moves journalled for a seat in a game.
func resignMoveCount(t *testing.T, gameID uuid.UUID, seat int) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1 AND action = 'resign' AND seat = $2`,
gameID, seat).Scan(&n); err != nil {
t.Fatalf("count resign moves: %v", err)
}
return n
}
@@ -0,0 +1,121 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
)
// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user
// records the suspension and forfeits their active game, the user card renders both branches,
// and unblocking lifts it.
func TestConsoleBlockAndUnblock(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
games := newGameService()
srv := consoleServer(t, accounts, games)
gid, seats := newGameWithSeats(t, 2)
blocked := seats[0]
// The card renders the not-blocked branch before any block.
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
t.Fatalf("user card before block = %d, want 200", rec.Code)
}
// Block permanently via the console.
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK {
t.Fatalf("console block = %d, want 200", rec.Code)
}
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok {
t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err)
}
if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished {
t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err)
}
// The card renders the blocked branch.
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
t.Fatalf("user card after block = %d, want 200", rec.Code)
}
// Unblock via the console.
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK {
t.Fatalf("console unblock = %d, want 200", rec.Code)
}
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok {
t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err)
}
}
// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders.
func TestConsoleReasonsCRUD(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := consoleServer(t, accounts, newGameService())
if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK {
t.Fatalf("reasons page = %d, want 200", rec.Code)
}
form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}}
if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK {
t.Fatalf("console create reason = %d, want 200", rec.Code)
}
reasons, err := accounts.ListReasons(ctx)
if err != nil {
t.Fatalf("list reasons: %v", err)
}
found := false
for _, r := range reasons {
if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" {
found = true
}
}
if !found {
t.Error("reason created via the console was not found")
}
}
// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a
// registry).
func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server {
t.Helper()
return server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: accounts,
Games: games,
Registry: testRegistry,
DictDir: dictDir(),
})
}
// consoleGet issues a console GET (a safe method, so the same-origin guard passes).
func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil))
return rec
}
// consolePost issues a same-origin form POST to the console.
func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "http://example.com")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
@@ -0,0 +1,151 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
)
// blockStatusBody mirrors the backend's /block-status JSON for the test.
type blockStatusBody struct {
Blocked bool `json:"blocked"`
Permanent bool `json:"permanent"`
Until string `json:"until"`
Reason string `json:"reason"`
}
// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked
// account is refused on a normal user route with 403 + account_blocked, the block-status probe
// stays reachable and resolves the reason to the account's language, and an unblock restores
// access.
func TestSuspensionGate(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: accounts,
})
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
if err != nil {
t.Fatalf("provision: %v", err)
}
id := acc.ID
// Not blocked: a normal route passes and block-status reports false.
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
t.Fatalf("profile before block = %d, want 200", rec.Code)
}
if bs := blockStatus(t, srv, id); bs.Blocked {
t.Fatal("fresh account should not be blocked")
}
// Block permanently with a reason.
if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
// A normal route is now refused with the stable code.
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusForbidden {
t.Fatalf("profile while blocked = %d, want 403", rec.Code)
}
if code := errorCode(t, rec); code != "account_blocked" {
t.Fatalf("error code = %q, want account_blocked", code)
}
// The block-status probe stays reachable and resolves the reason to the account's language.
bs := blockStatus(t, srv, id)
if !bs.Blocked || !bs.Permanent {
t.Fatalf("block-status = %+v, want blocked+permanent", bs)
}
if bs.Reason != "Спам" {
t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason)
}
if bs.Until != "" {
t.Errorf("until = %q, want empty for a permanent block", bs.Until)
}
// Unblock restores access.
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
t.Fatalf("profile after unblock = %d, want 200", rec.Code)
}
}
// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through
// block-status as a future RFC3339 instant and not permanent.
func TestSuspensionGateTemporaryUntil(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
id := provisionAccount(t)
until := time.Now().Add(24 * time.Hour)
if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
bs := blockStatus(t, srv, id)
if !bs.Blocked || bs.Permanent {
t.Fatalf("block-status = %+v, want blocked, not permanent", bs)
}
parsed, err := time.Parse(time.RFC3339, bs.Until)
if err != nil {
t.Fatalf("until %q is not RFC3339: %v", bs.Until, err)
}
if !parsed.After(time.Now()) {
t.Errorf("until = %v, want a future instant", parsed)
}
}
// userGet issues an authenticated GET (X-User-ID) against the assembled server.
func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-User-ID", id.String())
srv.Handler().ServeHTTP(rec, req)
return rec
}
// blockStatus fetches and decodes the /block-status payload, asserting a 200.
func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/block-status", id)
if rec.Code != http.StatusOK {
t.Fatalf("block-status status = %d, want 200", rec.Code)
}
var b blockStatusBody
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
t.Fatalf("decode block-status: %v", err)
}
return b
}
// errorCode extracts the stable code from the backend error envelope.
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var e struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
return e.Error.Code
}
+209
View File
@@ -0,0 +1,209 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with
// a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per
// language), and LiftSuspension reverses it.
func TestSuspensionRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
}
if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err)
}
if !susp.Permanent() {
t.Errorf("Permanent() = false, want true for a nil-until block")
}
if got := susp.LocalizedReason("en"); got != "Spam" {
t.Errorf("LocalizedReason(en) = %q, want Spam", got)
}
if got := susp.LocalizedReason("ru"); got != "Спам" {
t.Errorf("LocalizedReason(ru) = %q, want Спам", got)
}
if got := susp.LocalizedReason("fr"); got != "Spam" {
t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got)
}
if err := store.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not
// permanent), while a past expiry is already not in force.
func TestTemporarySuspensionExpiry(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
future := provisionAccount(t)
until := time.Now().Add(time.Hour)
if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil {
t.Fatalf("suspend future: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, future)
if err != nil || !ok {
t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if susp.Permanent() {
t.Error("Permanent() = true, want false for a dated block")
}
if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) {
t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil)
}
past := provisionAccount(t)
expired := time.Now().Add(-time.Hour)
if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil {
t.Fatalf("suspend past: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok {
t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the
// strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all.
func TestSuspensionStrongestWins(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
soon := time.Now().Add(time.Hour)
if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil {
t.Fatalf("suspend dated: %v", err)
}
if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend permanent: %v", err)
}
later := time.Now().Add(2 * time.Hour)
if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil {
t.Fatalf("suspend dated-later: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if !susp.Permanent() {
t.Error("strongest block should be the permanent one")
}
if err := store.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
}
}
// TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and
// the ErrNotFound paths.
func TestReasonsCRUD(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
created, err := store.CreateReason(ctx, "Cheating", "Читерство")
if err != nil {
t.Fatalf("create reason: %v", err)
}
if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" {
t.Fatalf("created reason = %+v, want populated en/ru and id", created)
}
got, err := store.GetReason(ctx, created.ID)
if err != nil {
t.Fatalf("get reason: %v", err)
}
if got.TextEn != "Cheating" {
t.Errorf("get TextEn = %q, want Cheating", got.TextEn)
}
list, err := store.ListReasons(ctx)
if err != nil {
t.Fatalf("list reasons: %v", err)
}
if !containsReason(list, created.ID) {
t.Error("created reason missing from ListReasons")
}
updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления")
if err != nil {
t.Fatalf("update reason: %v", err)
}
if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" {
t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated)
}
if err := store.DeleteReason(ctx, created.ID); err != nil {
t.Fatalf("delete reason: %v", err)
}
if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) {
t.Errorf("get deleted reason = %v, want ErrNotFound", err)
}
if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) {
t.Errorf("update missing reason = %v, want ErrNotFound", err)
}
}
// TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was
// stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot
// stays).
func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
id := provisionAccount(t)
reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность")
if err != nil {
t.Fatalf("create reason: %v", err)
}
if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil {
t.Fatalf("suspend with reason: %v", err)
}
if err := store.DeleteReason(ctx, reason.ID); err != nil {
t.Fatalf("delete reason: %v", err)
}
susp, ok, err := store.CurrentSuspension(ctx, id)
if err != nil || !ok {
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
}
if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" {
t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru"))
}
}
// containsReason reports whether list holds a reason with the given id.
func containsReason(list []account.Reason, id uuid.UUID) bool {
for _, r := range list {
if r.ID == id {
return true
}
}
return false
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountSuspensions struct {
SuspensionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
BlockedAt time.Time
BlockedUntil *time.Time
ReasonEn *string
ReasonRu *string
ReasonID *uuid.UUID
LiftedAt *time.Time
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type SuspensionReasons struct {
ReasonID uuid.UUID `sql:"primary_key"`
TextEn string
TextRu string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountSuspensions = newAccountSuspensionsTable("backend", "account_suspensions", "")
type accountSuspensionsTable struct {
postgres.Table
// Columns
SuspensionID postgres.ColumnString
AccountID postgres.ColumnString
BlockedAt postgres.ColumnTimestampz
BlockedUntil postgres.ColumnTimestampz
ReasonEn postgres.ColumnString
ReasonRu postgres.ColumnString
ReasonID postgres.ColumnString
LiftedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountSuspensionsTable struct {
accountSuspensionsTable
EXCLUDED accountSuspensionsTable
}
// AS creates new AccountSuspensionsTable with assigned alias
func (a AccountSuspensionsTable) AS(alias string) *AccountSuspensionsTable {
return newAccountSuspensionsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountSuspensionsTable with assigned schema name
func (a AccountSuspensionsTable) FromSchema(schemaName string) *AccountSuspensionsTable {
return newAccountSuspensionsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountSuspensionsTable with assigned table prefix
func (a AccountSuspensionsTable) WithPrefix(prefix string) *AccountSuspensionsTable {
return newAccountSuspensionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountSuspensionsTable with assigned table suffix
func (a AccountSuspensionsTable) WithSuffix(suffix string) *AccountSuspensionsTable {
return newAccountSuspensionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountSuspensionsTable(schemaName, tableName, alias string) *AccountSuspensionsTable {
return &AccountSuspensionsTable{
accountSuspensionsTable: newAccountSuspensionsTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountSuspensionsTableImpl("", "excluded", ""),
}
}
func newAccountSuspensionsTableImpl(schemaName, tableName, alias string) accountSuspensionsTable {
var (
SuspensionIDColumn = postgres.StringColumn("suspension_id")
AccountIDColumn = postgres.StringColumn("account_id")
BlockedAtColumn = postgres.TimestampzColumn("blocked_at")
BlockedUntilColumn = postgres.TimestampzColumn("blocked_until")
ReasonEnColumn = postgres.StringColumn("reason_en")
ReasonRuColumn = postgres.StringColumn("reason_ru")
ReasonIDColumn = postgres.StringColumn("reason_id")
LiftedAtColumn = postgres.TimestampzColumn("lifted_at")
allColumns = postgres.ColumnList{SuspensionIDColumn, AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn}
defaultColumns = postgres.ColumnList{BlockedAtColumn}
)
return accountSuspensionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
SuspensionID: SuspensionIDColumn,
AccountID: AccountIDColumn,
BlockedAt: BlockedAtColumn,
BlockedUntil: BlockedUntilColumn,
ReasonEn: ReasonEnColumn,
ReasonRu: ReasonRuColumn,
ReasonID: ReasonIDColumn,
LiftedAt: LiftedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var SuspensionReasons = newSuspensionReasonsTable("backend", "suspension_reasons", "")
type suspensionReasonsTable struct {
postgres.Table
// Columns
ReasonID postgres.ColumnString
TextEn postgres.ColumnString
TextRu postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type SuspensionReasonsTable struct {
suspensionReasonsTable
EXCLUDED suspensionReasonsTable
}
// AS creates new SuspensionReasonsTable with assigned alias
func (a SuspensionReasonsTable) AS(alias string) *SuspensionReasonsTable {
return newSuspensionReasonsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new SuspensionReasonsTable with assigned schema name
func (a SuspensionReasonsTable) FromSchema(schemaName string) *SuspensionReasonsTable {
return newSuspensionReasonsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new SuspensionReasonsTable with assigned table prefix
func (a SuspensionReasonsTable) WithPrefix(prefix string) *SuspensionReasonsTable {
return newSuspensionReasonsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new SuspensionReasonsTable with assigned table suffix
func (a SuspensionReasonsTable) WithSuffix(suffix string) *SuspensionReasonsTable {
return newSuspensionReasonsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newSuspensionReasonsTable(schemaName, tableName, alias string) *SuspensionReasonsTable {
return &SuspensionReasonsTable{
suspensionReasonsTable: newSuspensionReasonsTableImpl(schemaName, tableName, alias),
EXCLUDED: newSuspensionReasonsTableImpl("", "excluded", ""),
}
}
func newSuspensionReasonsTableImpl(schemaName, tableName, alias string) suspensionReasonsTable {
var (
ReasonIDColumn = postgres.StringColumn("reason_id")
TextEnColumn = postgres.StringColumn("text_en")
TextRuColumn = postgres.StringColumn("text_ru")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{ReasonIDColumn, TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn}
)
return suspensionReasonsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ReasonID: ReasonIDColumn,
TextEn: TextEnColumn,
TextRu: TextRuColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -11,6 +11,7 @@ package table
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountStats = AccountStats.FromSchema(schema)
AccountSuspensions = AccountSuspensions.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
Blocks = Blocks.FromSchema(schema)
ChatMessages = ChatMessages.FromSchema(schema)
@@ -28,4 +29,5 @@ func UseSchema(schema string) {
Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema)
SuspensionReasons = SuspensionReasons.FromSchema(schema)
}
@@ -0,0 +1,45 @@
-- +goose Up
-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft,
-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema
-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another);
-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to
-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until
-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12.
SET search_path = backend, pg_catalog;
-- The operator-editable reason picklist, one row per reason with its English and Russian text.
-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or
-- deleting a reason here never changes or breaks a reason already shown to a blocked player.
CREATE TABLE suspension_reasons (
reason_id uuid PRIMARY KEY,
text_en text NOT NULL,
text_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The block history: one row per block. blocked_until is NULL for a permanent block and the
-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early.
-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was
-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled
-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An
-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS
-- NULL OR blocked_until > now()).
CREATE TABLE account_suspensions (
suspension_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_at timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
reason_en text,
reason_ru text,
reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL,
lifted_at timestamptz
);
-- The enforcement gate looks up the newest suspension for an account on every authenticated
-- request; this index serves that "latest by account" probe.
CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_suspensions;
DROP TABLE IF EXISTS suspension_reasons;
+3
View File
@@ -46,6 +46,9 @@ func (s *Server) registerRoutes() {
u.GET("/profile", s.handleProfile)
u.PUT("/profile", s.handleUpdateProfile)
u.GET("/stats", s.handleStats)
// Exempt from the suspension gate (see requireNotSuspended): the one endpoint a blocked
// client may still reach, to fetch the block's expiry and reason for the blocked screen.
u.GET("/block-status", s.handleBlockStatus)
}
if s.links != nil {
// Account linking & merge. The request step always mails a code;
@@ -86,6 +86,48 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
c.JSON(http.StatusOK, profileResponseFor(acc))
}
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is
// the operator-set reason resolved to the account's language, empty when none was cited. It is
// the one payload a blocked client can still fetch, behind the suspension gate's exemption.
type blockStatusResponse struct {
Blocked bool `json:"blocked"`
Permanent bool `json:"permanent"`
Until string `json:"until,omitempty"`
Reason string `json:"reason,omitempty"`
}
// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the
// reason resolved to the account's language. The suspension gate exempts this route so a blocked
// client can render the terminal blocked screen.
func (s *Server) handleBlockStatus(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
resp := blockStatusResponse{Blocked: blocked}
if blocked {
resp.Permanent = susp.Permanent()
if susp.BlockedUntil != nil {
resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339)
}
// Resolve the reason snapshot to the account's interface language; fall back to English
// if the account row cannot be read for some reason.
lang := "en"
if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil {
lang = acc.PreferredLanguage
}
resp.Reason = susp.LocalizedReason(lang)
}
c.JSON(http.StatusOK, resp)
}
// handleStats returns the caller's lifetime statistics.
func (s *Server) handleStats(c *gin.Context) {
uid, ok := userID(c)
@@ -51,6 +51,12 @@ 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/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
gm.GET("/throttled", s.consoleThrottled)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
@@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
view.MoveChart = adminconsole.MoveDurationChart(cps)
}
if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked {
view.Suspension = adminconsole.SuspensionView{
Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt),
ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu,
}
if susp.BlockedUntil != nil {
view.Suspension.Until = fmtTime(*susp.BlockedUntil)
}
}
if reasons, err := s.accounts.ListReasons(ctx); err == nil {
for _, r := range reasons {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -754,6 +774,157 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// 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.
func (s *Server) consoleBlockUser(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until"))
if !ok {
s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back)
return
}
var reasonEn, reasonRu string
var reasonID *uuid.UUID
if rid := trimForm(c, "reason"); rid != "" {
parsed, err := uuid.Parse(rid)
if err != nil {
s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back)
return
}
reason, err := s.accounts.GetReason(ctx, parsed)
if err != nil {
s.consoleError(c, err)
return
}
reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID
}
if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil {
s.consoleError(c, err)
return
}
forfeited, err := s.games.ForfeitAllForAccount(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}
// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at
// block time are not restored.
func (s *Server) consoleUnblockUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
now := time.Now().UTC()
switch choice {
case "permanent":
return nil, true
case "1d":
t := now.AddDate(0, 0, 1)
return &t, true
case "3d":
t := now.AddDate(0, 0, 3)
return &t, true
case "1w":
t := now.AddDate(0, 0, 7)
return &t, true
case "1m":
t := now.AddDate(0, 1, 0)
return &t, true
case "custom":
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, custom); err == nil {
if !t.After(now) {
return nil, false
}
return &t, true
}
}
return nil, false
default:
return nil, false
}
}
// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
reasons, err := s.accounts.ListReasons(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
var view adminconsole.ReasonsView
for _, r := range reasons {
view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
}
s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}
// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}
// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}
// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
+37
View File
@@ -77,3 +77,40 @@ func sameOrigin(r *http.Request) bool {
}
return false
}
// codeAccountBlocked is the stable error code the suspension gate returns for a blocked account.
// It threads through the gateway unchanged as the Execute result_code, so the UI can detect the
// block from any call and switch to the terminal blocked screen.
const codeAccountBlocked = "account_blocked"
// blockStatusPath is the one /api/v1/user route exempt from the suspension gate: a blocked client
// must still reach it to fetch the block's expiry and reason for the blocked screen.
const blockStatusPath = "/api/v1/user/block-status"
// requireNotSuspended returns middleware that rejects a blocked account's requests with 403 and
// code "account_blocked", so the UI can detect an active block from any call. The block-status
// probe is exempt. It is a no-op when the account store is not wired. It runs after RequireUserID
// (which has already placed the account id in the context).
func (s *Server) requireNotSuspended() gin.HandlerFunc {
return func(c *gin.Context) {
if s.accounts == nil || c.FullPath() == blockStatusPath {
c.Next()
return
}
id, ok := userID(c)
if !ok {
c.Next() // RequireUserID runs first and has already rejected a missing id
return
}
_, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), id)
if err != nil {
s.abortErr(c, err)
return
}
if blocked {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: codeAccountBlocked, Message: "account is blocked"}})
return
}
c.Next()
}
}
+3
View File
@@ -185,6 +185,9 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.public = v1.Group("/public")
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
// The suspension gate runs after identity is established: a blocked account is refused on
// every user route (except the block-status probe) so the UI can show the blocked screen.
s.user.Use(s.requireNotSuspended())
s.internal = v1.Group("/internal")
}