Files
scrabble-game/backend/internal/social/adminchat.go
T
Ilia Denisov 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
Stage 17 round 6 (#18, PR D): admin Messages moderation section
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.
2026-06-08 20:10:27 +02:00

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
}