Files
scrabble-game/backend/internal/social/friends.go
T
Ilia Denisov 9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
feat(social): hide social controls on a deleted opponent's seat
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00

435 lines
17 KiB
Go

package social
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Friendship statuses persisted in friendships.status.
const (
friendPending = "pending"
friendAccepted = "accepted"
friendDeclined = "declined"
)
// friendRequestTTL is how long an unanswered (ignored) friend request stays
// pending before it lazily expires and may be re-sent. An explicit decline is
// remembered permanently (status 'declined') instead and is not subject to this
// window; a one-time friend code from the addressee bypasses a decline.
const friendRequestTTL = 30 * 24 * time.Hour
// accountRef resolves accountID into a notify.AccountRef (the display name from the account
// store, empty on a lookup failure), for enriching the friend_* live events so the client
// updates its requests/friends state without a refetch.
func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
ref := notify.AccountRef{AccountID: accountID.String()}
if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
ref.DisplayName = acc.DisplayName
}
return ref
}
// SendFriendRequest records a pending friend request from requesterID to
// addresseeID — the "befriend an opponent" path. It requires the two to share a
// game (active or finished) and refuses a self-request, a request across a block or
// the addressee's block_friend_requests toggle, a duplicate of a live request or an
// existing friendship, and a re-send after an explicit decline (ErrRequestDeclined).
// An ignored request that has lazily expired (friendRequestTTL) may be re-sent and
// reopens the existing row with a fresh clock.
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
if requesterID == addresseeID {
return ErrSelfRelation
}
// A guest cannot use friends — friends are a durable-account feature; the UI hides the
// flow, this is the server source of truth.
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
return err
} else if acc.IsGuest {
return ErrGuestForbidden
}
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
}
addressee, err := svc.accounts.GetByID(ctx, addresseeID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return account.ErrNotFound
}
return err
}
if addressee.Deleted {
// The addressee deleted their account: there is nobody to answer the request, so it is
// refused rather than left pending forever.
return ErrAccountDeleted
}
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
}
// 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)
if err != nil {
return err
}
if !shared {
return ErrNoSharedGame
}
edges, err := svc.store.loadEdges(ctx, requesterID, addresseeID)
if err != nil {
return err
}
cutoff := svc.now().Add(-friendRequestTTL)
for _, e := range edges {
// Already friends, or the addressee already has a live request awaiting the
// requester — in both cases there is nothing to (re-)send.
if e.Status == friendAccepted {
return ErrRequestExists
}
if e.RequesterID == addresseeID && e.Status == friendPending && e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
}
for _, e := range edges {
if e.RequesterID != requesterID {
continue
}
switch e.Status {
case friendDeclined:
return ErrRequestDeclined
case friendPending:
if e.CreatedAt.After(cutoff) {
return ErrRequestExists
}
// An ignored request that has expired — reopen it with a fresh clock.
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
return err
}
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
if isUniqueViolation(err) {
return ErrRequestExists
}
return err
}
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining records a
// permanent 'declined' status (so the same requester cannot re-send), rather than
// deleting the row. Either way ErrRequestNotFound is returned when no pending
// request matches.
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
var ok bool
var err error
if accept {
ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
} else {
ok, err = svc.store.declineFriendRequest(ctx, requesterID, addresseeID, svc.now())
}
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
// Tell the original requester their request was answered, so a game screen watching
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
// -> stays "request sent").
if accept {
svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendAdded, svc.accountRef(ctx, addresseeID)))
} else {
svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendDeclined, svc.accountRef(ctx, addresseeID)))
}
return nil
}
// CancelFriendRequest withdraws requesterID's own pending request to addresseeID.
func (svc *Service) CancelFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
ok, err := svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
return nil
}
// Unfriend removes the friendship between the two accounts, in either direction.
// It is idempotent: removing a non-existent friendship is not an error.
func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) error {
return svc.store.deleteFriendship(ctx, accountID, otherID)
}
// 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) {
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, 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) {
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
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
// permanently declined. The game's "add to friends" item reads it to stay disabled
// across reloads (a declined request reads identically to a still-pending one).
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}
// loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
stmt := postgres.SELECT(table.Friendships.AllColumns).
FROM(table.Friendships).
WHERE(edgeEither(a, b))
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: load friendship edges: %w", err)
}
return rows, nil
}
// insertFriendRequest inserts a pending request from requester to addressee,
// stamping created_at so the lazy-expiry clock is deterministic under a fake now.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, table.Friendships.CreatedAt,
).VALUES(requester, addressee, friendPending, now)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: insert friend request: %w", err)
}
return nil
}
// acceptFriendRequest flips a pending request to accepted and reports whether a
// row matched.
func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
stmt := table.Friendships.
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
SET(postgres.String(friendAccepted), postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: accept friend request")
}
// deletePendingRequest removes a pending request and reports whether a row matched.
// It backs the requester's own cancel (which leaves no trace, unlike a decline).
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
stmt := table.Friendships.DELETE().WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: delete friend request")
}
// declineFriendRequest marks a pending request from requester to addressee as
// permanently declined (so the requester cannot re-send) and reports whether a row
// matched.
func (s *Store) declineFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
stmt := table.Friendships.
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
SET(postgres.String(friendDeclined), postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: decline friend request")
}
// refreshFriendRequest resets an expired pending request's created_at so it counts
// as freshly sent again.
func (s *Store) refreshFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
stmt := table.Friendships.
UPDATE(table.Friendships.CreatedAt).
SET(postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: refresh friend request: %w", err)
}
return nil
}
// deleteFriendship removes an accepted friendship in either direction.
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
stmt := table.Friendships.DELETE().WHERE(
edgeEither(a, b).AND(table.Friendships.Status.EQ(postgres.String(friendAccepted))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: delete friendship: %w", err)
}
return nil
}
// listFriends returns the other side of every accepted edge touching accountID.
func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID, table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.Status.EQ(postgres.String(friendAccepted)).
AND(table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
OR(table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list friends: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
if r.RequesterID == accountID {
out = append(out, r.AddresseeID)
} else {
out = append(out, r.RequesterID)
}
}
return out, nil
}
// listIncomingRequests returns the requesters of every live (created after cutoff)
// pending request to accountID; lazily expired requests are hidden.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID).
FROM(table.Friendships).
WHERE(
table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list incoming requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.RequesterID)
}
return out, nil
}
// listOutgoingRequests returns the addressees of the caller's requests that block a
// re-send: a live (created after cutoff) pending request, or a permanently declined
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.AddresseeID)
}
return out, nil
}
// edgeEither matches a friendship row between a and b in either direction.
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
OR(table.Friendships.RequesterID.EQ(postgres.UUID(b)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(a))))
}
// execAffected runs a mutating statement and reports whether it changed a row.
func execAffected(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) (bool, error) {
res, err := stmt.ExecContext(ctx, db)
if err != nil {
return false, fmt.Errorf("%s: %w", what, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("%s rows: %w", what, err)
}
return n > 0, nil
}