Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
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 6b6baf5710
commit 356f490546
12 changed files with 305 additions and 7 deletions
@@ -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