fix(game): clear nudges on game completion
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

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.
This commit is contained in:
Ilia Denisov
2026-06-30 22:51:54 +02:00
parent adf7c55695
commit f9acea1d9a
8 changed files with 152 additions and 12 deletions
+34
View File
@@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID
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) {
@@ -140,6 +159,21 @@ RETURNING m.created_at`
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) {