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