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
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:
@@ -1378,6 +1378,15 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
||||
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
|
||||
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
|
||||
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -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">« games</a></nav>
|
||||
<nav class="subnav"><a href="/_gm/games">« 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}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
|
||||
<div class="cards">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -501,3 +501,54 @@ func TestRespondPublishesToRequester(t *testing.T) {
|
||||
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user