package social import ( "context" "database/sql" "errors" "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 // UnreadSeats is the message's unread bitmask; non-zero means at least one recipient // has not read it. The console shows a plain read/unread flag derived from it. UnreadSeats int16 } // 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; // UnreadOnly keeps only messages still unread by at least one recipient. type AdminMessageFilter struct { GameID uuid.UUID SenderID uuid.UUID NameMask string ExtMask string UnreadOnly bool } // 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)) } if f.UnreadOnly { where += ` AND m.unread_seats <> 0` } 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, m.unread_seats 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, &m.UnreadSeats); 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 } // AdminSeatRead is one game seat's status for a message's per-seat read breakdown: the // seat index, its occupant's display name, and its role for the message — "sender", // "read" or "unread". Still-empty (open) seats are omitted. type AdminSeatRead struct { Seat int AccountID uuid.UUID DisplayName string Role string } // AdminMessageCard is one chat message with the per-seat read breakdown, for the // console's message detail page. type AdminMessageCard struct { AdminMessage Kind string Seats []AdminSeatRead } // AdminMessageDetail loads one chat message and the read status of every seated player // in its game, for the moderation message card. The per-seat status is derived from the // message's unread bitmask (a set bit is an unread recipient seat); the sender's own // seat is marked "sender". func (svc *Service) AdminMessageDetail(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) { return svc.store.adminMessageCard(ctx, messageID) } func (s *Store) adminMessageCard(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) { var d AdminMessageCard q := `SELECT m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.kind, m.body, COALESCE(m.sender_ip, ''), m.created_at, m.unread_seats FROM backend.chat_messages m JOIN backend.accounts a ON a.account_id = m.sender_id WHERE m.message_id = $1` if err := s.db.QueryRowContext(ctx, q, messageID).Scan( &d.GameID, &d.SenderID, &d.SenderName, &d.Source, &d.Kind, &d.Body, &d.SenderIP, &d.CreatedAt, &d.UnreadSeats, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return AdminMessageCard{}, ErrMessageNotFound } return AdminMessageCard{}, fmt.Errorf("social: admin message card: %w", err) } d.ID = messageID rows, err := s.db.QueryContext(ctx, `SELECT p.seat, COALESCE(p.account_id::text, ''), p.display_name FROM backend.game_players p WHERE p.game_id = $1 ORDER BY p.seat`, d.GameID) if err != nil { return AdminMessageCard{}, fmt.Errorf("social: admin message seats: %w", err) } defer rows.Close() for rows.Next() { var ( seat int accStr string seatNam string ) if err := rows.Scan(&seat, &accStr, &seatNam); err != nil { return AdminMessageCard{}, fmt.Errorf("social: scan message seat: %w", err) } if accStr == "" { continue // a still-empty (open) seat has no reader } accID, _ := uuid.Parse(accStr) sr := AdminSeatRead{Seat: seat, AccountID: accID, DisplayName: seatNam} switch { case accID == d.SenderID: sr.Role = "sender" case d.UnreadSeats&(int16(1)<