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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user