Skip to content
9 changes: 9 additions & 0 deletions .ai/knowledge/scoping.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ Prices are stored in the store's primary currency and converted for display. Do

Code in those paths must receive store, customer, or language **explicitly as parameters**. Reaching for `IWorkContext` there yields null or a stale context.

### Catalog reads take a `CatalogAccess`

The catalog read services (`IBrandService`, `ICategoryService`, `ICollectionService`, `IProductService` and the product mapping services) take a `CatalogAccess` instead of a customer, a store id and a `showHidden` flag:

- `CatalogAccess.For(customer, storeId)`: the published records the customer's groups may see in the store. The storefront gets 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.

A store id limits the records whenever it is set. Pass `IncludingHidden()` without a store when a back-office screen must see every store's records.

## Review checklist

- [ ] Every list query filters by store, and by vendor when the caller is a vendor.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Grand.Business.Core.Interfaces.Catalog.Products;
using Grand.Business.Core.Queries.Catalog;
using Grand.Business.Core.Utilities.Catalog;
using Grand.Data;
using Grand.Domain.Catalog;
using Grand.Domain.Customers;
Expand Down Expand Up @@ -28,17 +29,21 @@ public GetPersonalizedProductsQueryHandler(

public async Task<IList<Product>> Handle(GetPersonalizedProductsQuery request, CancellationToken cancellationToken)
{
return await _cacheBase.GetAsync(string.Format(CacheKey.PRODUCTS_CUSTOMER_PERSONAL_KEY, request.CustomerId),
//the result is cached per key shared by many customers, so a query without one must not fill it
ArgumentNullException.ThrowIfNull(request.Customer);

return await _cacheBase.GetAsync(string.Format(CacheKey.PRODUCTS_CUSTOMER_PERSONAL_KEY, request.Customer.Id),
async () =>
{
var query = from cr in _customerProductRepository.Table
where cr.CustomerId == request.CustomerId
where cr.CustomerId == request.Customer.Id
orderby cr.DisplayOrder
select cr.ProductId;

var productIds = query.Take(request.ProductsNumber).ToList();

var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray());
var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray(),
access: CatalogAccess.For(request.Customer, request.StoreId));

return ids.Where(product => product.Published).ToList();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Grand.Business.Core.Interfaces.Catalog.Products;
using Grand.Business.Core.Queries.Catalog;
using Grand.Business.Core.Utilities.Catalog;
using Grand.Data;
using Grand.Domain.Catalog;
using Grand.Domain.Customers;
Expand Down Expand Up @@ -28,18 +29,24 @@ public GetRecommendedProductsQueryHandler(

public async Task<IList<Product>> Handle(GetRecommendedProductsQuery request, CancellationToken cancellationToken)
{
//the result is cached per key shared by many customers, so a query without one must not fill it
ArgumentNullException.ThrowIfNull(request.Customer);

var customerGroupIds = request.Customer.GetCustomerGroupIds();

return await _cacheBase.GetAsync(
string.Format(CacheKey.PRODUCTS_CUSTOMER_GROUP, string.Join(",", request.CustomerGroupIds),
string.Format(CacheKey.PRODUCTS_CUSTOMER_GROUP, string.Join(",", customerGroupIds),
request.StoreId), async () =>
{
var query = from cr in _customerGroupProductRepository.Table
where request.CustomerGroupIds.Contains(cr.CustomerGroupId)
where customerGroupIds.Contains(cr.CustomerGroupId)
orderby cr.DisplayOrder
select cr.ProductId;

var productIds = query.ToList();

var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray());
var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray(),
access: CatalogAccess.For(request.Customer, request.StoreId));

return ids.Where(product => product.Published).ToList();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ public GetSearchProductsQueryHandler(
// Clean up request parameters
CleanupRequestParameters(request);

// Access control list. Allowed customer groups
var allowedCustomerGroupsIds = request.CustomerGroupIds?.ToArray() ?? request.Customer.GetCustomerGroupIds();
// Access control list. Allowed customer groups; the customer is read only when the filter applies
var allowedCustomerGroupsIds = request.CustomerGroupIds?.ToArray()
?? (request.ShowHidden || _accessControlConfig.IgnoreAcl
? []
: request.Customer.GetCustomerGroupIds());

// Build base query
var query = _productRepository.Table.AsQueryable();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Grand.Business.Core.Interfaces.Catalog.Products;
using Grand.Business.Core.Queries.Catalog;
using Grand.Business.Core.Utilities.Catalog;
using Grand.Data;
using Grand.Domain.Catalog;
using Grand.Domain.Customers;
Expand All @@ -23,17 +24,23 @@ public GetSuggestedProductsQueryHandler(

public async Task<IList<Product>> Handle(GetSuggestedProductsQuery request, CancellationToken cancellationToken)
{
//the result is cached per key shared by many customers, so a query without one must not fill it
ArgumentNullException.ThrowIfNull(request.Customer);

var customerTagIds = request.Customer.CustomerTags.ToArray();

return await _cacheBase.GetAsync(
string.Format(CacheKey.PRODUCTS_CUSTOMER_TAG, string.Join(",", request.CustomerTagIds)), async () =>
string.Format(CacheKey.PRODUCTS_CUSTOMER_TAG, string.Join(",", customerTagIds)), async () =>
{
var query = from cr in _customerTagProductRepository.Table
where request.CustomerTagIds.Contains(cr.CustomerTagId)
where customerTagIds.Contains(cr.CustomerTagId)
orderby cr.DisplayOrder
select cr.ProductId;

var productIds = query.Take(request.ProductsNumber).ToList();

var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray());
var ids = await _productService.GetProductsByIds(productIds.Distinct().ToArray(),
access: CatalogAccess.For(request.Customer, request.StoreId));

return ids.Where(product => product.Published).ToList();
});
Expand Down
27 changes: 12 additions & 15 deletions src/Business/Grand.Business.Catalog/Services/Brands/BrandService.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using Grand.Business.Core.Interfaces.Catalog.Brands;
using Grand.Business.Core.Utilities.Catalog;
using Grand.Data;
using Grand.Domain;
using Grand.Domain.Catalog;
using Grand.Domain.Customers;
using Grand.Infrastructure;
using Grand.Infrastructure.Caching;
using Grand.Infrastructure.Caching.Constants;
using Grand.Infrastructure.Configuration;
Expand All @@ -24,12 +24,10 @@ public class BrandService : IBrandService
/// </summary>
public BrandService(ICacheBase cacheBase,
IRepository<Brand> brandRepository,
IContextAccessor contextAccessor,
IMediator mediator, AccessControlConfig accessControlConfig)
{
_cacheBase = cacheBase;
_brandRepository = brandRepository;
_contextAccessor = contextAccessor;
_mediator = mediator;
_accessControlConfig = accessControlConfig;
}
Expand All @@ -39,7 +37,6 @@ public BrandService(ICacheBase cacheBase,
#region Fields

private readonly IRepository<Brand> _brandRepository;
private readonly IContextAccessor _contextAccessor;
private readonly IMediator _mediator;
private readonly ICacheBase _cacheBase;
private readonly AccessControlConfig _accessControlConfig;
Expand All @@ -52,41 +49,41 @@ public BrandService(ICacheBase cacheBase,
/// Gets all brands
/// </summary>
/// <param name="brandName">Brand name</param>
/// <param name="storeId">Store ident</param>
/// <param name="access">Whose view of the brands to return, and the store whose limitations apply</param>
/// <param name="pageIndex">Page index</param>
/// <param name="pageSize">Page size</param>
/// <param name="showHidden">A value that indicates if it should shows hidden records</param>
/// <returns>Brands</returns>
public virtual async Task<IPagedList<Brand>> GetAllBrands(string brandName,
string storeId,
CatalogAccess access,
int pageIndex = 0,
int pageSize = int.MaxValue,
bool showHidden = false)
int pageSize = int.MaxValue)
{
ArgumentNullException.ThrowIfNull(access);

var query = from m in _brandRepository.Table
select m;

if (!showHidden)
if (!access.ShowHidden)
query = query.Where(m => m.Published);
if (!string.IsNullOrWhiteSpace(brandName))
query = query.Where(m => m.Name != null && m.Name.ToLower().Contains(brandName.ToLower()));

if (!_accessControlConfig.IgnoreAcl ||
(!string.IsNullOrEmpty(storeId) && !_accessControlConfig.IgnoreStoreLimitations))
(!string.IsNullOrEmpty(access.StoreId) && !_accessControlConfig.IgnoreStoreLimitations))
{
if (!showHidden && !_accessControlConfig.IgnoreAcl)
if (!access.ShowHidden && !_accessControlConfig.IgnoreAcl)
{
//Limited to customer groups rules
var allowedCustomerGroupsIds = _contextAccessor.WorkContext.CurrentCustomer.GetCustomerGroupIds();
var allowedCustomerGroupsIds = access.Customer.GetCustomerGroupIds();
query = from p in query
where !p.LimitedToGroups || allowedCustomerGroupsIds.Any(x => p.CustomerGroups.Contains(x))
select p;
}

if (!string.IsNullOrEmpty(storeId) && !_accessControlConfig.IgnoreStoreLimitations)
if (!string.IsNullOrEmpty(access.StoreId) && !_accessControlConfig.IgnoreStoreLimitations)
//Limited to stores rules
query = from p in query
where !p.LimitedToStores || p.Stores.Contains(storeId)
where !p.LimitedToStores || p.Stores.Contains(access.StoreId)
select p;
}

Expand Down
Loading
Loading