Skip to content

Pass customer and store explicitly to catalog read services - #930

Merged
KrzysztofPajak merged 9 commits into
developfrom
refactor/business-explicit-context-catalog
Oct 16, 2026
Merged

KrzysztofPajak merged 9 commits into
developfrom
refactor/business-explicit-context-catalog

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Type: feature

Issue

Six Catalog read services (BrandService, CategoryService, ProductCategoryService, CollectionService, ProductCollectionService, ProductService) injected IContextAccessor and filtered by WorkContext.CurrentCustomer / StoreContext.CurrentStore. What they returned depended on who called them. Outside an HTTP request (scheduled tasks, the API module, background mediator handlers) the customer and store were wrong or missing. This class of bug caused #860 (sitemap) and #878 (scheduler work context).

Solution

The six services no longer depend on IContextAccessor. Every read that filtered by the customer and store now gets them from its caller.

CatalogAccess

The reads that also offered a showHidden flag take one CatalogAccess argument (Grand.Business.Core.Utilities.Catalog) instead of a customer, a store id and the flag. Before, the customer had to be null exactly when hidden records were asked for, and only a comment said so. Now the caller states which view it wants:

  • CatalogAccess.For(customer, storeId): the published records the customer's groups may see in the store. For rejects a null customer. The storefront builds it from the request with IContextAccessor.CatalogAccess() (Grand.Web.Common.Extensions).
  • CatalogAccess.IncludingHidden(storeId = ""): every record, unpublished and group-limited ones included, for the admin panels, import, export and the sitemap. A store id still leaves out the records limited to other stores, which is how the store panel and the sitemap XML scope their lists.

CatalogAccess is a record: two accesses with the same customer and store are equal, so tests can match it directly.

Methods that take it:

  • IBrandService.GetAllBrands(brandName, access, pageIndex, pageSize), ICollectionService.GetAllCollections(collectionName, access, ...), ICategoryService.GetAllCategories(parentId, categoryName, access, ...).
  • ICategoryService.GetAllCategoriesByParentCategoryId(access, parentCategoryId, includeAllLevels), GetAllCategoriesDisplayedOnHomePage(access), GetAllCategoriesFeaturedProductsOnHomePage(access) and both GetCategoryBreadCrumb overloads. ICollectionService.GetAllCollectionFeaturedProductsOnHomePage(access).
  • IProductCategoryService.GetProductCategoriesByCategoryId(categoryId, access, ...) and IProductCollectionService.GetProductCollectionsByCollectionId(collectionId, access, ...).
  • IProductService.GetProductsByIds(ids, access), SearchProducts(access, ...) (its storeId and showHidden parameters are gone) and GetAssociatedProducts(parentId, access, vendorId).

Storefront-only reads

The reads that never show hidden records take the customer and the store explicitly: ICategoryService.GetMenuCategories(customer, storeId) and GetAllCategoriesSearchBox(customer, storeId), IProductService.GetCrossSellProductsByShoppingCart(cart, number, customer, storeId) and IRecentlyViewedProductsService.GetRecentlyViewedProducts(customer, storeId, number).

Queries

GetPersonalizedProductsQuery, GetRecommendedProductsQuery and GetSuggestedProductsQuery carry Customer and StoreId. Their handlers take the customer id, groups and tags from Customer, so the fields that duplicated it are gone.

Behavior

One rule now decides the store limitation: it applies whenever the access has a store id. Before, some methods ignored the store for hidden records and others did not. Every back-office caller of the former passes IncludingHidden() without a store, so what each screen returns is unchanged. Other details:

  • GetSearchProductsQueryHandler reads the customer's groups only when the group filter applies. Before, it read them even with ShowHidden.
  • GetAllCategoriesByParentCategoryId, GetProductCategoriesByCategoryId and GetProductCollectionsByCollectionId leave the customer's groups out of the cache key for hidden records, because they do not filter them.
  • CategoryService.DeleteCategory reads the child categories straight from the repository. Before, it used the cached lookup, which keys on the current customer's groups and store.
  • A storefront category or product mapping lookup with an empty store id no longer drops the records limited to stores; it skips the store limitation, as IAclService.Authorize already did. Every storefront caller passes the request's store.

Not in this PR (later M1 steps): PricingService, TaxService, PriceFormatter and ProductAttributeFormatter. They also read the working currency, language and host.

Breaking changes

  • Every method signature listed under Solution changed. A plugin that calls one of them passes a CatalogAccess (or the customer and store for the storefront-only reads) and must be rebuilt. IRecentlyViewedProductsService.GetRecentlyViewedProducts takes the Customer instead of its id.
  • GetPersonalizedProductsQuery, GetRecommendedProductsQuery and GetSuggestedProductsQuery now require Customer, and their handlers throw ArgumentNullException without one. Their results are cached under keys shared by many customers, so a query without a customer must not fill that entry with an empty list. A plugin or theme that sends these queries has to set Customer (and StoreId). GetPersonalizedProductsQuery.CustomerId, GetRecommendedProductsQuery.CustomerGroupIds and GetSuggestedProductsQuery.CustomerTagIds are removed.
  • The constructors of the six services no longer take IContextAccessor. This affects code that constructs or subclasses them.
  • Constructor changes in Web: BaseSearchController (and the Admin, Store and Vendor SearchControllers), Admin ProductTagsController, and the BlogPostProducts, HomePageProducts, RecommendedProducts, RelatedProducts and SimilarProducts view components now take IContextAccessor.

Testing

  1. dotnet build GrandNode.slnx
  2. dotnet test GrandNode.slnx: all green. Grand.Business.Customers.Tests had two failures only in the parallel full run and passes when run alone.
  3. CatalogAccessTests and CatalogAccessExtensionsTests cover the type: For rejects a null customer, IncludingHidden shows hidden records of any store by default, and equality compares the customer and the store.
  4. New tests show that the access drives the filters: BrandServiceTests.GetAllBrands_FiltersByGroupsOfGivenCustomer, CollectionServiceTests.GetAllCollectionFeaturedProductsOnHomePage_AuthorizesAgainstGivenCustomerAndStore, CategoryServiceDbTests.GetMenuCategories_UsesGivenCustomerAndStore / GetCategoryBreadCrumb_StopsAtCategoryHiddenFromGivenCustomer / GetAllCategoriesByParentCategoryId_ShowHiddenWithoutCustomer_ReturnsHiddenChildren / GetAllCategoriesSearchBox_WithoutCustomer_Throws / DeleteCategory_DetachesItsChildCategories, ProductCategoryServiceTests.GetProductCategoriesByCategoryId_FiltersByGivenStore / GetProductCategoriesByCategoryId_ShowHiddenWithoutCustomer_ReturnsLimitedProducts, ProductCollectionServiceTests.GetProductCollectionsByCollectionId_FiltersByGroupsOfGivenCustomer, ProductServiceTests.GetProductsByIds_AuthorizesAgainstGivenCustomerAndStore / GetProductsByIds_IncludingHidden_ReturnsUnpublishedAndGroupLimitedProducts / GetProductsByIds_WithoutAccess_Throws / SearchProducts_SendsGivenCustomerInQuery, GetSearchProductsQueryHandlerTests.Handle_WhenShowHiddenWithoutCustomer_DoesNotFilterByGroups, Get{Personalized,Recommended,Suggested}ProductsQueryHandlerTests.Handle_WithoutCustomer_ThrowsBeforeCaching, GetRecommendedProductsQueryHandlerTests.Handle_AuthorizesProductsAgainstCustomerAndStoreOfQuery, GetPersonalizedProductsQueryHandlerTests.Handle_KeysAndLoadsByIdOfQueryCustomer, GetSuggestedProductsQueryHandlerTests.Handle_KeysAndFiltersByTagsOfQueryCustomer.
  5. Existing tests changed only in arguments and constructor wiring; no assertion was removed. Mock setups that named a store or customer now match the equal CatalogAccess, so they still check them. CategoryServiceCacheKeyTests still pins the category tree cache key.

Not done: a manual pass through the storefront and the admin panel.

support added 6 commits October 16, 2026 16:00
The handlers cache the ACL-filtered result under keys shared by many customers. A query without a customer would authorize nothing and cache the empty list for all of them. Also pins DeleteCategory detaching its children and restores a negative assertion to any customer and store.
Copilot AI balanced review requested due to automatic review settings October 16, 2026 14:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

support added 3 commits October 16, 2026 16:38
…stomer for hidden records

GetPersonalizedProductsQuery and GetSuggestedProductsQuery read the customer id and tags from Customer instead of separate fields that could disagree with it. The product mapping and child category lookups no longer read the customer or store when showHidden is set, so admin callers pass null. Methods that authorize through IAclService throw on a missing customer instead of returning nothing.
The catalog read services took a customer, a store id and a showHidden flag whose meaning depended on each other: the customer had to be null exactly when hidden records were asked for. CatalogAccess.For(customer, storeId) and CatalogAccess.IncludingHidden(storeId) make that choice one argument. The store limitation now applies whenever a store id is set, so the back office passes IncludingHidden() where it ignored the store before. The storefront builds its access from the request with IContextAccessor.CatalogAccess().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants