8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
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: 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
|
|
}
|