9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.
The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
594 lines
18 KiB
Go
594 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/gamelimits"
|
|
)
|
|
|
|
// The handlers below cover the game and chat operations the UI needs. They follow
|
|
// the same pattern as handlers_user.go: X-User-ID identity, the domain service
|
|
// call, a JSON DTO mapped from the result.
|
|
|
|
// hintResultDTO is the top-ranked move plus the remaining hint budget.
|
|
type hintResultDTO struct {
|
|
Move moveRecordDTO `json:"move"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
WalletBalance int `json:"wallet_balance"`
|
|
}
|
|
|
|
// evalResultDTO is an unlimited move preview: legality, score, the words formed
|
|
// (main word first) and the orientation the engine inferred ("H"/"V", empty when
|
|
// illegal).
|
|
type evalResultDTO struct {
|
|
Legal bool `json:"legal"`
|
|
Score int `json:"score"`
|
|
Words []string `json:"words"`
|
|
Dir string `json:"dir"`
|
|
}
|
|
|
|
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
|
|
type wordCheckDTO struct {
|
|
Word string `json:"word"`
|
|
Legal bool `json:"legal"`
|
|
}
|
|
|
|
// historyDTO is a game's decoded move journal, the source for client board replay.
|
|
type historyDTO struct {
|
|
GameID string `json:"game_id"`
|
|
Moves []moveRecordDTO `json:"moves"`
|
|
}
|
|
|
|
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
|
// reports whether the caller has reached its tier's active-game cap for the random
|
|
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
|
|
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
|
|
// response — which the lobby re-fetches on every game event — instead of a separate
|
|
// request.
|
|
type gameListDTO struct {
|
|
Games []gameDTO `json:"games"`
|
|
AtGameLimit bool `json:"at_game_limit"`
|
|
}
|
|
|
|
// chatListDTO is a game's chat history.
|
|
type chatListDTO struct {
|
|
Messages []chatDTO `json:"messages"`
|
|
}
|
|
|
|
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
|
|
// indices; a blank is engine.BlankIndex.
|
|
type exchangeRequest struct {
|
|
Tiles []int `json:"tiles"`
|
|
}
|
|
|
|
// complaintRequest disputes a word-check result.
|
|
type complaintRequest struct {
|
|
Word string `json:"word"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// fillSeatNames fills each seat's display name from the account store — memoising across
|
|
// seats and games within one request — for the seats that carry no per-game snapshot yet
|
|
// (a pre-snapshot legacy row). A seat whose snapshot is already set is left untouched, so
|
|
// a disguised robot keeps its per-game name rather than reverting to its account name.
|
|
func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) {
|
|
for i := range g.Seats {
|
|
if g.Seats[i].DisplayName != "" {
|
|
continue // the seat already carries its per-game snapshot
|
|
}
|
|
id := g.Seats[i].AccountID
|
|
name, ok := memo[id]
|
|
if !ok {
|
|
if uid, err := uuid.Parse(id); err == nil {
|
|
if acc, err := s.accounts.GetByID(ctx, uid); err == nil {
|
|
name = acc.DisplayName
|
|
}
|
|
}
|
|
memo[id] = name
|
|
}
|
|
g.Seats[i].DisplayName = name
|
|
}
|
|
}
|
|
|
|
// fillSeatDeleted marks each seat whose account has been deleted, memoising across seats and
|
|
// games within one request (the same shape as fillSeatNames) and resolving the seats still
|
|
// unknown in a single batch query. A seat with no account (an open game's empty seat) is
|
|
// skipped. It is best-effort: on a store error the seats keep the false the projection left,
|
|
// which only leaves the social controls as they were before.
|
|
func (s *Server) fillSeatDeleted(ctx context.Context, g *gameDTO, memo map[string]bool) {
|
|
var ask []uuid.UUID
|
|
for _, seat := range g.Seats {
|
|
if seat.AccountID == "" {
|
|
continue
|
|
}
|
|
if _, ok := memo[seat.AccountID]; ok {
|
|
continue
|
|
}
|
|
uid, err := uuid.Parse(seat.AccountID)
|
|
if err != nil {
|
|
memo[seat.AccountID] = false
|
|
continue
|
|
}
|
|
memo[seat.AccountID] = false // resolved below; a failed lookup stays false
|
|
ask = append(ask, uid)
|
|
}
|
|
if len(ask) > 0 {
|
|
deleted, err := s.accounts.DeletedIDs(ctx, ask)
|
|
if err != nil {
|
|
s.log.Warn("seat deleted marks failed", zap.Error(err))
|
|
}
|
|
for id := range deleted {
|
|
memo[id.String()] = true
|
|
}
|
|
}
|
|
for i := range g.Seats {
|
|
g.Seats[i].Deleted = memo[g.Seats[i].AccountID]
|
|
}
|
|
}
|
|
|
|
// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
|
|
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
|
|
tiles := make([]tileDTO, 0, len(m.Tiles))
|
|
for _, t := range m.Tiles {
|
|
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
return moveRecordDTO{
|
|
Player: m.Seat,
|
|
Action: m.Action,
|
|
Dir: m.Dir,
|
|
MainRow: m.MainRow,
|
|
MainCol: m.MainCol,
|
|
Tiles: tiles,
|
|
Words: m.Words,
|
|
Count: len(m.Words),
|
|
Score: m.Score,
|
|
Total: m.RunningTotal,
|
|
}
|
|
}
|
|
|
|
// handlePass forfeits the player's turn.
|
|
func (s *Server) handlePass(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
res, err := s.games.Pass(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleExchange swaps the chosen rack tiles back into the bag.
|
|
func (s *Server) handleExchange(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req exchangeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
tiles, err := engine.DecodeTiles(variant, req.Tiles)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, tiles)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleResign resigns the player from the game.
|
|
func (s *Server) handleResign(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
res, err := s.games.Resign(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.writeMoveResult(c, res)
|
|
}
|
|
|
|
// handleHint reveals the top-ranked move and spends a hint.
|
|
func (s *Server) handleHint(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
h, err := s.games.Hint(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, hintResultDTO{
|
|
Move: moveRecordDTOFrom(h.Move),
|
|
HintsRemaining: h.HintsRemaining,
|
|
WalletBalance: h.WalletBalance,
|
|
})
|
|
}
|
|
|
|
// handleEvaluate previews a tentative play's legality and score.
|
|
func (s *Server) handleEvaluate(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req submitPlayRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
tiles, err := tilesFromRequest(variant, req)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, tiles)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words, Dir: ev.Dir})
|
|
}
|
|
|
|
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
|
|
// repeated ?idx= alphabet indices; the backend decodes them to the concrete
|
|
// word for the lookup and echoes that concrete word back for the client's result cache.
|
|
func (s *Server) handleCheckWord(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
idx, err := queryIndexes(c, "idx")
|
|
if err != nil {
|
|
abortBadRequest(c, "invalid word")
|
|
return
|
|
}
|
|
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
word, err := engine.DecodeWord(variant, idx)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
|
|
}
|
|
|
|
// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
|
|
// It carries a word-check query as alphabet indices on a GET.
|
|
func queryIndexes(c *gin.Context, key string) ([]int, error) {
|
|
raw := c.QueryArray(key)
|
|
out := make([]int, 0, len(raw))
|
|
for _, s := range raw {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// handleComplaint files a word-check complaint into the admin review queue.
|
|
func (s *Server) handleComplaint(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req complaintRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if _, err := s.games.FileComplaint(c.Request.Context(), gameID, uid, req.Word, req.Note); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// handleHistory returns a game's decoded move journal for board replay / history.
|
|
func (s *Server) handleHistory(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
view, err := s.games.History(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
moves := make([]moveRecordDTO, 0, len(view.Moves))
|
|
for _, m := range view.Moves {
|
|
moves = append(moves, moveRecordDTOFromHistory(m))
|
|
}
|
|
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
|
|
}
|
|
|
|
// gcgDTO is a game's GCG export: a suggested filename plus the GCG text.
|
|
type gcgDTO struct {
|
|
GameID string `json:"game_id"`
|
|
Filename string `json:"filename"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// handleExportGCG returns a finished game's GCG transcript for download/share. The
|
|
// service refuses an active game (ErrGameActive) to avoid leaking the live journal.
|
|
func (s *Server) handleExportGCG(c *gin.Context) {
|
|
_, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gcgDTO{
|
|
GameID: gameID.String(),
|
|
Filename: "game-" + gameID.String() + ".gcg",
|
|
Content: gcg,
|
|
})
|
|
}
|
|
|
|
// draftTileDTO is one tile a player has laid on the board but not yet submitted.
|
|
type draftTileDTO struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter string `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// draftDTO is a player's persisted client-side composition for a game: the
|
|
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
|
|
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
|
|
type draftDTO struct {
|
|
RackOrder string `json:"rack_order"`
|
|
BoardTiles []draftTileDTO `json:"board_tiles"`
|
|
}
|
|
|
|
// draftDTOFrom projects a stored draft into its wire DTO.
|
|
func draftDTOFrom(d game.Draft) draftDTO {
|
|
tiles := make([]draftTileDTO, 0, len(d.BoardTiles))
|
|
for _, t := range d.BoardTiles {
|
|
tiles = append(tiles, draftTileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
return draftDTO{RackOrder: d.RackOrder, BoardTiles: tiles}
|
|
}
|
|
|
|
// toDomain maps an inbound draft DTO to the domain draft.
|
|
func (d draftDTO) toDomain() game.Draft {
|
|
tiles := make([]game.DraftTile, 0, len(d.BoardTiles))
|
|
for _, t := range d.BoardTiles {
|
|
tiles = append(tiles, game.DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
|
|
}
|
|
|
|
// handleGetDraft returns the player's saved composition for a game, or an empty
|
|
// draft when none is stored.
|
|
func (s *Server) handleGetDraft(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := s.games.GetDraft(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, draftDTOFrom(d))
|
|
}
|
|
|
|
// handleSaveDraft upserts the player's composition for a game. The service
|
|
// rejects a non-player with ErrNotAPlayer.
|
|
func (s *Server) handleSaveDraft(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req draftDTO
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.games.SaveDraft(c.Request.Context(), gameID, uid, req.toDomain()); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
|
|
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
|
|
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
|
|
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
|
|
// accepting an incoming invitation is deliberately exempt.
|
|
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
|
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return false
|
|
}
|
|
if atLimit {
|
|
s.abortErr(c, game.ErrGameLimitReached)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// handleListGames returns the caller's active and finished games for the lobby. It omits
|
|
// the honest-AI games the caller left (resigned or abandoned to timeout); see
|
|
// game.Service.ListForLobby.
|
|
func (s *Server) handleListGames(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
games, err := s.games.ListForLobby(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
memo := map[string]string{}
|
|
delMemo := map[string]bool{}
|
|
// Two queries seed the lobby's per-card unread badge for every listed game: which games have
|
|
// any unread entry, and which of those have a real message (so the badge can colour
|
|
// message-bearing games apart from nudge-only ones).
|
|
var unread, unreadMsg map[uuid.UUID]bool
|
|
if s.social != nil {
|
|
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
|
|
unreadMsg, _ = s.social.UnreadMessageGames(c.Request.Context(), uid)
|
|
}
|
|
out := make([]gameDTO, 0, len(games))
|
|
for _, g := range games {
|
|
dto := gameDTOFromGame(g)
|
|
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
|
s.fillSeatDeleted(c.Request.Context(), &dto, delMemo)
|
|
dto.UnreadChat = unread[g.ID]
|
|
dto.UnreadMessages = unreadMsg[g.ID]
|
|
out = append(out, dto)
|
|
}
|
|
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
|
|
}
|
|
|
|
// handleChatList returns a game's chat history for the viewer.
|
|
func (s *Server) handleChatList(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
msgs, err := s.social.Messages(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
out := make([]chatDTO, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
out = append(out, chatDTOFrom(m))
|
|
}
|
|
c.JSON(http.StatusOK, chatListDTO{Messages: out})
|
|
}
|
|
|
|
// handleNudge posts a nudge to the player whose turn is awaited.
|
|
func (s *Server) handleNudge(c *gin.Context) {
|
|
uid, gameID, ok := s.userGame(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
msg, err := s.social.Nudge(c.Request.Context(), gameID, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
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) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return uuid.UUID{}, uuid.UUID{}, false
|
|
}
|
|
gameID, ok := gameIDParam(c)
|
|
if !ok {
|
|
abortBadRequest(c, "invalid game id")
|
|
return uuid.UUID{}, uuid.UUID{}, false
|
|
}
|
|
return uid, gameID, true
|
|
}
|
|
|
|
// writeMoveResult emits a committed move with seat display names filled in.
|
|
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
|
dto, err := moveResultDTOFrom(res)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
|
s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
|
|
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 flags for viewer uid on one game's DTO, when the
|
|
// social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is
|
|
// a real message (so the badge can be coloured). 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
|
|
}
|
|
if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil {
|
|
g.UnreadMessages = msg
|
|
}
|
|
}
|