feat(social): asymmetric per-user block, in-game block control, admin lists
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 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.

- backend: a directional blockExists guard across chat/nudge/friends/invitations
  (store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
  the matchmaker excludes a block-related pair (both directions) from auto-match;
  user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
  (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
  composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
Ilia Denisov
2026-06-18 11:50:34 +02:00
parent 9074417762
commit 81b9e1529e
34 changed files with 1191 additions and 109 deletions
@@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
@@ -102,6 +102,36 @@
</tbody>
</table>
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
<tbody>
{{range .Friends}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocks</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .Blocks}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocked by</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .BlockedBy}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
+15
View File
@@ -175,6 +175,21 @@ type UserDetailView struct {
// grant form offers. The first role is the feedback ban (see internal/account).
Roles []string
KnownRoles []string
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
// other account with the date it happened. They are the full truth; the asymmetric block
// suppression that hides relationships from players never applies to the console.
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
AccountID string
DisplayName string
Date string
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
+2 -2
View File
@@ -274,7 +274,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
@@ -319,7 +319,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
if seed&1 == 1 {
seats = []seatInsert{{}, caller}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude)
if err != nil {
return Game{}, false, err
}
+7 -2
View File
@@ -200,22 +200,27 @@ func openMatchKey(variant string, multipleWords bool) int64 {
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e)
}
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
// A game whose waiting player is in exclude (the caller's per-user block set, either
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
// an empty exclude (the "{}" literal) excludes nothing.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3)
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
return er
+270
View File
@@ -0,0 +1,270 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
// user had left unread for the blocker in their shared games, so no stale unread badge
// lingers for someone the blocker no longer sees.
func TestBlockInstantReadsExistingUnread(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
t.Fatal("viewer should have the message unread before blocking")
}
if err := svc.Block(ctx, viewer, sender); err != nil {
t.Fatalf("block: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
t.Error("blocking should instant-read the blocked sender's prior unread message")
}
}
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
// message the blocked user sends is stored and visible to themselves, but is born read for
// the blocker, never delivered to them, and hidden from their view.
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's message must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindChatMessage) {
t.Error("the blocked sender's message must not be delivered to the blocker")
}
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
}
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
}
}
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
// only opponent is someone they have blocked (the composer is hidden client-side too).
func TestChatToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
// ordinary to them) but is born read and never delivered to the blocker.
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's nudge must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindNudge) {
t.Error("the blocked sender's nudge must not be delivered to the blocker")
}
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
t.Error("the suppressed nudge should still be recorded")
}
}
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
// awaited opponent when they have blocked them.
func TestNudgeToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
// their other sessions update in place) and never reach the blocked user.
func TestBlockPublishesToBlockerOnly(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
blocker, blocked := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserBlocked) {
t.Error("the blocker should receive a user_blocked event")
}
if pub.notified(blocked, notify.NotifyUserBlocked) {
t.Error("the blocked user must never receive a user_blocked event")
}
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
t.Fatalf("unblock: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
t.Error("the blocker should receive a user_unblocked event")
}
if pub.notified(blocked, notify.NotifyUserUnblocked) {
t.Error("the blocked user must never receive a user_unblocked event")
}
}
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
// block between them (either direction), while an unblocked third player still joins.
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
soc := newSocialService()
mm.SetBlocker(soc)
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
if err := soc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
// The block is seen from both sides — the exclusion set unions both directions.
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
}
// b opens a game awaiting an opponent.
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must open a game, not match")
}
// a must not join b's game (a blocked b): it opens its own instead.
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r2.Matched || r2.Game.ID == r1.Game.ID {
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
}
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
// is specific to the blocked pair, not a blanket refusal.
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue c: %v", err)
}
if !r3.Matched || r3.Game.ID != r1.Game.ID {
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
}
}
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
// return the full truth in both directions, including a friendship that a block overrides but
// does not delete.
func TestAdminSocialLists(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("request: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
blocks, err := svc.AdminBlocksBy(ctx, a)
if err != nil {
t.Fatalf("admin blocks by: %v", err)
}
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
}
blockedBy, err := svc.AdminBlockedBy(ctx, b)
if err != nil {
t.Fatalf("admin blocked by: %v", err)
}
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
}
// The friendship survives the block, so it still shows on both admin friend lists.
friendsA, err := svc.AdminFriends(ctx, a)
if err != nil {
t.Fatalf("admin friends: %v", err)
}
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
}
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
}
}
// blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper()
ids, err := soc.BlockedWith(context.Background(), id)
if err != nil {
t.Fatalf("blocked with: %v", err)
}
return ids
}
// contains reports whether ids includes want.
func contains(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
+1 -1
View File
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
// An open (awaiting-opponent) quick game counts.
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute)); err != nil {
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
}
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
+27 -5
View File
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
social := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
soc := newSocialService()
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
inviter := provisionAccount(t)
invitee := provisionAccount(t)
if err := social.Block(ctx, invitee, inviter); err != nil {
blockedInvitee := provisionAccount(t)
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
}
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
// (no error — the inviter notices nothing), but that invitee is never notified and never
// sees it in their list.
inviter2 := provisionAccount(t)
suppressed := provisionAccount(t)
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
}
if pub.notified(suppressed, notify.NotifyInvitation) {
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
}
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
}
}
+3 -3
View File
@@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open game: %v", err)
}
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open: %v", err)
}
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("join: %v", err)
}
+65 -6
View File
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
return false
}
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
// nudge) was published to user — used to assert that a blocked sender's message or nudge
// never reaches the blocker.
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
return true
}
}
return false
}
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request
// just sits unanswered and later expires — mirroring a human who ignores it.
@@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
}
// Block: the addressee has blocked the requester.
c, d := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, d, c); err != nil {
// Block from the requester's own side: a requester who has blocked the addressee is
// refused outright (they are the blocker and aware of it). The reverse direction — the
// addressee having blocked the requester — is instead silently suppressed, covered by
// TestFriendRequestFromBlockedIsSuppressed.
_, seats := newGameWithSeats(t, 2)
c, d := seats[0], seats[1]
if err := svc.Block(ctx, c, d); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
}
}
func TestBlockSeversFriendship(t *testing.T) {
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
// addressee has blocked the requester, the requester's friend request succeeds silently
// (no error — they must not notice the block), is stored, but is never delivered to nor
// surfaced for the blocker.
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1]
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
t.Fatalf("suppressed request = %v, want nil", err)
}
if pub.notified(blocker, notify.NotifyFriendRequest) {
t.Error("blocker must not be notified of a suppressed request")
}
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
}
// The blocked user sees an ordinary outgoing request — no leak of the block.
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
t.Errorf("blocked outgoing = %v, want [blocker]", got)
}
}
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
t.Fatalf("block: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friendship must be severed by a block, got %v", friends)
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
}
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
}
if err := svc.Unblock(ctx, a, b); err != nil {
t.Fatalf("unblock: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
}
}
+47 -3
View File
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
}
intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients {
// An invitee who has blocked the inviter never sees this invitation, so they must not
// receive its updates either (an update would otherwise re-add it to their lobby). The
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
if id != inv.InviterID {
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
continue
}
}
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
}
svc.pub.Publish(intents...)
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
}
seen := map[uuid.UUID]bool{inviterID: true}
// suppressed collects invitees who have blocked the inviter: the invitation is still
// created and persisted for them, but they are never notified and never see it (their
// friendship with the inviter survived the block, so they can still appear in the
// inviter's picker — they must not learn of the block).
suppressed := make(map[uuid.UUID]bool)
for _, id := range inviteeIDs {
if seen[id] {
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
}
return Invitation{}, err
}
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
if err != nil {
return Invitation{}, err
}
if blocked {
if iBlock {
return Invitation{}, ErrInvitationBlocked
}
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
if err != nil {
return Invitation{}, err
}
if theyBlock {
suppressed[id] = true
}
}
id, err := uuid.NewV7()
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if err != nil {
return Invitation{}, err
}
svc.emitInvitation(ctx, inv, inviteeIDs)
// Notify every invitee except those who have blocked the inviter (their copy stays
// stored but unseen — the store-but-hide that keeps the block invisible).
notifyIDs := inviteeIDs
if len(suppressed) > 0 {
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
for _, id := range inviteeIDs {
if !suppressed[id] {
notifyIDs = append(notifyIDs, id)
}
}
}
svc.emitInvitation(ctx, inv, notifyIDs)
return inv, nil
}
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
if err != nil {
return nil, err
}
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
if inv.InviterID != accountID {
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
if err != nil {
return nil, err
}
if blocked {
continue
}
}
out = append(out, inv)
}
return out, nil
+8 -4
View File
@@ -38,11 +38,15 @@ type RobotProvider interface {
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
}
// Blocker reports whether two accounts have a block between them (either
// direction). social.Service satisfies it; the lobby uses it to refuse
// invitations between blocked accounts.
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
// block the invitee placed on the inviter is silently suppressed; auto-match consults
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
type Blocker interface {
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
// Blocks reports whether blocker has blocked blocked (exact direction).
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
// BlockedWith returns every account in a block with accountID in either direction.
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
}
// Auto-match defaults: a casual two-player game on the longest move clock with one
+29 -4
View File
@@ -18,7 +18,10 @@ import (
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it.
type GameMatcher interface {
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
// one; exclude lists account IDs whose open game must never be joined (the caller's
// per-user block set), so a block keeps the pair out of the same anonymous game.
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
@@ -35,8 +38,9 @@ type GameMatcher interface {
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use.
//
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
// friends, chat and invitations between known players).
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
// paired into a game whose waiting opponent they have blocked, or who has blocked them
// (either direction), so a block keeps two players apart everywhere.
type Matchmaker struct {
games GameMatcher
robots RobotProvider
@@ -44,6 +48,7 @@ type Matchmaker struct {
jitter time.Duration
clock func() time.Time
pub notify.Publisher
blocker Blocker
log *zap.Logger
}
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
}
}
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
// player with someone they have a block with (either direction). It must be called
// during startup wiring; the default is no blocker, in which case auto-match pairs
// anonymously (no block exclusion).
func (m *Matchmaker) SetBlocker(b Blocker) {
if b != nil {
m.blocker = b
}
}
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// in, and whether it already had an opponent (they joined a waiting game) rather than
// being freshly opened and still awaiting one.
@@ -90,7 +105,17 @@ type EnqueueResult struct {
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
// that game's waiting starter.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
// Exclude every account the caller has a block with (either direction) from the
// candidate open games, so auto-match never pairs a blocked pair into one game.
var exclude []uuid.UUID
if m.blocker != nil {
var err error
exclude, err = m.blocker.BlockedWith(ctx, accountID)
if err != nil {
return EnqueueResult{}, err
}
}
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
if err != nil {
return EnqueueResult{}, err
}
+30 -1
View File
@@ -25,6 +25,7 @@ type stubMatcher struct {
openErr error
openCalls int
lastDeadline time.Time
lastExclude []uuid.UUID
expired []game.OpenGame
@@ -39,9 +40,10 @@ type stubMatcher struct {
createParams game.CreateParams
}
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
s.openCalls++
s.lastDeadline = deadline
s.lastExclude = exclude
return s.openGame, s.openJoined, s.openErr
}
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
// fakeBlocker is a Blocker returning a canned both-direction block set.
type fakeBlocker struct {
with []uuid.UUID
err error
}
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
return f.with, f.err
}
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
// whose waiting player has a block with the caller.
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
caller, blocked := uuid.New(), uuid.New()
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !slices.Contains(m.lastExclude, blocked) {
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
}
}
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
// (uuid.Nil for a still-empty opponent seat).
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
+10
View File
@@ -62,6 +62,16 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
// only to the blocker — never to the blocked user, who must not learn of the block —
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
NotifyUserBlocked = "user_blocked"
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
// unblocked account, so their open game screens restore the controls and un-strike
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
NotifyUserUnblocked = "user_unblocked"
)
// Intent is one live event destined for a single user. Payload is the
@@ -389,9 +389,30 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
if s.social != nil {
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
view.Blocks = relationRows(rels)
}
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
view.BlockedBy = relationRows(rels)
}
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
view.Friends = relationRows(rels)
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
out := make([]adminconsole.RelationRow, 0, len(rels))
for _, r := range rels {
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
}
return out
}
// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
ctx := c.Request.Context()
+5 -4
View File
@@ -7,10 +7,11 @@ import (
"github.com/google/uuid"
)
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
// is mutual in effect (the social checks apply it both ways) and severs any
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution.
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric:
// the blocker stops seeing everything from the blocked user (chat, nudge, requests,
// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does
// not delete — any friendship (an unblock restores it). They reuse the friend handlers'
// targetIDRequest and account-ref resolution.
// blockListDTO is the accounts the caller has blocked.
type blockListDTO struct {
+79
View File
@@ -0,0 +1,79 @@
package social
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
)
// AdminRelation is one entry in the admin user card's blocks / blocked-by / friends lists:
// the other account, its display name, and when the relationship was recorded. It is the
// unfiltered truth — the asymmetric block suppression that hides relationships from players
// never applies to the operator console.
type AdminRelation struct {
AccountID uuid.UUID
DisplayName string
At time.Time
}
// AdminBlocksBy returns the accounts blockerID has blocked, with display names and block
// times, newest first — the admin card's "blocks" list.
func (svc *Service) AdminBlocksBy(ctx context.Context, blockerID uuid.UUID) ([]AdminRelation, error) {
return svc.store.adminBlocks(ctx, blockerID, true)
}
// AdminBlockedBy returns the accounts that have blocked blockedID, with names and times,
// newest first — the admin card's "blocked by" list.
func (svc *Service) AdminBlockedBy(ctx context.Context, blockedID uuid.UUID) ([]AdminRelation, error) {
return svc.store.adminBlocks(ctx, blockedID, false)
}
// AdminFriends returns accountID's accepted friendships in either direction, with the other
// account's name and when the friendship was accepted, newest first — the admin card's
// "friends" list.
func (svc *Service) AdminFriends(ctx context.Context, accountID uuid.UUID) ([]AdminRelation, error) {
const q = `SELECT other_id, a.display_name, at FROM (
SELECT CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END AS other_id,
COALESCE(f.responded_at, f.created_at) AS at
FROM backend.friendships f
WHERE f.status = 'accepted' AND (f.requester_id = $1 OR f.addressee_id = $1)
) rel
JOIN backend.accounts a ON a.account_id = rel.other_id
ORDER BY at DESC`
return svc.store.adminRelations(ctx, q, accountID)
}
// adminBlocks reads the blocks touching id: when asBlocker the rows where id is the blocker
// (the other side is who they blocked), otherwise the rows where id is the blocked (the
// other side is who blocked them).
func (s *Store) adminBlocks(ctx context.Context, id uuid.UUID, asBlocker bool) ([]AdminRelation, error) {
q := `SELECT b.blocked_id, a.display_name, b.created_at
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocked_id
WHERE b.blocker_id = $1 ORDER BY b.created_at DESC`
if !asBlocker {
q = `SELECT b.blocker_id, a.display_name, b.created_at
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocker_id
WHERE b.blocked_id = $1 ORDER BY b.created_at DESC`
}
return s.adminRelations(ctx, q, id)
}
// adminRelations runs an admin list query of the (account_id, display_name, at) shape.
func (s *Store) adminRelations(ctx context.Context, query string, id uuid.UUID) ([]AdminRelation, error) {
rows, err := s.db.QueryContext(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("social: admin relations: %w", err)
}
defer rows.Close()
var out []AdminRelation
for rows.Next() {
var r AdminRelation
if err := rows.Scan(&r.AccountID, &r.DisplayName, &r.At); err != nil {
return nil, fmt.Errorf("social: scan admin relation: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
+110 -13
View File
@@ -10,24 +10,41 @@ import (
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Block records that blockerID has blocked blockedID. Blocking severs any
// friendship or pending request between the two and, through the mutual block
// checks, suppresses chat visibility and new requests/invitations in both
// directions. It is idempotent.
// Block records that blockerID has blocked blockedID. The block is asymmetric and
// non-destructive: it suppresses everything the blocked user sends toward the blocker
// (chat, nudge, friend requests, invitations are persisted but never delivered or
// surfaced) and hides the blocked user from the blocker's lists, while the blocked
// user notices nothing. It does NOT delete the friendship — an unblock cleanly
// restores it — and keeps every stored row. As a courtesy it marks any of the blocked
// user's still-unread chat entries in their shared games read at once, so no stale
// unread badge lingers for someone the blocker no longer sees. A user_blocked event is
// delivered to the blocker (only) so their other sessions update in place. It is idempotent.
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
if blockerID == blockedID {
return ErrSelfRelation
}
return svc.store.insertBlock(ctx, blockerID, blockedID)
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
return nil
}
// Unblock removes blockerID's block on blockedID. It is idempotent.
// Unblock removes blockerID's block on blockedID and confirms it to the (former)
// blocker with a user_unblocked event so their open game screens restore the controls
// and un-strike the name in place. Any friendship the block had been overriding becomes
// effective again. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
return svc.store.deleteBlock(ctx, blockerID, blockedID)
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID)))
return nil
}
// ListBlocks returns the account IDs blockerID has blocked.
@@ -35,11 +52,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid
return svc.store.listBlocks(ctx, blockerID)
}
// BlockedWith returns every account that has a block with accountID in either
// direction (those accountID blocked, plus those who blocked accountID), de-duplicated.
// The matchmaker excludes them so a block — either way — keeps the pair out of the same
// anonymous auto-match game.
func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
mine, err := svc.store.listBlocks(ctx, accountID)
if err != nil {
return nil, err
}
theirs, err := svc.store.listBlockedBy(ctx, accountID)
if err != nil {
return nil, err
}
seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs))
out := make([]uuid.UUID, 0, len(mine)+len(theirs))
for _, id := range append(mine, theirs...) {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
// IsBlocked reports whether a block stands between a and b in either direction.
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return svc.store.isBlocked(ctx, a, b)
}
// Blocks reports whether blocker has blocked blocked — the exact direction, not the
// symmetric IsBlocked. It backs the directional guards (chat, friend requests,
// invitations) that must tell the blocker (refuse) from the blocked (silently suppress).
func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
return svc.store.blockExists(ctx, blocker, blocked)
}
// isBlocked reports whether a block row exists between a and b in either direction.
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
@@ -58,14 +107,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return true, nil
}
// insertBlock severs any friendship between the pair and inserts the block, in one
// transaction; a duplicate block is ignored.
// blockExists reports whether blocker has blocked blocked — the exact direction, not
// the symmetric isBlocked. The asymmetric block turns on this one-directional test:
// the blocker filters out and refuses the blocked user, while the blocked user's own
// actions are silently suppressed rather than refused, so they never notice.
func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))).
LIMIT(1)
var row model.Blocks
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: block exists: %w", err)
}
return true, nil
}
// insertBlock inserts the block and, in the same transaction, marks read every chat
// entry the blocked user authored that the blocker had still left unread across their
// shared games — so no stale unread badge lingers for someone the blocker no longer
// sees. It deliberately leaves the friendship intact (the block overrides it; an
// unblock restores it). A duplicate block is ignored.
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear friendship on block: %w", err)
}
ins := table.Blocks.
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
VALUES(blocker, blocked).
@@ -73,6 +141,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert block: %w", err)
}
// Clear the blocker's unread bit on every entry the blocked user sent, in any game
// they share, resolving the blocker's seat through game_players. The bitwise terms
// are cast through int4 so the operators resolve unambiguously (as in markRead).
const clearUnread = `UPDATE backend.chat_messages m
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
FROM backend.game_players p
WHERE p.account_id = $1 AND p.game_id = m.game_id
AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil {
return fmt.Errorf("clear unread on block: %w", err)
}
return nil
})
}
@@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
}
return out, nil
}
// listBlockedBy returns the accounts that have blocked blocked — the reverse of
// listBlocks. The matchmaker unions the two so a block in either direction keeps a
// pair out of the same auto-match game.
func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))
var rows []model.Blocks
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list blocked by: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.BlockerID)
}
return out, nil
}
+124 -13
View File
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil {
return Message{}, err
}
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
// Blocker-side guard: a sender whose only opponents are people they have blocked cannot
// post (the composer is hidden client-side; this is the server-side counterpart). In a
// multi-player game with a non-blocked opponent the post is allowed and reaches them.
if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil {
return Message{}, err
} else if guard {
return Message{}, ErrRecipientBlocked
}
// Recipients whose copy is born read so it never shows as unread: a disguised robot
// opponent (never opens the chat) and any recipient who has blocked the sender — the
// latter is also dropped from live delivery below, so the block stays invisible to the
// blocked sender while the blocker sees nothing.
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
if err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
suppressed, err := svc.blockedRecipients(ctx, seats, senderID)
if err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed)))
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindMessage)
svc.emitChat(seats, senderID, msg)
svc.emitChat(seats, senderID, msg, suppressed)
return msg, nil
}
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
// The awaited player is the nudge's sole recipient.
var target uuid.UUID
if toMove >= 0 && toMove < len(seats) {
target = seats[toMove]
}
// Blocker-side guard: a sender who has blocked the awaited player cannot nudge them
// (the control is hidden client-side; this is the server-side counterpart).
if target != uuid.Nil {
if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil {
return Message{}, err
} else if guard {
return Message{}, ErrRecipientBlocked
}
}
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
if err != nil {
return Message{}, err
@@ -153,16 +182,30 @@ 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, int16(1)<<uint(toMove))
// Suppress when the awaited player has blocked the sender: the nudge still persists and
// counts toward the once-per-hour cooldown (the sender notices nothing) but is born read
// (mask 0) and never delivered, so the blocker sees no nudge.
var suppressed bool
if target != uuid.Nil {
suppressed, err = svc.store.blockExists(ctx, target, senderID)
if err != nil {
return Message{}, err
}
}
var mask int16
if !suppressed && target != uuid.Nil {
mask = int16(1) << uint(toMove)
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask)
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) {
if !suppressed && target != uuid.Nil {
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
nudge := notify.Nudge(target, gameID, senderID, senderName)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
}
@@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID,
return false, nil
}
// emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
// emitChat pushes a chat message to every seated player except the sender and any
// recipient in suppressed — a recipient who has blocked the sender, who must never see
// it (best-effort live delivery; the other recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) {
intents := make([]notify.Intent, 0, len(seats))
for _, id := range seats {
if id == senderID {
if id == senderID || suppressed[id] {
continue
}
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
@@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
// seated player. Messages from a sender the viewer has blocked are dropped — one
// direction only, so a blocked player still sees the blocker's messages and never
// notices the block — and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
if seat == viewerID {
continue
}
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
yes, err := svc.store.blockExists(ctx, viewerID, seat)
if err != nil {
return nil, err
}
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
return robots, nil
}
// blockedRecipients returns the seated recipients (every non-empty seat but the sender)
// that have blocked the sender. Their copy of a message or nudge is born read and is not
// delivered — the store-but-hide that keeps a block invisible to the blocked sender while
// denying the blocker any sight of what they send.
func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
var out map[uuid.UUID]bool
for _, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
yes, err := svc.store.blockExists(ctx, id, senderID)
if err != nil {
return nil, err
}
if yes {
if out == nil {
out = make(map[uuid.UUID]bool)
}
out[id] = true
}
}
return out, nil
}
// senderBlocksEveryOpponent reports whether senderID has blocked every other seated
// player (and there is at least one). It is the blocker-side chat guard: a player whose
// only opponents are people they have blocked cannot post. In a multi-player game that
// still has a non-blocked opponent it is false, so the player can talk to the others.
func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) {
opponents := 0
for _, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
opponents++
blocked, err := svc.store.blockExists(ctx, senderID, id)
if err != nil {
return false, err
}
if !blocked {
return false, nil
}
}
return opponents > 0, nil
}
// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the
// born-read recipients (disguised robots and recipients who blocked the sender) into one
// mask input.
func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool {
if len(b) == 0 {
return a
}
if len(a) == 0 {
return b
}
out := make(map[uuid.UUID]bool, len(a)+len(b))
for id := range a {
out[id] = true
}
for id := range b {
out[id] = true
}
return out
}
// 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()
+66 -8
View File
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if requesterID == addresseeID {
return ErrSelfRelation
}
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
if err != nil {
return err
}
theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
if err != nil {
return err
}
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
if blocked || addressee.BlockFriendRequests {
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
}
// When the addressee has blocked the requester, the request still proceeds and persists
// by the normal logic below, but it is never delivered and the blocker never sees it
// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
// block. The flag gates only the notification; it also bypasses the addressee's
// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
suppressed := theyBlockMe
if !suppressed && addressee.BlockFriendRequests {
return ErrRequestBlocked
}
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
@@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
}
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
return svc.store.deleteFriendship(ctx, accountID, otherID)
}
// ListFriends returns the account IDs that are accepted friends of accountID.
// ListFriends returns the account IDs that are accepted friends of accountID, with any
// the caller has blocked filtered out: a block overrides — but does not delete — the
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
// list still shows the blocker (they never notice).
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listFriends(ctx, accountID)
ids, err := svc.store.listFriends(ctx, accountID)
if err != nil {
return nil, err
}
return svc.dropBlocked(ctx, accountID, ids)
}
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
// pending friend request awaiting accountID's response.
// pending friend request awaiting accountID's response, with any from a requester the
// caller has blocked filtered out (their suppressed request stays stored but unseen).
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
if err != nil {
return nil, err
}
return svc.dropBlocked(ctx, accountID, ids)
}
// dropBlocked removes from ids every account the viewer has blocked. It keeps the
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
// friend / incoming-request lists, while the blocked user's lists are never filtered.
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
if len(ids) == 0 {
return ids, nil
}
blockedIDs, err := svc.store.listBlocks(ctx, viewer)
if err != nil {
return nil, err
}
if len(blockedIDs) == 0 {
return ids, nil
}
blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
for _, id := range blockedIDs {
blocked[id] = struct{}{}
}
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
if _, b := blocked[id]; b {
continue
}
out = append(out, id)
}
return out, nil
}
// ListOutgoingRequests returns the account IDs the caller has already requested and
+4
View File
@@ -80,6 +80,10 @@ var (
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")
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
// player's own sends are never refused, only silently suppressed, so they never notice).
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
// ErrEmptyMessage is returned when a chat message is blank after trimming.