aaac816dc2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).
UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
199 lines
7.3 KiB
Go
199 lines
7.3 KiB
Go
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)<<uint(seat)) != 0:
|
|
sr.Role = "unread"
|
|
default:
|
|
sr.Role = "read"
|
|
}
|
|
d.Seats = append(d.Seats, sr)
|
|
}
|
|
return d, rows.Err()
|
|
}
|