Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s

A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.

The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.

Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
This commit is contained in:
Ilia Denisov
2026-06-08 19:58:55 +02:00
parent 5928be40b0
commit e01faae28a
12 changed files with 305 additions and 7 deletions
+4 -4
View File
@@ -51,11 +51,11 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := likePattern(f.NameMask); name != "" {
if name := LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := likePattern(f.ExternalIDMask); ext != "" {
if ext := LikePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
@@ -95,9 +95,9 @@ func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
return n, nil
}
// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
func likePattern(mask string) string {
func LikePattern(mask string) string {
mask = strings.TrimSpace(mask)
if mask == "" {
return ""
@@ -26,6 +26,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
@@ -16,6 +16,7 @@
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<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/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>
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>Game {{.ID}}</h1>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a></nav>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Variant</b> {{.Variant}}</li>
@@ -0,0 +1,37 @@
{{define "content" -}}
<h1>Messages</h1>
{{with .Data}}
<form class="form" method="get" action="/_gm/messages">
{{if .GameID}}<input type="hidden" name="game" value="{{.GameID}}">{{end}}
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<button type="submit">Filter</button>
</form>
{{if or .GameID .UserID}}
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a></nav>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
<div class="cards">
<section class="panel"><h2>Account</h2>
<ul class="kv">
+26
View File
@@ -73,6 +73,32 @@ type UserRow struct {
MoveMax string
}
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
// game or user card); FilterQuery is the active filters encoded for the pager links.
type MessagesView struct {
Items []MessageRow
Pager Pager
NameMask string
ExtMask string
GameID string
UserID string
FilterQuery string
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
type MessageRow struct {
ID string
SenderID string
SenderName string
Source string
IP string
Body string
GameID string
CreatedAt string
}
// UserDetailView is one account with its stats, identities and recent games.
type UserDetailView struct {
ID string
+51
View File
@@ -383,3 +383,54 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
}
}
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil {
t.Fatalf("post: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
t.Fatalf("nudge: %v", err)
}
// Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded.
msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0)
if err != nil {
t.Fatalf("admin list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs))
}
if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" {
t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m)
}
if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity
t.Errorf("source = %q, want telegram", msgs[0].Source)
}
if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 {
t.Errorf("count = %d, want 1", n)
}
// Sender pin: seat 0 has the message; seat 1 has only a nudge.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 {
t.Error("sender=seat0 returned nothing")
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 {
t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got))
}
// Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude.
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 {
t.Errorf("ext mask tg-* = %d, want 1", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 {
t.Errorf("ext mask zzz-* = %d, want 0", len(got))
}
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 {
t.Errorf("name mask miss = %d, want 0", len(got))
}
}
@@ -19,6 +19,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// adminPageSize is the page size of the admin console's paginated lists.
@@ -51,6 +52,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
gm.GET("/messages", s.consoleMessages)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
@@ -130,6 +132,60 @@ func (s *Server) consoleUsers(c *gin.Context) {
s.renderConsole(c, "users", "users", "Users", view)
}
// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
func (s *Server) consoleMessages(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.GameID != uuid.Nil {
q.Set("game", filter.GameID.String())
}
if filter.SenderID != uuid.Nil {
q.Set("user", filter.SenderID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
FilterQuery: q.Encode(),
}
if filter.GameID != uuid.Nil {
view.GameID = filter.GameID.String()
}
if filter.SenderID != uuid.Nil {
view.UserID = filter.SenderID.String()
}
for _, m := range items {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()
+113
View File
@@ -0,0 +1,113 @@
package social
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// AdminMessage is one chat message in the admin moderation list (Stage 17): the message
// plus its sender's resolved display name and source, for the operator console.
type AdminMessage struct {
ID uuid.UUID
GameID uuid.UUID
SenderID uuid.UUID
SenderName string
// Source is the sender's account kind: "guest", "robot", or its oldest identity kind
// (e.g. "email", "telegram"); "—" when it has none.
Source string
Body string
SenderIP string
CreatedAt time.Time
}
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
// case-insensitively against the sender's display name / any identity's external id.
type AdminMessageFilter struct {
GameID uuid.UUID
SenderID uuid.UUID
NameMask string
ExtMask string
}
// AdminListMessages returns the filtered chat messages — real messages only, nudges
// excluded — newest first, paginated, for the admin moderation console.
func (svc *Service) AdminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
return svc.store.adminListMessages(ctx, f, limit, offset)
}
// AdminCountMessages counts the filtered chat messages, for the admin list pager.
func (svc *Service) AdminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
return svc.store.adminCountMessages(ctx, f)
}
// adminMessageSource is the SQL CASE projecting a sender's source: guest, robot, or its
// oldest identity kind ("—" when it has none).
const adminMessageSource = `CASE
WHEN a.is_guest THEN 'guest'
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
END`
// adminMessageWhere builds the shared WHERE clause and its positional args (from $1).
// Only real messages are listed; nudges are excluded.
func adminMessageWhere(f AdminMessageFilter) (string, []any) {
where := `m.kind = 'message'`
var args []any
if f.GameID != uuid.Nil {
args = append(args, f.GameID)
where += fmt.Sprintf(` AND m.game_id = $%d`, len(args))
}
if f.SenderID != uuid.Nil {
args = append(args, f.SenderID)
where += fmt.Sprintf(` AND m.sender_id = $%d`, len(args))
}
if name := account.LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := account.LikePattern(f.ExtMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
where, args := adminMessageWhere(f)
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at
FROM backend.chat_messages m
JOIN backend.accounts a ON a.account_id = m.sender_id
WHERE ` + where +
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("social: admin list messages: %w", err)
}
defer rows.Close()
var out []AdminMessage
for rows.Next() {
var m AdminMessage
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("social: scan admin message: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
where, args := adminMessageWhere(f)
var n int
q := `SELECT COUNT(*) FROM backend.chat_messages m JOIN backend.accounts a ON a.account_id = m.sender_id WHERE ` + where
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("social: admin count messages: %w", err)
}
return n, nil
}