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 }