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 (R4). 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 } blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID) 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 blocked || 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 } 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 } 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. func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { return svc.store.listFriends(ctx, accountID) } // ListIncomingRequests returns the account IDs that have a live (not yet expired) // pending friend request awaiting accountID's response. func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) } // 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 }