From 81b9e1529eedea9413c009c75374e38ac0a384d0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 18 Jun 2026 11:50:34 +0200 Subject: [PATCH 1/2] feat(social): asymmetric per-user block, in-game block control, admin lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PRERELEASE.md | 1 + backend/cmd/backend/main.go | 1 + backend/internal/adminconsole/render_test.go | 5 + .../templates/pages/user_detail.gohtml | 30 ++ backend/internal/adminconsole/views.go | 15 + backend/internal/game/service.go | 4 +- backend/internal/game/store.go | 9 +- backend/internal/inttest/block_test.go | 270 ++++++++++++++++++ backend/internal/inttest/game_limit_test.go | 2 +- backend/internal/inttest/lobby_test.go | 32 ++- backend/internal/inttest/open_match_test.go | 6 +- backend/internal/inttest/social_test.go | 71 ++++- backend/internal/lobby/invitations.go | 50 +++- backend/internal/lobby/lobby.go | 12 +- backend/internal/lobby/matchmaker.go | 33 ++- backend/internal/lobby/matchmaker_test.go | 31 +- backend/internal/notify/notify.go | 10 + .../internal/server/handlers_admin_console.go | 21 ++ backend/internal/server/handlers_blocks.go | 9 +- backend/internal/social/admin.go | 79 +++++ backend/internal/social/blocks.go | 123 +++++++- backend/internal/social/chat.go | 137 ++++++++- backend/internal/social/friends.go | 74 ++++- backend/internal/social/social.go | 4 + docs/ARCHITECTURE.md | 29 +- docs/FUNCTIONAL.md | 28 +- docs/FUNCTIONAL_ru.md | 29 +- docs/UI_DESIGN.md | 16 +- ui/e2e/social.spec.ts | 23 ++ ui/src/game/Chat.svelte | 7 + ui/src/game/ChatScreen.svelte | 27 ++ ui/src/game/Game.svelte | 108 ++++++- ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + 34 files changed, 1191 insertions(+), 109 deletions(-) create mode 100644 backend/internal/inttest/block_test.go create mode 100644 backend/internal/social/admin.go diff --git a/PRERELEASE.md b/PRERELEASE.md index 98890f4..3321c6c 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -35,6 +35,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** | | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | +| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 05cd6c1..1663c5b 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -189,6 +189,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger) matchmaker.SetNotifier(hub) + matchmaker.SetBlocker(socialSvc) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations.SetNotifier(hub) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 3d2952a..f827e3f 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -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"}}, diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index b8ca25a..8b2b353 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -102,6 +102,36 @@ +

Friends

+ + + +{{range .Friends}} + +{{else}}{{end}} + +
AccountFriends since
{{.DisplayName}}{{.Date}}
no friends
+
+

Blocks

+ + + +{{range .Blocks}} + +{{else}}{{end}} + +
AccountBlocked at
{{.DisplayName}}{{.Date}}
blocks no one
+
+

Blocked by

+ + + +{{range .BlockedBy}} + +{{else}}{{end}} + +
AccountBlocked at
{{.DisplayName}}{{.Date}}
blocked by no one
+
{{if .TelegramID}}

Send Telegram message

{{if .ConnectorEnabled}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 11a3de4..59cb2f7 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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 diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 522c3c0..5b2dac5 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 } diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index f726f27..bcdec97 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -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 diff --git a/backend/internal/inttest/block_test.go b/backend/internal/inttest/block_test.go new file mode 100644 index 0000000..7993323 --- /dev/null +++ b/backend/internal/inttest/block_test.go @@ -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 +} diff --git a/backend/internal/inttest/game_limit_test.go b/backend/internal/inttest/game_limit_test.go index 63bce78..56b1574 100644 --- a/backend/internal/inttest/game_limit_test.go +++ b/backend/internal/inttest/game_limit_test.go @@ -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). diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 4f237a4..74d0485 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -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) } } diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go index e266e7f..f66a5f6 100644 --- a/backend/internal/inttest/open_match_test.go +++ b/backend/internal/inttest/open_match_test.go @@ -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) } diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 971b027..f6f9e84 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -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) } } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 8a91e34..30b661b 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -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 diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index b088691..1b2c1fe 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -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 diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 8bf1646..54e9b16 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -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 } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 60cbfd4..dd258c4 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -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 { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 16b3ca0..9e74774 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -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 diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 80a3146..3dc929b 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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() diff --git a/backend/internal/server/handlers_blocks.go b/backend/internal/server/handlers_blocks.go index a523f52..0f5b4a3 100644 --- a/backend/internal/server/handlers_blocks.go +++ b/backend/internal/server/handlers_blocks.go @@ -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 { diff --git a/backend/internal/social/admin.go b/backend/internal/social/admin.go new file mode 100644 index 0000000..d6e6f81 --- /dev/null +++ b/backend/internal/social/admin.go @@ -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() +} diff --git a/backend/internal/social/blocks.go b/backend/internal/social/blocks.go index 4bd4417..b9d36bf 100644 --- a/backend/internal/social/blocks.go +++ b/backend/internal/social/blocks.go @@ -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 +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 9d07b88..27e8329 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -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)<= 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 ": …"; 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() diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index add0ec9..1f7a187 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -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 diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index f571f30..ac86a1b 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 489754c..f28570c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -470,7 +470,9 @@ disguised robot stays indistinguishable from a person. (or joins a different player's) rather than returning the caller's own, so choosing "random opponent" again always starts a new search (bounded by the simultaneous quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not - an in-memory pool), so it survives a restart and stays anonymous (no block check); + an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a +player is never paired into a game whose waiting opponent they have a per-user block with, +in either direction (the enqueue excludes the caller's `BlockedWith` set); concurrent enqueues for one bucket are serialised by a transaction-scoped advisory lock so two callers pair rather than each opening a game. A background **reaper** seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus @@ -506,12 +508,19 @@ disguised robot stays indistinguishable from a person. expires after **30 days** and may be re-sent), or **decline** — a decline is remembered (`status='declined'`) and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still - deletes the row; blocking someone severs an existing friendship. (Discovery by - friend list or platform deep-link is future work.) + deletes the row. (Discovery by friend list or platform deep-link is future work.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is - applied mutually: it hides the pair's chat from each other and refuses friend - requests and game invitations between them. + **asymmetric and non-destructive**: the blocker stops receiving everything **from** the + blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never + pairs them — while the blocked user notices nothing. Their sends still persist by the normal + rules but are never delivered or surfaced to the blocker (a directional `blockExists` check + drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read), + and applying a block also marks read any unread the blocked user had left for the blocker. It + **overrides but does not delete** a friendship (an unblock cleanly restores it), so a pair may + be friends **and** blocked at once; the admin user card shows the full truth (blocks, + blocked-by, friends) regardless of this suppression. Block/unblock emit a `user_blocked` / + `user_unblocked` notification to the blocker only (§10). - **Friend games**: formed by **invitation → accept** (an `game_invitations` record with one row per invitee). The 2–4 player game starts once **every** invitee accepts; any decline cancels the invitation, and a pending invitation @@ -693,7 +702,10 @@ opponent card and the resign/chat controls update **in place**; **game-over** ca carry the changed account / invitation, so the client patches its lobby lists in place: **invitation** and **invitation-update** carry the full invitation, and the client upserts a still-pending one and drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel -too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` / +too, fresh from any screen without a refetch. The **user-blocked** / **user-unblocked** sub-kinds +confirm a per-user block change to the **blocker only** (never the blocked user), carrying the other +account, so the blocker's open game screens re-derive the block / add-friend controls and the struck +name in place across sessions. The move-commit **response** (`submit_play` / `pass` / `exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby **preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved @@ -720,8 +732,9 @@ localized message with a Mini App deep-link button — only when the recipient h identity and has not confined notifications to the app, so the two channels never duplicate. The connector routes by that language to the matching bot and renders the message in it. The out-of-app set is your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds; -the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a -response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts +the connector renders the message and skips the rest — so in-app-only sub-kinds like +**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a +block-state sync to the blocker) never become a platform push. Operator broadcasts (`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 01c160d..200a226 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -179,14 +179,26 @@ digits, valid for twelve hours), or send a **request to someone you have played with** — they accept, ignore it (a request lapses after thirty days and can then be re-sent), or decline (a decline blocks further requests from you until they hand you a code). Cancelling your own pending request withdraws it; unfriending removes the -friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the -move history is open) that mirrors the live relationship: it confirms with a tap on a fading -✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is -pending or was declined, and **disappears** once you are friends — updating in place the -moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat -and/or friend requests — and block individual players (a per-user block hides that -person's chat and stops requests and game invitations both ways; it also ends any -existing friendship). Per-game chat is for quick reactions: messages are short +friendship. In a game, each opponent's score card (while the move history is open) carries two controls: +an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap +on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while +confirming — and while one is confirming the other is hidden so they never overlap. The 🤝 +goes **disabled** while a request is pending or was declined and **disappears** once you are +friends; both controls **disappear and the opponent's name is struck through** once you have +blocked them. They update in place the moment the relationship changes and stay correct across +reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a +live event; a transport failure rolls the control back to its prior state. + +Block globally — switch off incoming chat and/or friend requests — or block an **individual +player**. A per-user block is **one-directional and silent**: you stop receiving everything +**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker +never pairs you with them, while they **notice nothing**. Their messages and nudges still send +by the normal rules but never reach you (anything that would show as unread for you is marked +read at once), and a friend request or invitation they send you is kept but never surfaces. A +block **overrides but does not delete** an existing friendship (so you may block a friend, and +they keep seeing you as one); active games are never interrupted — you can finish them, with +the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card +mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. You may send **one message per turn, on your own turn**; once it is sent the field gives way to a short caption until your next turn. Nudge the player whose turn diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 18139ff..caeeec0 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -183,14 +183,27 @@ nudge) приходят от бота **этой партии** — по язы тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки -снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол -**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается -тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится -**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия — -обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие -чат и/или заявки — -и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки -и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат +снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой +истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый +подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или +*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы +они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и +**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым** +после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после +перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым +событием; при сбое транспорта контрол возвращается в прежнее состояние. + +Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного +игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека +всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с +ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным +правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается +прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок +**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он +продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у +заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в +партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** +есть только там. Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 8e428cc..e8369d7 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -131,10 +131,14 @@ except Login uses `Screen`. (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian game with "multiple words per turn" off) centres a **"One word per turn"** label between those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that - yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a - 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that - confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed - (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. + yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two + edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend, + disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap, + swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while + armed (see Controls); while one is armed the other hides so they never overlap. Once the + opponent is **blocked** both controls go and the **name is struck through** (and the comms-hub + chat composer is hidden — only the log remains). The name and score stay centred — the controls + are pinned to the card's edges. Unread chat is also badged on the score bar itself, so it shows with the history closed. - **Vertical fit & keyboard**: when the game does not fit the viewport, only the board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab @@ -180,8 +184,8 @@ except Login uses `Screen`. tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade under reduce-motion, but the window still holds); a tap on the ✅ within it confirms, otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing - the old press-and-hold popover) and the in-game **add-friend 🤝**. The - **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). + the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card + controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). - **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent button) beside the rack commits the move — no popover, and disabled while the pending word diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index db219a2..d07d1ec 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -193,6 +193,29 @@ test('game: the add-friend 🤝 confirms with a tap and then reads as sent', asy await expect(add).toBeDisabled(); }); +test('game: the block ✖️ confirms in red, then hides both controls and strikes the name', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann + await page.locator('.scoreboard').click(); // open the history -> 🤝 and ✖️ appear on Ann's card + const block = page.getByRole('button', { name: 'Block player' }); + await expect(block).toBeVisible(); + await block.click(); // arm: ✖️ -> a fading ✅, the score caption turns to a red "Block?" + await expect(page.locator('.sc.blockprompt')).toHaveText('Block?'); + // While confirming a block the add-friend control is hidden so the two never overlap. + await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0); + await block.click(); // confirm within the window + // The block applied (optimistically): both controls disappear and the name is struck. + await expect(page.getByRole('button', { name: 'Block player' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0); + await expect(page.locator('.nm.struck')).toBeVisible(); + + // The chat composer is hidden for a blocked opponent — only the log remains, as in a + // finished game. + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); +}); + test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 963d94f..e58199d 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -13,6 +13,7 @@ waiting = false, nudgeOnCooldown = false, vsAi = false, + blocked = false, onsend, onnudge, }: { @@ -38,6 +39,10 @@ // vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is // nobody to message or hurry. The fields still show (turn-driven), but disabled. vsAi?: boolean; + // blocked hides the whole composer (message field, send and nudge), leaving only the chat + // log — as in a finished game — when the viewer has blocked the opponent: there is no one + // they will message (the backend rejects it too). + blocked?: boolean; onsend: (text: string) => void; onnudge: () => void; } = $props(); @@ -65,6 +70,7 @@ {/if} {/each} + {#if !blocked}
{#if canSend} 🛎️ {/if}
+ {/if}