Files
scrabble-game/backend/internal/social/adminchat.go
T
Ilia Denisov 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
feat(chat): unread read-receipts with lobby/game dot and history-open ack
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.
2026-06-17 11:12:38 +02:00

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()
}