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 }