81b9e1529e
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.
204 lines
8.3 KiB
Go
204 lines
8.3 KiB
Go
package social
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"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. 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
|
|
}
|
|
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 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 {
|
|
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.
|
|
func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid.UUID, error) {
|
|
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).
|
|
FROM(table.Blocks).
|
|
WHERE(
|
|
table.Blocks.BlockerID.EQ(postgres.UUID(a)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(b))).
|
|
OR(table.Blocks.BlockerID.EQ(postgres.UUID(b)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(a)))),
|
|
).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: is blocked: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// 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 {
|
|
ins := table.Blocks.
|
|
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
|
VALUES(blocker, blocked).
|
|
ON_CONFLICT(table.Blocks.BlockerID, table.Blocks.BlockedID).DO_NOTHING()
|
|
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
|
|
})
|
|
}
|
|
|
|
// deleteBlock removes a block. It is idempotent.
|
|
func (s *Store) deleteBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
|
stmt := table.Blocks.DELETE().WHERE(
|
|
table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
|
|
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked))),
|
|
)
|
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
|
return fmt.Errorf("social: delete block: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// listBlocks returns the accounts blocker has blocked.
|
|
func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID, error) {
|
|
stmt := postgres.SELECT(table.Blocks.BlockedID).
|
|
FROM(table.Blocks).
|
|
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)))
|
|
var rows []model.Blocks
|
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
|
return nil, fmt.Errorf("social: list blocks: %w", err)
|
|
}
|
|
out := make([]uuid.UUID, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, r.BlockedID)
|
|
}
|
|
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
|
|
}
|