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/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) // Friendship statuses persisted in friendships.status. const ( friendPending = "pending" friendAccepted = "accepted" ) // SendFriendRequest records a pending friend request from requesterID to // addresseeID. It refuses a self-request, a request blocked by either a per-user // block or the addressee's block_friend_requests toggle, and a duplicate of an // existing request or friendship in either direction. 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 } exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID) if err != nil { return err } if exists { return ErrRequestExists } if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil { if isUniqueViolation(err) { return ErrRequestExists } return err } return nil } // RespondFriendRequest lets addresseeID accept or decline the pending request // from requesterID. Accepting flips it to a friendship; declining deletes it. // 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.deletePendingRequest(ctx, requesterID, addresseeID) } if err != nil { return err } if !ok { return ErrRequestNotFound } 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 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) } // friendshipExists reports whether any friendship row (pending or accepted) exists // between a and b in either direction. func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) { stmt := postgres.SELECT(table.Friendships.Status). FROM(table.Friendships). WHERE(edgeEither(a, b)). LIMIT(1) var row model.Friendships if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return false, nil } return false, fmt.Errorf("social: friendship exists: %w", err) } return true, nil } // insertFriendRequest inserts a pending request from requester to addressee. func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error { stmt := table.Friendships.INSERT( table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, ).VALUES(requester, addressee, friendPending) 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. 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") } // 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 pending request to accountID. func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]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))), ) 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 } // 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 }