Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 13s

Server-rendered admin console in the backend at /_gm (internal/adminconsole),
fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy
(mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs;
no operator identity is tracked. This supersedes the Stage 6 gateway-fronts-
/api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are
dropped and gateway/internal/admin is repurposed to the verbatim proxy.

- Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/
  resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a
  query-derived pending dictionary-change pipeline (marked applied after a reload).
- Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new
  Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on
  restart. Partially addresses TODO-2.
- Broadcasts: a backend Telegram-connector client (internal/connector,
  BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9
  forward-note).
- Admin reads: account.ListAccounts/CountAccounts/Identities and
  game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/
  ResolveComplaint/DictionaryChanges/MarkChangesApplied.
- Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim
  proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console.
- Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13,
  FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
This commit is contained in:
Ilia Denisov
2026-06-04 09:24:59 +02:00
parent 4c4beace85
commit aafdd46a4b
49 changed files with 2548 additions and 200 deletions
+190 -10
View File
@@ -197,6 +197,53 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// ListGames returns games for the admin games list, most-recently-updated first,
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
// The seats are not loaded — the list shows summaries; the detail view uses
// GetGame.
func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
WHERE(where).
ORDER_BY(table.Games.UpdatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list games: %w", err)
}
out := make([]Game, 0, len(rows))
for _, g := range rows {
pg, err := projectGame(g, nil)
if err != nil {
return nil, err
}
out = append(out, pg)
}
return out, nil
}
// CountGames returns the number of games, optionally restricted to a status, for
// admin-list pagination.
func (s *Store) CountGames(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")).
FROM(table.Games).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count games: %w", err)
}
return int(dest.Count), nil
}
// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns).
@@ -384,6 +431,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
return projectComplaint(row)
}
// ListComplaints returns complaints for the admin review queue, newest first.
// status filters by lifecycle state when non-empty; limit and offset paginate.
func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(where).
ORDER_BY(table.Complaints.CreatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list complaints: %w", err)
}
return projectComplaints(rows)
}
// GetComplaint loads one complaint by id, or ErrNotFound.
func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ResolveComplaint closes a complaint with a disposition and note, stamping
// resolved_at, and returns the updated row (ErrNotFound when none matches). It
// leaves applied_in_version untouched.
func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) {
stmt := table.Complaints.UPDATE(
table.Complaints.Status, table.Complaints.Disposition,
table.Complaints.ResolutionNote, table.Complaints.ResolvedAt,
).SET(
postgres.String(StatusComplaintResolved), postgres.String(disposition),
postgres.String(note), postgres.TimestampzT(now),
).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
RETURNING(table.Complaints.AllColumns)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ListDictionaryChanges returns the resolved, accepted complaints not yet marked
// applied (the pending wordlist edits), ordered by variant then resolution time.
func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
).
ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC())
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list dictionary changes: %w", err)
}
return projectComplaints(rows)
}
// MarkChangesApplied stamps every pending accepted change for variant with
// version (so it drops out of ListDictionaryChanges) and returns the count.
func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) {
stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion).
SET(postgres.String(version)).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Variant.EQ(postgres.String(variant))).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("game: mark changes applied: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
@@ -523,19 +686,36 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
}
return Complaint{
ID: row.ComplaintID,
ComplainantID: row.ComplainantID,
GameID: row.GameID,
Variant: variant,
DictVersion: row.DictVersion,
Word: row.Word,
WasValid: row.WasValid,
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
ID: row.ComplaintID,
ComplainantID: row.ComplainantID,
GameID: row.GameID,
Variant: variant,
DictVersion: row.DictVersion,
Word: row.Word,
WasValid: row.WasValid,
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
Disposition: row.Disposition,
ResolutionNote: row.ResolutionNote,
ResolvedAt: row.ResolvedAt,
AppliedInVersion: row.AppliedInVersion,
}, nil
}
// projectComplaints projects a slice of complaint rows, preserving order.
func projectComplaints(rows []model.Complaints) ([]Complaint, error) {
out := make([]Complaint, 0, len(rows))
for _, r := range rows {
c, err := projectComplaint(r)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)