feat(chat): unread read-receipts with lobby/game dot and history-open ack
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.
This commit is contained in:
Ilia Denisov
2026-06-17 11:12:38 +02:00
parent d53ff18a67
commit aaac816dc2
51 changed files with 1000 additions and 111 deletions
+2 -1
View File
@@ -35,7 +35,8 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
@@ -0,0 +1,28 @@
{{define "content" -}}
{{with .Data}}
<h1>Message</h1>
<nav class="subnav"><a href="/_gm/messages">&laquo; messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Time</b> {{.CreatedAt}}</li>
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>IP</b> {{.IP}}</li>
<li><b>Kind</b> {{.Kind}}</li>
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
<li><b>Message</b> {{.Body}}</li>
</ul>
</section>
<section class="panel"><h2>Read by seat</h2>
<table class="list">
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
{{else}}
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -6,6 +6,7 @@
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
@@ -13,19 +14,20 @@
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
+29 -1
View File
@@ -94,11 +94,13 @@ type MessagesView struct {
ExtMask string
GameID string
UserID string
UnreadOnly bool
FilterQuery template.URL
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
// card), source, IP, body, game (linked to the game card), time, and whether it is still
// unread by at least one recipient.
type MessageRow struct {
ID string
SenderID string
@@ -108,6 +110,32 @@ type MessageRow struct {
Body string
GameID string
CreatedAt string
Unread bool
}
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
// console message card.
type ChatMessageDetailView struct {
ID string
GameID string
SenderID string
SenderName string
Source string
Kind string
Body string
IP string
CreatedAt string
Unread bool
Seats []ChatSeatStatusRow
}
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
type ChatSeatStatusRow struct {
Seat int
AccountID string
DisplayName string
Role string
}
// UserDetailView is one account with its stats, identities and recent games.
+21 -2
View File
@@ -45,8 +45,12 @@ type Service struct {
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID)
metrics *gameMetrics
log *zap.Logger
// clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -89,6 +93,14 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger
}
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat.
// The social package wires its ClearNudges here.
func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) {
svc.clearNudges = fn
}
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit.
@@ -567,6 +579,13 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A nudge the actor answered by moving stops counting as unread (best-effort; the
// move has committed, so a cleanup failure is logged, not surfaced).
if svc.clearNudges != nil {
if err := svc.clearNudges(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear nudges after move", zap.Error(err))
}
}
// Record the seat's think time (turn start to commit) for the move-duration
// metric; the timeout path commits separately and is excluded by design.
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
+168
View File
@@ -0,0 +1,168 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/social"
)
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
// The opponent has it unread; the sender does not.
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); !unread {
t.Error("recipient should have the message unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("sender should not have their own message unread")
}
if set, err := svc.UnreadGames(ctx, seats[1]); err != nil {
t.Fatalf("unread games: %v", err)
} else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient")
}
// Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
} else if n != 1 {
t.Errorf("marked %d, want 1", n)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("message should be read after MarkRead")
}
// A second MarkRead is a no-op — nothing is left unread.
if n, _ := svc.MarkRead(ctx, gameID, seats[1]); n != 0 {
t.Errorf("second mark read marked %d, want 0", n)
}
}
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move
// A waiting player nudges; only the awaited seat 0 is targeted.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("the nudging sender must not have it unread")
}
}
// TestTextMessageUnreadReachesAllOpponents checks a text message in a three-player game marks both
// opponents (every seated player but the sender) unread.
func TestTextMessageUnreadReachesAllOpponents(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move and may chat
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hello all", ""); err != nil {
t.Fatalf("post: %v", err)
}
for _, s := range []int{1, 2} {
if unread, _ := svc.HasUnread(ctx, gameID, seats[s]); !unread {
t.Errorf("seat %d should have the text message unread", s)
}
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the sender should not have their own message unread")
}
}
// TestNudgeClearedByMove checks the move path clears a nudge the actor answered by moving: the
// game service's wired NudgeClearer flips the awaited seat's nudge to read on commit.
func TestNudgeClearedByMove(t *testing.T) {
ctx := context.Background()
gameSvc, gameID, seats, play := newDraftGame(t)
socialSvc := newSocialService()
gameSvc.SetNudgeClearer(socialSvc.ClearNudges)
// The waiting seat 1 nudges the to-move seat 0.
if _, err := socialSvc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Fatal("seat 0 should have the nudge unread before moving")
}
// Seat 0 answers by moving — the commit clears its pending nudge.
if _, err := gameSvc.SubmitPlay(ctx, gameID, seats[0], play.Tiles); err != nil {
t.Fatalf("submit play: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the nudge should be cleared once the awaited player has moved")
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
msg, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
// The unread-only filter (scoped to the game) lists the still-unread message.
if items, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); err != nil {
t.Fatalf("admin list: %v", err)
} else if len(items) != 1 || items[0].ID != msg.ID {
t.Fatalf("unread filter = %+v, want the one message", items)
}
// The detail card marks the sender 'sender' and the recipient 'unread'.
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
roles := map[uuid.UUID]string{}
for _, s := range card.Seats {
roles[s.AccountID] = s.Role
}
if roles[seats[0]] != "sender" {
t.Errorf("sender role = %q, want sender", roles[seats[0]])
}
if roles[seats[1]] != "unread" {
t.Errorf("recipient role = %q, want unread", roles[seats[1]])
}
// After the recipient reads, the filter drops it and the seat flips to 'read'.
if _, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("unread filter should be empty after read, got %d", len(items))
}
card2, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail after read: %v", err)
}
for _, s := range card2.Seats {
if s.AccountID == seats[1] && s.Role != "read" {
t.Errorf("recipient role after read = %q, want read", s.Role)
}
}
}
@@ -13,11 +13,12 @@ import (
)
type ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
UnreadSeats int16
}
@@ -17,13 +17,14 @@ type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UnreadSeats postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +66,31 @@ func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTabl
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UnreadSeatsColumn = postgres.IntegerColumn("unread_seats")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn, UnreadSeatsColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
UnreadSeats: UnreadSeatsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,20 @@
-- +goose Up
-- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is
-- set while seat i still has the message unread, and is cleared when that seat reads it
-- (opens the move history / chat, or — for a nudge — takes its move). A text message
-- starts with the bits of every seated recipient except the sender; a nudge starts with
-- only the awaited player's bit. The mask is inverted so "anything unread" is a plain
-- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all
-- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts).
SET search_path = backend, pg_catalog;
ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0;
-- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter,
-- the unread-count gauge): only the comparatively few still-unread rows are indexed.
CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0;
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX chat_messages_unread_idx;
ALTER TABLE chat_messages DROP COLUMN unread_seats;
+4
View File
@@ -108,6 +108,10 @@ type gameDTO struct {
// game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"`
// UnreadChat is a per-viewer flag: the requesting player has at least one unread chat
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
UnreadChat bool `json:"unread_chat"`
}
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
+1
View File
@@ -96,6 +96,7 @@ func (s *Server) registerRoutes() {
if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost)
u.GET("/games/:id/chat", s.handleChatList)
u.POST("/games/:id/chat/read", s.handleChatRead)
u.POST("/games/:id/nudge", s.handleNudge)
u.GET("/friends", s.handleListFriends)
u.GET("/friends/incoming", s.handleIncomingRequests)
@@ -3,6 +3,7 @@ package server
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"html/template"
"net/http"
@@ -82,6 +83,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/messages/:id", s.consoleChatMessageDetail)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
gm.POST("/dictionary/install", s.consoleInstallDictionary)
@@ -187,10 +189,11 @@ func (s *Server) consoleMessages(c *gin.Context) {
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
@@ -211,10 +214,14 @@ func (s *Server) consoleMessages(c *gin.Context) {
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
if filter.UnreadOnly {
q.Set("unread", "1")
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
UnreadOnly: filter.UnreadOnly,
FilterQuery: template.URL(q.Encode()),
}
if filter.GameID != uuid.Nil {
@@ -227,12 +234,41 @@ func (s *Server) consoleMessages(c *gin.Context) {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
// its game (who has read it, who has not).
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/messages")
if !ok {
return
}
card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, social.ErrMessageNotFound) {
s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
return
}
s.consoleError(c, err)
return
}
view := adminconsole.ChatMessageDetailView{
ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
}
for _, st := range card.Seats {
view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
})
}
s.renderConsole(c, "chatmessage", "messages", "Message", view)
}
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000
@@ -243,10 +279,11 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
if err != nil {
@@ -256,12 +293,16 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
for _, m := range items {
read := "read"
if m.UnreadSeats != 0 {
read = "unread"
}
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
})
}
w.Flush()
+41
View File
@@ -439,10 +439,16 @@ func (s *Server) handleListGames(c *gin.Context) {
return
}
memo := map[string]string{}
// One query seeds the lobby's per-card unread badge for every listed game.
var unread map[uuid.UUID]bool
if s.social != nil {
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
}
out := make([]gameDTO, 0, len(games))
for _, g := range games {
dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo)
dto.UnreadChat = unread[g.ID]
out = append(out, dto)
}
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
@@ -480,6 +486,22 @@ func (s *Server) handleNudge(c *gin.Context) {
c.JSON(http.StatusOK, chatDTOFrom(msg))
}
// handleChatRead marks every chat entry the caller has not yet read in the game as read
// — the acknowledgement the client sends when the player opens the move history or chat
// — and records each entry's publish-to-read latency. The client calls it only when it
// believes something is unread, so the backend is not hit on every history open.
func (s *Server) handleChatRead(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
if _, err := s.social.MarkRead(c.Request.Context(), gameID, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// userGame reads the authenticated account and the :id game param, aborting with the
// right status when either is missing. ok is false when the request was aborted.
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
@@ -504,5 +526,24 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
}
c.JSON(http.StatusOK, dto)
}
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO,
// when the social domain is present. It is best-effort: a lookup error leaves the flag
// false (a missing badge) rather than failing the surrounding game response.
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
if s.social == nil {
return
}
gid, err := uuid.Parse(g.ID)
if err != nil {
return
}
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
g.UnreadChat = unread
}
}
+1
View File
@@ -130,6 +130,7 @@ func (s *Server) handleGameState(c *gin.Context) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto)
}
+92 -7
View File
@@ -2,6 +2,8 @@ package social
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -23,16 +25,21 @@ type AdminMessage struct {
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.
// 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
GameID uuid.UUID
SenderID uuid.UUID
NameMask string
ExtMask string
UnreadOnly bool
}
// AdminListMessages returns the filtered chat messages — real messages only, nudges
@@ -75,12 +82,15 @@ func adminMessageWhere(f AdminMessageFilter) (string, []any) {
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
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 +
@@ -94,7 +104,7 @@ WHERE ` + where +
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); err != nil {
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)
@@ -111,3 +121,78 @@ func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (i
}
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()
}
+20 -5
View File
@@ -100,7 +100,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID))
if err != nil {
return Message{}, err
}
@@ -148,7 +148,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, ErrNudgeTooSoon
}
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
if err != nil {
return Message{}, err
}
@@ -258,8 +258,22 @@ func parseIP(raw string) *string {
return &canon
}
// insertChatMessage stores one chat row and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
// recipientMask is the initial unread bitmask for a text message: a set bit for every
// seated recipient (by seat index) other than the sender. Still-empty (open) seats are
// skipped. With at most four seats the result always fits the smallint column.
func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 {
var mask int16
for i, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
mask |= int16(1) << uint(i)
}
return mask
}
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
id, err := uuid.NewV7()
if err != nil {
return Message{}, fmt.Errorf("social: new message id: %w", err)
@@ -271,7 +285,8 @@ func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUI
stmt := table.ChatMessages.INSERT(
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
).VALUES(id, gameID, senderID, kind, body, ipVal).
table.ChatMessages.UnreadSeats,
).VALUES(id, gameID, senderID, kind, body, ipVal, unreadMask).
RETURNING(table.ChatMessages.AllColumns)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+173
View File
@@ -0,0 +1,173 @@
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
}
// 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)
}
// 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()
}
// 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
}
// 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
}
+32 -2
View File
@@ -2,6 +2,7 @@ package social
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
@@ -15,7 +16,8 @@ const meterName = "scrabble/backend/social"
// no-ops (see defaultSocialMetrics); SetMetrics installs the real meter during
// startup wiring.
type socialMetrics struct {
messages metric.Int64Counter
messages metric.Int64Counter
readDuration metric.Float64Histogram
}
// defaultSocialMetrics returns instruments backed by a no-op meter.
@@ -31,7 +33,13 @@ func newSocialMetrics(meter metric.Meter) *socialMetrics {
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("chat_messages_total")
}
return &socialMetrics{messages: c}
h, err := meter.Float64Histogram("chat_read_duration",
metric.WithDescription("Time from a chat entry being posted to a recipient reading it, in seconds, labelled by kind (message/nudge)."),
metric.WithUnit("s"))
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("chat_read_duration")
}
return &socialMetrics{messages: c, readDuration: h}
}
// SetMetrics installs the meter the social domain records to. It must be called
@@ -41,9 +49,31 @@ func (svc *Service) SetMetrics(meter metric.Meter) {
return
}
svc.metrics = newSocialMetrics(meter)
// chat_unread_messages observes the current backlog of unread chat entries at scrape
// time (the partial index keeps the count cheap). A registration error is ignored —
// the gauge is best-effort observability.
_, _ = meter.Int64ObservableGauge("chat_unread_messages",
metric.WithDescription("Chat entries with at least one recipient seat that has not read them yet."),
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
n, err := svc.store.countUnread(ctx)
if err != nil {
return err
}
o.Observe(n)
return nil
}))
}
// recordChat counts one posted chat entry of the given kind (message or nudge).
func (m *socialMetrics) recordChat(ctx context.Context, kind string) {
m.messages.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
}
// recordRead records one chat entry's publish-to-read latency, labelled by kind. A
// non-positive duration (clock skew) is dropped.
func (m *socialMetrics) recordRead(ctx context.Context, kind string, d time.Duration) {
if d <= 0 {
return
}
m.readDuration.Record(ctx, d.Seconds(), metric.WithAttributes(attribute.String("kind", kind)))
}
+9
View File
@@ -13,11 +13,16 @@ import (
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// tracerName scopes the social domain's OpenTelemetry spans.
const tracerName = "scrabble/backend/social"
// GameReader is the slice of the game domain the social package needs: the seated
// accounts in seat order, the seat index whose turn it is, and the game status, plus
// a shared-game test. game.Service satisfies it, so chat, nudge and the
@@ -71,6 +76,8 @@ var (
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
// ErrNotParticipant is returned when an account is not seated in the game.
ErrNotParticipant = errors.New("social: account is not a player in this game")
// ErrMessageNotFound is returned when an admin message lookup finds no such message.
ErrMessageNotFound = errors.New("social: message not found")
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
ErrChatBlocked = errors.New("social: chat is disabled for this account")
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
@@ -104,6 +111,7 @@ type Service struct {
games GameReader
pub notify.Publisher
metrics *socialMetrics
tracer trace.Tracer
now func() time.Time
}
@@ -116,6 +124,7 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
games: games,
pub: notify.Nop{},
metrics: defaultSocialMetrics(),
tracer: otel.Tracer(tracerName),
now: func() time.Time { return time.Now().UTC() },
}
}