356f490546
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.
114 lines
4.3 KiB
Go
114 lines
4.3 KiB
Go
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
|
|
}
|