diff --git a/core/components/minishop3/config/routes/manager.php b/core/components/minishop3/config/routes/manager.php index 0c085aab..eca8322e 100644 --- a/core/components/minishop3/config/routes/manager.php +++ b/core/components/minishop3/config/routes/manager.php @@ -329,6 +329,17 @@ CategoryProductActionPermissions::mutationPermissions() ) ]); + // Category-scoped inline-edit product data (#455) + $router->put('/{id}/products/{productId}/data', function($params) use ($modx) { + $input = file_get_contents('php://input'); + $data = json_decode($input, true) ?: []; + $allParams = array_merge($data, $_GET, $params); + + $controller = new \MiniShop3\Controllers\Api\Manager\CategoryProductsController($modx); + return $controller->updateProductData($allParams); + }, [ + new PermissionMiddleware($modx, 'msproduct_save') + ]); // Toggle product publish status $router->post('/{id}/products/{productId}/publish', function ($params) use ($modx) { $input = file_get_contents('php://input'); diff --git a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php index 282f222e..d77ada17 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php @@ -339,6 +339,76 @@ public function bulkDelete(array $params = []): array return $this->multiple($params); } + /** + * Update product data from category grid inline-edit + * PUT /api/mgr/categories/{id}/products/{productId}/data + * + * @param array $params + * @return array Response + */ + public function updateProductData(array $params = []): array + { + $categoryId = (int) ($params['id'] ?? 0); + $productId = (int) ($params['productId'] ?? 0); + $nested = filter_var($params['nested'] ?? false, FILTER_VALIDATE_BOOLEAN); + + if (!$categoryId) { + return Response::error('Category ID is required', HttpStatus::BAD_REQUEST)->getData(); + } + + if (!$productId) { + return Response::error('Product ID is required', HttpStatus::BAD_REQUEST)->getData(); + } + + $data = $params; + unset($data['id'], $data['productId'], $data['nested']); + + if ($data === []) { + return Response::error('Invalid request data', HttpStatus::BAD_REQUEST)->getData(); + } + + // Scope check + product fetch in one round-trip (CategoryProductScopePolicy). + // Replaces the separate isProductInCategoryScope() bool-only lookup and yields + // the product instance for the document ACL check below (#473 pattern). + $product = $this->scopeService()->findInCategory($categoryId, $productId, $nested); + + if (!$product) { + $this->modx->lexicon->load('minishop3:default'); + + return Response::error( + $this->modx->lexicon('ms3_err_product_not_in_category_scope'), + HttpStatus::FORBIDDEN + )->getData(); + } + + $savePolicies = [CategoryProductDocumentPolicy::POLICY_SAVE]; + if (!CategoryProductDocumentPolicy::isAllowedAll($product, $savePolicies)) { + $this->logDocumentPolicyDenied($product, $savePolicies); + + return Response::error( + 'Save permission denied for this document', + HttpStatus::FORBIDDEN + )->getData(); + } + + /** @var \MiniShop3\Services\Product\ProductDataService|null $productDataService */ + $productDataService = $this->modx->services->get('ms3_product_data_service'); + if (!$productDataService) { + return Response::error('Product data service is not available', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); + } + + $result = $productDataService->updateProductData($productId, $data); + + if (!empty($result['ok']) && !empty($result['data'])) { + return Response::success($result['data'])->getData(); + } + + $code = $result['code'] ?? HttpStatus::INTERNAL_SERVER_ERROR; + $message = $result['message'] ?? 'Failed to save product data'; + + return Response::error($message, $code)->getData(); + } + /** * Toggle product publish status * POST /api/mgr/categories/{id}/products/{productId}/publish @@ -611,8 +681,11 @@ private function countVisibleListResults( return $visible; } - /** @param list $policies */ - private function logDocumentPolicyDenied(msProduct $product, array $policies): void + /** + * @param object $product msProduct or smoke-test stub exposing get('id') + * @param list $policies + */ + private function logDocumentPolicyDenied(object $product, array $policies): void { $this->modx->log( modX::LOG_LEVEL_WARN, diff --git a/core/components/minishop3/src/Services/Category/CategoryProductScopePolicy.php b/core/components/minishop3/src/Services/Category/CategoryProductScopePolicy.php index 5dcd1152..041322e4 100644 --- a/core/components/minishop3/src/Services/Category/CategoryProductScopePolicy.php +++ b/core/components/minishop3/src/Services/Category/CategoryProductScopePolicy.php @@ -22,13 +22,30 @@ public static function isParentInScope( return false; } + return in_array( + $productParentId, + self::allowedParentCategoryIds($categoryId, $nested, $descendantCategoryIds), + true + ); + } + + /** + * @param list $descendantCategoryIds Child category IDs (recursive, excluding root) + * + * @return list + */ + public static function allowedParentCategoryIds( + int $categoryId, + bool $nested, + array $descendantCategoryIds + ): array { if (!$nested) { - return $productParentId === $categoryId; + return [$categoryId]; } $allowed = $descendantCategoryIds; $allowed[] = $categoryId; - return in_array($productParentId, $allowed, true); + return $allowed; } } diff --git a/core/components/minishop3/src/Services/Category/CategoryProductsListService.php b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php index 806fe333..8808311c 100644 --- a/core/components/minishop3/src/Services/Category/CategoryProductsListService.php +++ b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php @@ -226,6 +226,38 @@ private function quoteOptionKeyForJoinCondition(string $key): string return str_replace("'", "''", $key); } + /** + * Parent category IDs allowed for products in category grid scope (matches list filter). + * + * @return list + */ + public function getAllowedProductParentCategoryIds(int $categoryId, bool $nested): array + { + return CategoryProductScopePolicy::allowedParentCategoryIds( + $categoryId, + $nested, + $nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : [] + ); + } + + /** + * Whether a product belongs to the category products grid scope (direct parent or nested tree). + */ + public function isProductInCategoryScope(int $productId, int $categoryId, bool $nested): bool + { + $product = $this->modx->getObject(msProduct::class, $productId); + if (!$product) { + return false; + } + + return CategoryProductScopePolicy::isParentInScope( + (int) $product->get('parent'), + $categoryId, + $nested, + $nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : [] + ); + } + /** * @param list $optionFieldNames Allowed option field names (whitelist) * diff --git a/core/components/minishop3/tests/CategoryProductScopePolicyTest.php b/core/components/minishop3/tests/CategoryProductScopePolicyTest.php index d39c7e47..4d31d008 100644 --- a/core/components/minishop3/tests/CategoryProductScopePolicyTest.php +++ b/core/components/minishop3/tests/CategoryProductScopePolicyTest.php @@ -1,7 +1,7 @@ 999, 'parent' => 2, 'published' => 0], ['id' => 100, 'parent' => 1, 'published' => 0], ['id' => 101, 'parent' => 2, 'published' => 0], + ['id' => 200, 'parent' => 1, 'published' => 0, 'policies' => ['save' => false]], ]; $modx->categories = [ ['id' => 2, 'parent' => 1], @@ -89,4 +90,28 @@ $assertSame(true, $sort['success'] ?? null, 'sort success'); $assertSame(1, $sort['data']['updated'] ?? null, 'sort updated count'); +// updateProductData: in-scope product with document save policy denied → 403 (#473 pattern) +$modx->getObjectCalls = []; +$aclDenied = $controller->updateProductData([ + 'id' => 1, + 'productId' => 200, + 'pagetitle' => 'updated title', +]); +$assertSame(false, $aclDenied['success'] ?? null, 'updateProductData ACL denied success'); +$assertSame(HttpStatus::FORBIDDEN, $aclDenied['code'] ?? null, 'updateProductData ACL denied code'); +$assertSame( + 'Save permission denied for this document', + $aclDenied['message'] ?? null, + 'updateProductData ACL denied message' +); + +// updateProductData: out-of-scope product → 403 (scope guard still enforced) +$outOfScope = $controller->updateProductData([ + 'id' => 1, + 'productId' => 999, + 'pagetitle' => 'updated title', +]); +$assertSame(false, $outOfScope['success'] ?? null, 'updateProductData out-of-scope success'); +$assertSame(HttpStatus::FORBIDDEN, $outOfScope['code'] ?? null, 'updateProductData out-of-scope code'); + fwrite(STDOUT, "OK: CategoryProductsControllerScopeTest\n"); diff --git a/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php b/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php index 80fb9651..5b3fc59d 100644 --- a/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php +++ b/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php @@ -13,7 +13,10 @@ */ class CategoryProductScopeModxStub extends modX { - /** @var list */ + /** @var object|null */ + public $lexicon; + + /** @var list}> */ public array $products = []; /** @var list */ @@ -25,6 +28,27 @@ class CategoryProductScopeModxStub extends modX public function __construct() { parent::__construct(); + $this->user = new class { + public function isAuthenticated(string $context): bool + { + return $context === 'mgr'; + } + + public function getUserToken(string $contextKey): string + { + return 'test-modauth-token'; + } + + public function get(string $key): mixed + { + return $key === 'id' ? 0 : null; + } + }; + $this->lexicon = new class { + public function load(string ...$topics): void + { + } + }; $this->services = new class { public function get(string $key): null { @@ -38,6 +62,14 @@ public function has(string $key): bool }; } + /** + * @param array $params + */ + public function lexicon(string $key, array $params = [], string $language = ''): string + { + return $key; + } + public function getObject($className = '', $criteria = null, $cacheFlag = true) { $this->getObjectCalls[] = ['class' => $className, 'criteria' => $criteria]; @@ -77,7 +109,7 @@ public function getObject($className = '', $criteria = null, $cacheFlag = true) foreach ($this->products as $row) { if ((int) $row['id'] === $productId && (int) $row['parent'] === $parentId) { - return new StubMsProduct($row); + return new StubMsProduct($row, $row['policies'] ?? null); } } @@ -87,7 +119,7 @@ public function getObject($className = '', $criteria = null, $cacheFlag = true) $productId = (int) $criteria; foreach ($this->products as $row) { if ((int) $row['id'] === $productId) { - return new StubMsProduct($row); + return new StubMsProduct($row, $row['policies'] ?? null); } } diff --git a/vueManager/src/composables/useCategoryProductsInlineEdit.js b/vueManager/src/composables/useCategoryProductsInlineEdit.js index 60828c4c..eac07fe4 100644 --- a/vueManager/src/composables/useCategoryProductsInlineEdit.js +++ b/vueManager/src/composables/useCategoryProductsInlineEdit.js @@ -26,10 +26,11 @@ import { export function useCategoryProductsInlineEdit(deps) { const { products, referencePathsByKey, categoryId, nested, request, toast, _ } = deps - const resolveScopeContext = () => ({ - category_id: typeof categoryId === 'object' && categoryId !== null ? categoryId.value : categoryId, - nested: Boolean(typeof nested === 'object' && nested !== null ? nested.value : nested), - }) + const resolveCategoryId = () => + typeof categoryId === 'object' && categoryId !== null ? categoryId.value : categoryId + + const resolveNested = () => + Boolean(typeof nested === 'object' && nested !== null ? nested.value : nested) const editingCell = ref(null) const inlineEditValue = ref('') @@ -211,10 +212,15 @@ export function useCategoryProductsInlineEdit(deps) { } inlineEditSaving.value = true try { - const res = await request.put(`/api/mgr/product-data/${product.id}`, { - [column.name]: value, - ...resolveScopeContext(), - }) + const scopedCategoryId = resolveCategoryId() + const payload = { [column.name]: value } + if (resolveNested()) { + payload.nested = 1 + } + const res = await request.put( + `/api/mgr/categories/${scopedCategoryId}/products/${product.id}/data`, + payload + ) const idx = products.value.findIndex(p => p.id === product.id) if (idx >= 0) { if (res && typeof res === 'object') { diff --git a/vueManager/src/composables/useCategoryProductsInlineEdit.test.js b/vueManager/src/composables/useCategoryProductsInlineEdit.test.js index 8c6c5603..40afc3aa 100644 --- a/vueManager/src/composables/useCategoryProductsInlineEdit.test.js +++ b/vueManager/src/composables/useCategoryProductsInlineEdit.test.js @@ -122,10 +122,8 @@ describe('useCategoryProductsInlineEdit', () => { api.inlineEditValue.value = '25' await api.saveInlineEdit(product, column) - expect(request.put).toHaveBeenCalledWith('/api/mgr/product-data/11', { + expect(request.put).toHaveBeenCalledWith('/api/mgr/categories/5/products/11/data', { price: 25, - category_id: 5, - nested: false, }) expect(products.value[0].price).toBe(25) expect(toast.add).toHaveBeenCalledWith(