Files
scrabble-game/backend/internal/social/chatread.go
T
Ilia Denisov f9acea1d9a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
fix(game): clear nudges on game completion
Nudge badges lingered in the lobby on games that ended by turn-timeout,
resignation or forfeit: those paths commit the finish directly, bypassing
the move path's per-mover NudgeClearer, so the awaited seat's nudge was
never marked read. A finished game's nudges are stale, so clear them all.

Add a wired NudgeExpirer (social.ExpireNudges) called from the shared
commit finish block — covering every completion path — and from the
voidGame recovery path. It clears every seat's nudge bits for the game
and leaves chat messages unread; unlike ClearNudges it records no
publish-to-read latency, since a completion is an expiry, not a read.

An integration test reproduces the timeout case (nudge cleared, chat
kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README.
2026-06-30 22:51:54 +02:00

260 lines
11 KiB
Go

package social
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// MarkRead clears viewerID's unread bit on every chat entry of the game they have not
// yet read — the acknowledgement the client sends when the player opens the move
// history (or the chat). For each entry that flips to read it records the
// publish-to-read latency. It returns the number of entries marked, and is a harmless
// no-op (zero) when the viewer holds no seat or has nothing unread, so the caller may
// invoke it without first checking participation.
func (svc *Service) MarkRead(ctx context.Context, gameID, viewerID uuid.UUID) (int, error) {
ctx, span := svc.tracer.Start(ctx, "social.MarkRead",
trace.WithAttributes(attribute.String("game.id", gameID.String())))
defer span.End()
marks, err := svc.store.markRead(ctx, gameID, viewerID)
if err != nil {
span.RecordError(err)
return 0, err
}
now := svc.now()
for _, m := range marks {
svc.metrics.recordRead(ctx, m.kind, now.Sub(m.createdAt))
}
span.SetAttributes(attribute.Int("marked.count", len(marks)))
return len(marks), nil
}
// ClearNudges marks accountID's pending nudges in the game read once they have acted
// (taken their move): a nudge answered by moving stops counting as unread. It records
// the publish-to-read latency for each nudge it clears. It satisfies game.NudgeClearer
// and is wired into the move path; failures are the caller's to log (the move has
// already committed).
func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID) error {
ctx, span := svc.tracer.Start(ctx, "social.ClearNudges",
trace.WithAttributes(attribute.String("game.id", gameID.String())))
defer span.End()
times, err := svc.store.clearNudges(ctx, gameID, accountID)
if err != nil {
span.RecordError(err)
return err
}
now := svc.now()
for _, t := range times {
svc.metrics.recordRead(ctx, kindNudge, now.Sub(t))
}
span.SetAttributes(attribute.Int("marked.count", len(times)))
return nil
}
// ExpireNudges marks every pending nudge in the game read, for when the game finishes: a nudge
// badge is stale once the game is over, so completion clears them all for every seat. Chat
// messages are left untouched (they stay unread). It satisfies game.NudgeExpirer and is wired
// into the completion path; failures are the caller's to log (the game has already finished).
// Unlike ClearNudges it records no publish-to-read latency — a completion is an expiry, not the
// recipient reading the nudge — so the latency metric is not skewed by games that end hours later.
func (svc *Service) ExpireNudges(ctx context.Context, gameID uuid.UUID) error {
ctx, span := svc.tracer.Start(ctx, "social.ExpireNudges",
trace.WithAttributes(attribute.String("game.id", gameID.String())))
defer span.End()
n, err := svc.store.expireNudges(ctx, gameID)
if err != nil {
span.RecordError(err)
return err
}
span.SetAttributes(attribute.Int64("expired.count", n))
return nil
}
// UnreadGames returns the set of games in which viewerID has at least one unread chat
// entry, for seeding the lobby's per-card unread badge in a single query.
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
return svc.store.unreadGames(ctx, viewerID)
}
// HasUnread reports whether viewerID has any unread chat entry in the game, for the
// per-game unread flag of a single game's state and move-result views.
func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
return svc.store.hasUnread(ctx, gameID, viewerID)
}
// UnreadMessageGames returns the subset of UnreadGames whose unread entries include a real chat
// message (a 'message' kind, not a nudge), for colouring the lobby's per-card unread badge: a game
// present here has an unread message (the regular badge), one only in UnreadGames has nudges alone
// (the soft nudge badge).
func (svc *Service) UnreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
return svc.store.unreadMessageGames(ctx, viewerID)
}
// HasUnreadMessage reports whether viewerID has an unread chat message (a 'message' kind, not a
// nudge) in the game, for distinguishing a message badge from a nudge-only one on a single game's
// state and move-result views.
func (svc *Service) HasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
return svc.store.hasUnreadMessage(ctx, gameID, viewerID)
}
// readMark is one chat entry that flipped from unread to read, carrying what the
// publish-to-read latency metric needs.
type readMark struct {
kind string
createdAt time.Time
}
// markRead clears the viewer's seat bit on the game's entries still unread by them,
// resolving the seat through the game_players join, and returns the cleared entries.
// The bitwise terms are cast through int4 so the operators resolve unambiguously.
func (s *Store) markRead(ctx context.Context, gameID, viewerID uuid.UUID) ([]readMark, error) {
const q = `UPDATE backend.chat_messages m
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
FROM backend.game_players p
WHERE p.game_id = m.game_id AND p.account_id = $2
AND m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
RETURNING m.kind, m.created_at`
rows, err := s.db.QueryContext(ctx, q, gameID, viewerID)
if err != nil {
return nil, fmt.Errorf("social: mark read: %w", err)
}
defer rows.Close()
var out []readMark
for rows.Next() {
var m readMark
if err := rows.Scan(&m.kind, &m.createdAt); err != nil {
return nil, fmt.Errorf("social: scan mark read: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// clearNudges clears the actor's seat bit on the game's still-unread nudges and returns
// their post times (for the latency metric).
func (s *Store) clearNudges(ctx context.Context, gameID, accountID uuid.UUID) ([]time.Time, error) {
const q = `UPDATE backend.chat_messages m
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
FROM backend.game_players p
WHERE p.game_id = m.game_id AND p.account_id = $2
AND m.game_id = $1 AND m.kind = 'nudge' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
RETURNING m.created_at`
rows, err := s.db.QueryContext(ctx, q, gameID, accountID)
if err != nil {
return nil, fmt.Errorf("social: clear nudges: %w", err)
}
defer rows.Close()
var out []time.Time
for rows.Next() {
var t time.Time
if err := rows.Scan(&t); err != nil {
return nil, fmt.Errorf("social: scan clear nudges: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// expireNudges marks every still-unread nudge in the game read for all seats at once, used when
// the game finishes. It returns the number of nudge entries it cleared. Only 'nudge' rows are
// touched, so chat messages keep their unread bits; it returns no post times because a completion
// is an expiry, not a player reading, and must not feed the publish-to-read latency metric.
func (s *Store) expireNudges(ctx context.Context, gameID uuid.UUID) (int64, error) {
const q = `UPDATE backend.chat_messages
SET unread_seats = 0
WHERE game_id = $1 AND kind = 'nudge' AND unread_seats <> 0`
res, err := s.db.ExecContext(ctx, q, gameID)
if err != nil {
return 0, fmt.Errorf("social: expire nudges: %w", err)
}
return res.RowsAffected()
}
// unreadGames returns the games where viewerID has an unread entry, resolving their
// seat per game through the game_players join.
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
const q = `SELECT DISTINCT m.game_id
FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
WHERE m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
rows, err := s.db.QueryContext(ctx, q, viewerID)
if err != nil {
return nil, fmt.Errorf("social: unread games: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]bool)
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("social: scan unread games: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// hasUnread reports whether viewerID has an unread entry in the one game.
func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
const q = `SELECT EXISTS(
SELECT 1 FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
WHERE m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
var ok bool
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
return false, fmt.Errorf("social: has unread: %w", err)
}
return ok, nil
}
// unreadMessageGames is unreadGames restricted to real chat messages (kind 'message', not a
// nudge), so the caller can tell a message badge from a nudge-only one in one extra query.
func (s *Store) unreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
const q = `SELECT DISTINCT m.game_id
FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
WHERE m.kind = 'message' AND m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
rows, err := s.db.QueryContext(ctx, q, viewerID)
if err != nil {
return nil, fmt.Errorf("social: unread message games: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]bool)
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("social: scan unread message games: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// hasUnreadMessage reports whether viewerID has an unread real chat message (kind 'message', not
// a nudge) in the one game.
func (s *Store) hasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
const q = `SELECT EXISTS(
SELECT 1 FROM backend.chat_messages m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
WHERE m.game_id = $1 AND m.kind = 'message' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
var ok bool
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
return false, fmt.Errorf("social: has unread message: %w", err)
}
return ok, nil
}
// countUnread counts the chat entries with at least one recipient seat still unread,
// for the chat_unread_messages gauge.
func (s *Store) countUnread(ctx context.Context) (int64, error) {
var n int64
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM backend.chat_messages WHERE unread_seats <> 0`).Scan(&n); err != nil {
return 0, fmt.Errorf("social: count unread: %w", err)
}
return n, nil
}