Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use MiniShop3\Router\HttpStatus;
use MiniShop3\Router\Response;
use MiniShop3\Services\Category\CategoryProductActionPermissions;
use MiniShop3\Services\Category\CategoryProductDocumentPolicy;
use MiniShop3\Services\Category\CategoryProductScopeService;
use MiniShop3\Services\Category\CategoryProductsListService;
use MiniShop3\Services\FilterConfigManager;
Expand Down Expand Up @@ -38,14 +39,9 @@ public function __construct(modX $modx)
public function getList(array $params = []): array
{
$categoryId = (int) ($params['id'] ?? 0);

if (!$categoryId) {
return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData();
}

$category = $this->modx->getObject(msCategory::class, $categoryId);
if (!$category) {
return Response::error('Category not found', HttpStatus::NOT_FOUND)->getData();
$resolved = $this->requireCategoryWithView($categoryId);
if (is_array($resolved)) {
return $resolved;
}

$start = (int) ($params['start'] ?? 0);
Expand All @@ -67,7 +63,8 @@ public function getList(array $params = []): array
return Response::error('Category products list service is not available', 500)->getData();
}

$page = $listService->getPage(
$results = $this->collectVisibleListPage(
$listService,
$categoryId,
$params,
$nested,
Expand All @@ -78,9 +75,19 @@ public function getList(array $params = []): array
$sortDir
);

$total = $this->countVisibleListResults(
$listService,
$categoryId,
$params,
$nested,
$gridFields,
(string) $sortBy,
$sortDir
);

return Response::success([
'results' => $page['results'],
'total' => $page['total'],
'results' => $results,
'total' => $total,
])->getData();
}

Expand All @@ -93,6 +100,12 @@ public function getList(array $params = []): array
*/
public function getFilters(array $params = []): array
{
$categoryId = (int) ($params['id'] ?? 0);
$resolved = $this->requireCategoryWithView($categoryId);
if (is_array($resolved)) {
return $resolved;
}

/** @var FilterConfigManager $filterConfigManager */
$filterConfigManager = $this->modx->services->get('ms3_filter_config');

Expand All @@ -118,15 +131,17 @@ public function sort(array $params = []): array
$items = $params['items'] ?? [];
$nested = $this->isNested($params);

if (!$categoryId) {
return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData();
}

if (empty($items) || !is_array($items)) {
return Response::error('Items array is required', HttpStatus::BAD_REQUEST)->getData();
}

$resolved = $this->requireCategoryWithView($categoryId);
if (is_array($resolved)) {
return $resolved;
}

$updated = 0;
$policyDenied = 0;

$scope = $this->scopeService();

Expand All @@ -140,12 +155,27 @@ public function sort(array $params = []): array

$product = $scope->findInCategory($categoryId, $productId, $nested);

if ($product) {
$product->set('menuindex', $menuindex);
if ($product->save()) {
$updated++;
}
if (!$product) {
continue;
}

if (!CategoryProductDocumentPolicy::isAllowedAll($product, CategoryProductDocumentPolicy::sortPolicies())) {
$policyDenied++;
$this->logDocumentPolicyDenied($product, CategoryProductDocumentPolicy::sortPolicies());
continue;
}

$product->set('menuindex', $menuindex);
if ($product->save()) {
$updated++;
}
}

if ($updated === 0 && $policyDenied > 0) {
return Response::error(
'Save permission denied for this document',
HttpStatus::FORBIDDEN
)->getData();
}

return Response::success([
Expand Down Expand Up @@ -210,8 +240,11 @@ public function multiple(array $params = []): array
return Response::error('No valid product IDs provided', HttpStatus::BAD_REQUEST)->getData();
}

$documentPolicies = CategoryProductActionPermissions::documentPoliciesForMethod($method);

$success = 0;
$failed = 0;
$policyDenied = 0;
$scope = $this->scopeService();

foreach ($ids as $id) {
Expand All @@ -222,6 +255,15 @@ public function multiple(array $params = []): array
continue;
}

if (
$documentPolicies !== null
&& !CategoryProductDocumentPolicy::isAllowedAll($product, $documentPolicies)
) {
$this->logDocumentPolicyDenied($product, $documentPolicies);
$policyDenied++;
continue;
}

// $method is already validated by CategoryProductActionPermissions::evaluate()
// above (unknown → 400 before this loop); default is defensive/unreachable.
$result = match ($method) {
Expand All @@ -242,6 +284,13 @@ public function multiple(array $params = []): array
}

if ($success === 0) {
if ($policyDenied > 0) {
return Response::error(
'Save permission denied for this document',
HttpStatus::FORBIDDEN
)->getData();
}

return Response::error('No products were updated', HttpStatus::INTERNAL_SERVER_ERROR)->getData();
}

Expand Down Expand Up @@ -303,6 +352,13 @@ public function publish(array $params = []): array
$published = $product->get('published') ? 0 : 1;
}

$policies = CategoryProductDocumentPolicy::policiesForPublish((bool) $published);
if ($denied = CategoryProductDocumentPolicy::denialResponseAll($product, $policies)) {
$this->logDocumentPolicyDenied($product, $policies);

return $denied;
}

if (!$this->applyPublish($product, (bool) $published)) {
return Response::error('Failed to update product', HttpStatus::INTERNAL_SERVER_ERROR)->getData();
}
Expand Down Expand Up @@ -331,6 +387,216 @@ private function scopeService(): CategoryProductScopeService
: new CategoryProductScopeService($this->modx);
}

/**
* @return msCategory|array msCategory on success, error response array on failure
*/
private function requireCategoryWithView(int $categoryId): object|array
{
if (!$categoryId) {
return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData();
}

$category = $this->modx->getObject(msCategory::class, $categoryId);
if (!$category) {
return Response::error('Category not found', HttpStatus::NOT_FOUND)->getData();
}

if ($denied = CategoryProductDocumentPolicy::denialResponse(
$category,
CategoryProductDocumentPolicy::categoryViewPolicy()
)) {
$this->modx->log(
modX::LOG_LEVEL_WARN,
'[CategoryProductsController] Document view denied for category '
. $categoryId
. ' (user id ' . (int) ($this->modx->user->get('id') ?? 0) . ')'
);

return $denied;
}

return $category;
}

/**
* @param list<array<string, mixed>> $results
* @return list<array<string, mixed>>
*/
private function filterListResultsByDocumentView(array $results, bool $nested): array
{
if ($results === []) {
return [];
}

$productIds = array_values(array_filter(array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$results
)));

if ($productIds === []) {
return [];
}

/** @var array<int, msProduct> $productsById */
$productsById = [];
$collection = $this->modx->getCollection(msProduct::class, ['id:IN' => $productIds]);
foreach ($collection as $product) {
if ($product instanceof msProduct) {
$productsById[(int) $product->get('id')] = $product;
}
}

/** @var array<int, msCategory> $parentsById */
$parentsById = [];
if ($nested) {
$parentIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): int => (int) ($row['parent'] ?? 0),
$results
))));
if ($parentIds !== []) {
$parentCollection = $this->modx->getCollection(msCategory::class, ['id:IN' => $parentIds]);
foreach ($parentCollection as $parent) {
if ($parent instanceof msCategory) {
$parentsById[(int) $parent->get('id')] = $parent;
}
}
}
}

$filtered = [];
foreach ($results as $row) {
$productId = (int) ($row['id'] ?? 0);
$product = $productsById[$productId] ?? null;
if (!$product instanceof msProduct) {
continue;
}

if (CategoryProductDocumentPolicy::canViewInCategoryGridCached($product, $nested, $parentsById)) {
$filtered[] = $row;
}
}

return $filtered;
}

/**
* @param array<int, array<string, mixed>> $gridFields
* @return list<array<string, mixed>>
*/
private function collectVisibleListPage(
CategoryProductsListService $listService,
int $categoryId,
array $params,
bool $nested,
array $gridFields,
int $start,
int $limit,
string $sortBy,
string $sortDir,
): array {
if ($limit <= 0) {
return [];
}

$visible = [];
$scanOffset = 0;
$skipped = 0;
$batchSize = max($limit * 2, 20);

while (count($visible) < $limit) {
$page = $listService->getPage(
$categoryId,
$params,
$nested,
$gridFields,
$scanOffset,
$batchSize,
$sortBy,
$sortDir
);

if ($page['results'] === []) {
break;
}

$filtered = $this->filterListResultsByDocumentView($page['results'], $nested);
foreach ($filtered as $row) {
if ($skipped < $start) {
$skipped++;
continue;
}

$visible[] = $row;
if (count($visible) >= $limit) {
break 2;
}
}

$scanOffset += count($page['results']);
if (count($page['results']) < $batchSize) {
break;
}
}

return $visible;
}

/**
* @param array<int, array<string, mixed>> $gridFields
*/
private function countVisibleListResults(
CategoryProductsListService $listService,
int $categoryId,
array $params,
bool $nested,
array $gridFields,
string $sortBy,
string $sortDir,
): int {
$visible = 0;
$scanOffset = 0;
$batchSize = 200;

while (true) {
$page = $listService->getPage(
$categoryId,
$params,
$nested,
$gridFields,
$scanOffset,
$batchSize,
$sortBy,
$sortDir
);

if ($page['results'] === []) {
break;
}

$visible += count($this->filterListResultsByDocumentView($page['results'], $nested));
$scanOffset += count($page['results']);

if (count($page['results']) < $batchSize) {
break;
}
}

return $visible;
}

/** @param list<string> $policies */
private function logDocumentPolicyDenied(msProduct $product, array $policies): void
{
$this->modx->log(
modX::LOG_LEVEL_WARN,
'[CategoryProductsController] Document policy denied ('
. implode(',', $policies)
. ') for product '
. (int) $product->get('id')
. ' (user id ' . (int) ($this->modx->user->get('id') ?? 0) . ')'
);
}

private function denyWithoutPermission(string $permission): ?array
{
if ($this->modx->hasPermission($permission)) {
Expand Down
Loading