diff --git a/PLAN.md b/PLAN.md
index 4bfa5ef..6530e68 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1348,6 +1348,15 @@ provided cert) at the contour caddy; prod VPN; rollback.
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
+ - **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
+ posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
+ oldest identity kind) · sender (→ user card) · IP · body · game (→ 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. Server-rendered (`adminconsole` `MessagesView` +
+ `messages.gohtml`, 50/page via the shared pager); the list query lives in `social` (raw SQL,
+ `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
+ glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
+ Users section), a top-level nav entry plus the card deep-links.
## Deferred TODOs (cross-stage)
diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go
index e2c12fc..b2bd372 100644
--- a/backend/internal/account/userlist.go
+++ b/backend/internal/account/userlist.go
@@ -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 ""
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
index 39778d2..ab98a2c 100644
--- a/backend/internal/adminconsole/render_test.go
+++ b/backend/internal/adminconsole/render_test.go
@@ -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"},
diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml
index c7e4946..970d876 100644
--- a/backend/internal/adminconsole/templates/layout.gohtml
+++ b/backend/internal/adminconsole/templates/layout.gohtml
@@ -16,6 +16,7 @@
Users
Games
Complaints
+ Messages
Dictionary
Broadcast
Grafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
index 976d691..068b1a6 100644
--- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
Game {{.ID}}
-
+
Summary
- Variant {{.Variant}}
diff --git a/backend/internal/adminconsole/templates/pages/messages.gohtml b/backend/internal/adminconsole/templates/pages/messages.gohtml
new file mode 100644
index 0000000..7c985f5
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/messages.gohtml
@@ -0,0 +1,37 @@
+{{define "content" -}}
+Messages
+{{with .Data}}
+
+{{if or .GameID .UserID}}
+Filtered{{if .GameID}} to game {{.GameID}}{{end}}{{if .UserID}} from sender{{end}} · clear
+{{end}}
+
+| Time | Source | Sender | IP | Message | Game |
+
+{{range .Items}}
+
+| {{.CreatedAt}} |
+{{.Source}} |
+{{.SenderName}} |
+{{.IP}} |
+{{.Body}} |
+game |
+
+{{else}}
+| no messages |
+{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index eb0f664..08b75b2 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
{{.DisplayName}}
-
+
Account
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index cb92d26..1ef4aed 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -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
diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go
index 33908fa..c2bce6f 100644
--- a/backend/internal/inttest/social_test.go
+++ b/backend/internal/inttest/social_test.go
@@ -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))
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 5631c8b..c1a0b3f 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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()
diff --git a/backend/internal/social/adminchat.go b/backend/internal/social/adminchat.go
new file mode 100644
index 0000000..cfeb9f2
--- /dev/null
+++ b/backend/internal/social/adminchat.go
@@ -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
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 404d898..de97006 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -375,7 +375,11 @@ English game the Latin pool.
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
not contact exchange. Each message stores the sender's IP (forwarded by the
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
- and messages from a blocked sender are hidden from the viewer.
+ and messages from a blocked sender are hidden from the viewer. The operator console
+ has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
+ newest-first with the sender's resolved name, **source** (guest / robot / oldest
+ identity kind), IP and game, searchable by sender name / external-id glob masks and
+ pinnable to one game or sender (linked from the game and user cards).
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform