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 the given target 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. The target is either a blocked human's account id (the // blocks table) or a robot_blocks row id (a per-game disguised-robot block) — the robot // block is tried first, falling back to the human block. Any friendship the block had been // overriding becomes effective again. It is idempotent. func (svc *Service) Unblock(ctx context.Context, blockerID, target uuid.UUID) error { removed, err := svc.store.deleteRobotBlock(ctx, blockerID, target) if err != nil { return err } if !removed { if err := svc.store.deleteBlock(ctx, blockerID, target); err != nil { return err } } svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, target))) 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 }