Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -420,6 +420,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU
|
||||
})
|
||||
}
|
||||
|
||||
// ListComplaints returns word-check complaints for the admin review queue,
|
||||
// newest first. status filters by lifecycle state ("" = all); limit is clamped
|
||||
// to a sane page size and offset is floored at zero.
|
||||
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
|
||||
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
|
||||
}
|
||||
|
||||
// GetComplaint loads a single complaint for the admin detail view.
|
||||
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
|
||||
return svc.store.GetComplaint(ctx, id)
|
||||
}
|
||||
|
||||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||||
// status, for the admin queue pager and the dashboard counts.
|
||||
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
|
||||
return svc.store.CountComplaints(ctx, status)
|
||||
}
|
||||
|
||||
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||||
// change is marked applied. It returns ErrInvalidConfig for an unknown
|
||||
// disposition and ErrNotFound when no complaint matches.
|
||||
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
|
||||
if !validDisposition(disposition) {
|
||||
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
|
||||
}
|
||||
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
|
||||
}
|
||||
|
||||
// DictionaryChanges returns the pending wordlist edits implied by resolved,
|
||||
// accepted complaints not yet marked applied — the input to the offline DAWG
|
||||
// rebuild.
|
||||
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
|
||||
rows, err := svc.store.ListDictionaryChanges(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DictionaryChange, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
ch := DictionaryChange{
|
||||
ComplaintID: c.ID,
|
||||
Variant: c.Variant,
|
||||
Word: c.Word,
|
||||
Add: c.Disposition == DispositionAcceptAdd,
|
||||
Note: c.Note,
|
||||
}
|
||||
if c.ResolvedAt != nil {
|
||||
ch.ResolvedAt = *c.ResolvedAt
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkChangesApplied records that every pending accepted change for variant has
|
||||
// been folded into the dictionary version that was just hot-reloaded, removing
|
||||
// them from DictionaryChanges. It returns the number of changes marked.
|
||||
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
@@ -583,6 +645,23 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
// GameByID returns a game with its seats for the admin console detail view.
|
||||
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
return svc.store.GetGame(ctx, id)
|
||||
}
|
||||
|
||||
// ListGames returns games for the admin list, newest-updated first, paginated,
|
||||
// optionally filtered by status.
|
||||
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
|
||||
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
|
||||
}
|
||||
|
||||
// CountGames returns the game count, optionally filtered by status, for the admin
|
||||
// list pager and dashboard.
|
||||
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
|
||||
return svc.store.CountGames(ctx, status)
|
||||
}
|
||||
|
||||
// History returns a game's full, dictionary-independent move journal.
|
||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
@@ -770,6 +849,29 @@ func normalizeWord(word string) string {
|
||||
return strings.ToLower(strings.TrimSpace(word))
|
||||
}
|
||||
|
||||
// validDisposition reports whether d is an accepted complaint disposition.
|
||||
func validDisposition(d string) bool {
|
||||
switch d {
|
||||
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
|
||||
// (non-positive) request to 50.
|
||||
func clampPageSize(limit int) int {
|
||||
switch {
|
||||
case limit <= 0:
|
||||
return 50
|
||||
case limit > 200:
|
||||
return 200
|
||||
default:
|
||||
return limit
|
||||
}
|
||||
}
|
||||
|
||||
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
|
||||
// system source fails.
|
||||
func randomSeed() int64 {
|
||||
|
||||
+190
-10
@@ -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)
|
||||
|
||||
@@ -15,9 +15,23 @@ const (
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
|
||||
// ever writes StatusComplaintOpen.
|
||||
const StatusComplaintOpen = "open"
|
||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
|
||||
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
|
||||
// Disposition. The CHECK constraints live in migration 00008.
|
||||
const (
|
||||
StatusComplaintOpen = "open"
|
||||
StatusComplaintResolved = "resolved"
|
||||
)
|
||||
|
||||
// Complaint dispositions chosen at resolution. DispositionReject keeps the
|
||||
// dictionary as-is; DispositionAcceptAdd / DispositionAcceptRemove mark the word
|
||||
// for addition to / removal from the variant's wordlist and feed the offline
|
||||
// dictionary-rebuild pipeline (see DictionaryChange).
|
||||
const (
|
||||
DispositionReject = "reject"
|
||||
DispositionAcceptAdd = "accept_add"
|
||||
DispositionAcceptRemove = "accept_remove"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
|
||||
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
||||
@@ -179,7 +193,9 @@ type RobotTurn struct {
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// Complaint is a word-check complaint awaiting admin review (Stage 10).
|
||||
// Complaint is a word-check complaint in the admin review queue. It is filed
|
||||
// against a game's pinned (Variant, DictVersion) with the lookup result at filing
|
||||
// time (WasValid); the resolution fields stay empty until an operator resolves it.
|
||||
type Complaint struct {
|
||||
ID uuid.UUID
|
||||
ComplainantID uuid.UUID
|
||||
@@ -191,4 +207,24 @@ type Complaint struct {
|
||||
Note string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
|
||||
// Resolution fields, set when Status == StatusComplaintResolved.
|
||||
Disposition string // "" while open; otherwise a Disposition* value
|
||||
ResolutionNote string // operator note recorded at resolution
|
||||
ResolvedAt *time.Time // nil while open
|
||||
AppliedInVersion string // dict version an accepted change was folded into ("" = pending)
|
||||
}
|
||||
|
||||
// DictionaryChange is the wordlist edit implied by one resolved, accepted
|
||||
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
|
||||
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
|
||||
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
|
||||
// dictionary version is hot-reloaded they are marked applied.
|
||||
type DictionaryChange struct {
|
||||
ComplaintID uuid.UUID
|
||||
Variant engine.Variant
|
||||
Word string
|
||||
Add bool
|
||||
ResolvedAt time.Time
|
||||
Note string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user