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

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:
Ilia Denisov
2026-07-27 17:06:10 +02:00
parent 9506f89d9e
commit 9471341a0e
30 changed files with 375 additions and 23 deletions
+6
View File
@@ -71,6 +71,11 @@ type Account struct {
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID
// Deleted marks a tombstoned account (accounts.deleted_at set): its credentials are
// journalled and freed and its live surfaces anonymised, but the row survives for the
// no-cascade foreign keys. Nobody is behind it any more, so it takes part in no social
// exchange (docs/ARCHITECTURE.md §9.1).
Deleted bool
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold. An
@@ -630,6 +635,7 @@ func modelToAccount(row model.Accounts) Account {
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
MergedInto: mergedInto,
Deleted: row.DeletedAt != nil,
FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
+26
View File
@@ -180,6 +180,32 @@ func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (Deletion
return info, nil
}
// DeletedIDs returns the subset of ids whose accounts are tombstoned (deleted_at set), as a
// set. It is the batch form of Account.Deleted for a caller holding several ids at once —
// the game views resolve every seat's deleted mark in one round-trip. An empty ids slice
// queries nothing and returns an empty set; an unknown id is simply absent from the result.
func (s *Store) DeletedIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error) {
out := map[uuid.UUID]bool{}
if len(ids) == 0 {
return out, nil
}
exprs := make([]postgres.Expression, len(ids))
for i, id := range ids {
exprs[i] = postgres.UUID(id)
}
stmt := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.IN(exprs...).AND(table.Accounts.DeletedAt.IS_NOT_NULL()))
var rows []model.Accounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: deleted ids: %w", err)
}
for _, r := range rows {
out[r.AccountID] = true
}
return out, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
+114
View File
@@ -5,17 +5,22 @@ package inttest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
"scrabble/backend/internal/social"
)
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
@@ -277,6 +282,115 @@ func TestDropAllRobotGames(t *testing.T) {
}
}
// TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a
// friend request nor a block may target it — the client hides both controls on a deleted
// seat, and the server refuses them for an older client. The seat's deleted mark that drives
// that hiding is resolved by Store.DeletedIDs.
func TestDeletedAccountRefusesSocialActions(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := newSocialService()
deleter := accountdelete.NewDeleter(testDB)
_, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate
viewer, gone := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil {
t.Fatalf("friend request before deletion = %v, want nil", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil {
t.Fatalf("delete: %v", err)
}
// Deletion dropped the pending request with the rest of the account's social rows, so
// this is a fresh request against the tombstone — exactly what an older client sends
// after the 🤝 reappears on the finished game's scoreboard.
if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err)
}
if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err)
}
// The live opponent stays actionable — the guard is per-account, not a blanket switch.
live := provisionAccount(t)
if err := svc.Block(ctx, viewer, live); err != nil {
t.Errorf("block of a live account = %v, want nil", err)
}
deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone})
if err != nil {
t.Fatalf("deleted ids: %v", err)
}
if !deleted[gone] {
t.Error("the deleted account must be marked deleted")
}
if deleted[viewer] {
t.Error("a live account must not be marked deleted")
}
}
// TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted
// opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge)
// on the client; the viewer's own live seat stays unmarked.
func TestGameStateMarksDeletedSeat(t *testing.T) {
ctx := context.Background()
games := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := games.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: games,
})
viewer, gone := seats[0], seats[1]
if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] {
t.Fatalf("before deletion no seat may be marked deleted, got %v", marks)
}
if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil {
t.Fatalf("delete: %v", err)
}
marks := stateSeatDeleted(t, srv, g.ID, viewer)
if marks[0] {
t.Error("the viewer's own live seat must not be marked deleted")
}
if !marks[1] {
t.Error("the deleted opponent's seat must be marked deleted")
}
}
// stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed
// by seat.
func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer)
if rec.Code != http.StatusOK {
t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String())
}
var body struct {
Game struct {
Seats []struct {
Seat int `json:"seat"`
Deleted bool `json:"deleted"`
} `json:"seats"`
} `json:"game"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode game state: %v", err)
}
out := map[int]bool{}
for _, s := range body.Game.Seats {
out[s.Seat] = s.Deleted
}
return out
}
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
// deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) {
+4
View File
@@ -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.
+2
View File
@@ -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):
+40
View File
@@ -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)
}
+2
View File
@@ -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)
}
+10
View File
@@ -28,6 +28,16 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e
if blockerID == blockedID {
return ErrSelfRelation
}
// A deleted account sends nothing there is anything left to suppress, and the block would
// outlive the retention purge as a dangling row: refuse it. The client already hides the
// control on a deleted seat; this guard catches an older one.
blocked, err := svc.accounts.GetByID(ctx, blockedID)
if err != nil {
return err
}
if blocked.Deleted {
return ErrAccountDeleted
}
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err
}
+5
View File
@@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
if addressee.Deleted {
// The addressee deleted their account: there is nobody to answer the request, so it is
// refused rather than left pending forever.
return ErrAccountDeleted
}
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
+5
View File
@@ -62,6 +62,11 @@ var (
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
// request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends")
// ErrAccountDeleted is returned when a social action targets a deleted (tombstoned)
// account: nobody is behind it any more, so it can be neither befriended nor blocked.
// The client already hides those controls on a deleted seat; this is the server source
// of truth, and the guard an older client hits.
ErrAccountDeleted = errors.New("social: this account has been deleted")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the