feat(social): hide social controls on a deleted opponent's seat
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
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.
This commit is contained in:
@@ -132,6 +132,10 @@ type seatDTO struct {
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
// Deleted marks a seat whose account has been deleted: the client hides every social
|
||||
// control aimed at it (add-friend, block, chat, nudge). gameDTOFromGame leaves it false
|
||||
// (it does not reach the account store); fillSeatDeleted fills it.
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// gameDTO is the shared game summary.
|
||||
|
||||
@@ -314,6 +314,8 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "request_exists"
|
||||
case errors.Is(err, social.ErrRequestBlocked):
|
||||
return http.StatusForbidden, "request_blocked"
|
||||
case errors.Is(err, social.ErrAccountDeleted):
|
||||
return http.StatusGone, "account_deleted"
|
||||
case errors.Is(err, social.ErrRequestNotFound):
|
||||
return http.StatusNotFound, "request_not_found"
|
||||
case errors.Is(err, social.ErrNoSharedGame):
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
@@ -97,6 +98,42 @@ func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -435,6 +472,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
||||
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).
|
||||
@@ -447,6 +485,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
||||
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)
|
||||
@@ -526,6 +565,7 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ func (s *Server) handleGameState(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||
s.fillSeatDeleted(c.Request.Context(), &dto.Game, map[string]bool{})
|
||||
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
@@ -207,6 +208,7 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
||||
dto := matchDTOFrom(res)
|
||||
if dto.Game != nil {
|
||||
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
|
||||
s.fillSeatDeleted(c.Request.Context(), dto.Game, map[string]bool{})
|
||||
}
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user