PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
← All changes | app/Http/Controllers/ProductController.php +260 -41 1.5.2 → 1.6.5 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Http\Controllers;
4 4
5 +use FluentCart\Api\ModuleSettings;
5 6 use FluentCart\Api\Resource\ProductDetailResource;
6 7 use FluentCart\Api\Resource\ProductResource;
7 8 use FluentCart\Api\Resource\ProductVariationResource;
8 9 use FluentCart\App\Events\StockChanged;
@@ -28,8 +29,9 @@
28 29 use FluentCart\App\Helpers\AttributeHelper;
29 30 use FluentCart\App\Services\BulkProductInsertService;
30 31 use FluentCart\App\Services\BulkProductUpdateService;
31 32 use FluentCart\App\Services\Filter\ProductFilter;
33 +use FluentCart\App\Services\Permission\PermissionManager;
32 34 use FluentCart\App\Services\PlanUpgradeService;
33 35 use FluentCart\Framework\Database\Orm\Builder;
34 36 use FluentCart\Framework\Http\Request\Request;
35 37 use FluentCart\Framework\Support\Arr;
@@ -63,10 +65,12 @@
63 65 }
64 66
65 67 public function find(Request $request, Product $product): array
66 68 {
67 - if ($request->get('with')) {
68 - $product->load($request->get('with'));
69 + $with = $this->resolveEagerLoads($request->get('with', []));
70 +
71 + if ($with) {
72 + $product->load($with);
69 73 }
70 74 $data = [
71 75 'product' => $product,
72 76 ];
@@ -77,14 +81,178 @@
77 81
78 82 return $data;
79 83 }
80 84
85 + /**
86 + * What the `with` parameter on `GET products/{id}` may eager-load.
87 + *
88 + * ## The entry form
89 + *
90 + * Every entry is a LITERAL request key mapped to a CALLABLE. The key is never
91 + * decomposed, prefix-matched or suffix-stripped, so what the client sends is
92 + * either a key in this map or it is dropped. That is what keeps the dotted
93 + * `orderItems.order.customer`, the column-select
94 + * `orderItems.order.customer:id,email` and the nested array
95 + * `with[orderItems][]=order.customer` out — none of them is a key.
96 + *
97 + * The callback owns the whole path AND its own permission bar, and returns the
98 + * relation paths to eager-load, or an empty array when it refuses.
99 + *
100 + * ## Two tiers of key
101 + *
102 + * A SCREEN key names a calling screen and loads exactly what that screen
103 + * renders. A PUBLIC key is a plain relation name an external consumer of a
104 + * product endpoint can reasonably ask for.
105 + *
106 + * ## What stays off the map
107 + *
108 + * `Product::orderItems()` is a real relation keyed on `post_id`, so before the
109 + * request value was constrained an actor holding nothing but products/view
110 + * could walk `?with[]=orderItems.order.customer` from a catalogue product to
111 + * order and customer data — a probe pulled 104 KB of it off one product. It
112 + * must stay unreachable, along with `downloadable_files` (protected file
113 + * paths), `licensesMeta`, `postmeta` and `wpTerms`.
114 + *
115 + * `product_menu` is NOT on this map and does not belong on it: it is a
116 + * controller sentinel, not a relation. find() reads it straight off the raw
117 + * request and answers it with AdminHelper::getProductMenu(); it never reaches
118 + * load(), so it is unaffected by anything here.
119 + *
120 + * No entry declares a select. `Product::$appends` carries `thumbnail`, which
121 + * resolves through `detail->featured_media` — itself a ProductDetail append
122 + * backed by the `galleryImage` relation — and `ProductVariation::$appends`
123 + * lazy-loads `media` keyed on the variant `id`. A select would have to go
124 + * INSIDE the relation closure in any case; on the main query it would narrow
125 + * the product row itself.
126 + *
127 + * @return array<string, callable>
128 + */
129 + private function allowedWiths(): array
130 + {
131 + return [
132 + 'block_product_detail' => [$this, 'blockProductDetail'],
133 +
134 + // The public entry points. These are the two relations an external
135 + // consumer can reasonably ask a product endpoint for: the catalogue
136 + // detail row and the variation rows. Both are catalogue data already
137 + // covered by the route's own products/view, and neither chains toward
138 + // orders, customers or protected downloads, so they carry no risk the
139 + // route does not already carry.
140 + //
141 + // The screen key above exists because the block editors want both in
142 + // one request; these two give either one on its own to a consumer
143 + // that is not that screen.
144 + 'detail' => [$this, 'publicDetail'],
145 + 'variants' => [$this, 'publicVariants'],
146 + ];
147 + }
148 +
149 + /**
150 + * The Gutenberg block editors' single-product fetch. Fourteen block editors
151 + * under `resources/admin/BlockEditor/` hit this endpoint — BuySection,
152 + * Excerpt, MediaCarousel, PriceRange, ProductCard, ProductDescription,
153 + * ProductGallery, ProductImage, ProductInfo, ProductSku, ProductTitle,
154 + * RelatedProduct, SaleBadge and Stock — and between them they render the
155 + * detail row (price range, stock availability, gallery) and the variation
156 + * rows (SKU, per-variant price, buy section), so the key loads both.
157 + *
158 + * `products/view` is the route's own bar, restated here so the entry still
159 + * refuses if this map is ever reached from somewhere the route did not guard.
160 + *
161 + * @return array relation paths
162 + */
163 + private function blockProductDetail(): array
164 + {
165 + if (!PermissionManager::hasPermission('products/view')) {
166 + return [];
167 + }
168 +
169 + return ['detail', 'variants'];
170 + }
171 +
172 + /**
173 + * The catalogue detail row on its own — price range, stock availability,
174 + * variation type, featured media.
175 + *
176 + * @return array relation paths
177 + */
178 + private function publicDetail(): array
179 + {
180 + if (!PermissionManager::hasPermission('products/view')) {
181 + return [];
182 + }
183 +
184 + return ['detail'];
185 + }
186 +
187 + /**
188 + * The variation rows on their own — SKU, per-variant price, stock.
189 + *
190 + * @return array relation paths
191 + */
192 + private function publicVariants(): array
193 + {
194 + if (!PermissionManager::hasPermission('products/view')) {
195 + return [];
196 + }
197 +
198 + return ['variants'];
199 + }
200 +
201 + /**
202 + * Reduce a client-supplied `with` payload to the relation paths this endpoint
203 + * is allowed to eager-load.
204 + *
205 + * Anything that is not a literal key of allowedWiths() is dropped SILENTLY —
206 + * an unknown relation otherwise reaches Builder::getRelation() and becomes a
207 + * RelationNotFoundException, i.e. a 500, where a stale block build should
208 + * simply render without its data.
209 + *
210 + * Only STRING request entries are considered, which is what drops the nested
211 + * array shape `with[orderItems][]=order.customer`: its value is an array and
212 + * its key is never read.
213 + *
214 + * Kept local to this controller rather than folded into
215 + * `Services/Filter/BaseFilter::allowedWiths()`: that map adopts a Builder
216 + * returned by each callback, while this endpoint eager-loads onto a
217 + * route-model-bound instance, and the two maps share no entry.
218 + *
219 + * @param mixed $with raw request value
220 + * @return array relation names safe to pass to Product::load()
221 + */
222 + private function resolveEagerLoads($with): array
223 + {
224 + $map = $this->allowedWiths();
225 +
226 + $resolved = [];
227 +
228 + foreach (Arr::wrap($with) as $requestKey) {
229 + if (!is_string($requestKey) || !array_key_exists($requestKey, $map)) {
230 + continue;
231 + }
232 +
233 + $entry = $map[$requestKey];
234 +
235 + if (!is_callable($entry)) {
236 + continue;
237 + }
238 +
239 + foreach ((array) $entry() as $relation) {
240 + if (is_string($relation) && $relation !== '') {
241 + $resolved[$relation] = true;
242 + }
243 + }
244 + }
245 +
246 + return array_keys($resolved);
247 + }
248 +
81 249 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
82 250 {
83 251 $productId = absint($productId);
84 252
85 253 if (!$productId) {
86 - return $this->sendError('Invalid product ID');
254 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
87 255 }
88 256
89 257 $relatedBy = [];
90 258
@@ -289,9 +457,9 @@
289 457 } catch (\RuntimeException $e) {
290 458 if ((int)$e->getCode() === 404) {
291 459 return $this->sendError([
292 460 'message' => __('Product not found', 'fluent-cart')
293 - ]);
461 + ], 404);
294 462 }
295 463 return $this->sendError([
296 464 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
297 465 ]);
@@ -323,8 +491,15 @@
323 491 public function update(ProductUpdateRequest $request, $postId)
324 492 {
325 493 $data = $request->getSafe($request->sanitize());
326 494
495 + $isPartialUpdate = !(isset($data['detail']) && is_array($data['detail'])) &&
496 + !(isset($data['variants']) && is_array($data['variants']));
497 +
498 + if ($isPartialUpdate) {
499 + return $this->applyPartialPostUpdate($data, $postId);
500 + }
501 +
327 502 if (
328 503 Arr::get($data, 'detail.variation_type') === 'simple' &&
329 504 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
330 505 ) {
@@ -341,9 +516,8 @@
341 516 // }
342 517
343 518 $isUpdated = ProductResource::update($data, $postId);
344 519
345 -
346 520 if (is_wp_error($isUpdated)) {
347 521 return $isUpdated;
348 522 }
349 523
@@ -354,8 +528,25 @@
354 528
355 529 return $this->response->sendSuccess($isUpdated);
356 530 }
357 531
532 + private function applyPartialPostUpdate(array $data, $postId)
533 + {
534 + $result = ProductResource::partialUpdate($data, $postId);
535 +
536 + if (is_wp_error($result)) {
537 + $statusCode = $result->get_error_code() === 'not_found' ? 404 : 422;
538 + return $this->sendError(['message' => $result->get_error_message()], $statusCode);
539 + }
540 +
541 + do_action('fluent_cart/product_updated', [
542 + 'data' => $data,
543 + 'product' => $result['data'],
544 + ]);
545 +
546 + return $this->response->sendSuccess($result);
547 + }
548 +
358 549 public function updateLongDescEditorMode(Request $request, $postId)
359 550 {
360 551 // Validate input
361 552 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -848,19 +1039,24 @@
848 1039
849 1040 $termNames = explode(',', $name);
850 1041 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
851 1042
852 - if (count($ids)) {
853 - $this->response->json([
1043 + // response->json() delegates to wp_send_json(), which prints and exits —
1044 + // bypassing the REST server (and killing in-process dispatch). send()
1045 + // returns the identical JSON body and status through WP_REST_Response.
1046 + // addTaxonomyTerms returns false (not an array) for a taxonomy outside
1047 + // the registered catalog, e.g. the unshipped product-tags — that must
1048 + // fall into the 423 branch, not raise a count-on-bool warning.
1049 + if (is_array($ids) && count($ids)) {
1050 + return $this->response->send([
854 1051 'term_ids' => $ids,
855 1052 'names' => $termNames
856 1053 ]);
857 - } else {
858 - $this->response->json([
859 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
860 - ], 423);
861 1054 }
862 1055
1056 + return $this->response->sendError([
1057 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1058 + ], 423);
863 1059 }
864 1060
865 1061 public function getProductTermsList(): array
866 1062 {
@@ -970,39 +1166,47 @@
970 1166 $name = Arr::get($data, 'search', '');
971 1167 }
972 1168 $ids = Arr::get($data, 'ids', []);
973 1169 $productVariations = [];
974 - $query = [];
975 - if (!empty($name) || count($ids) > 0) {
976 - $query = [
977 - "ID" =>
978 - [
979 - "column" => "ID",
980 - "operator" => "in",
981 - "value" => Arr::get($data, 'ids', [])
982 - ]
983 - ,
984 - "post_title" =>
985 - [
986 - "column" => "post_title",
987 - "operator" => "like",
988 - "value" => '%' . Arr::get($data, 'name') . '%'
989 - ],
990 - "post_status" =>
991 - [
992 - "column" => "post_status",
993 - "operator" => "=",
994 - "value" => 'publish'
995 - ]
996 - ];
997 - }
998 1170
999 1171 $products = Product::query()
1000 - ->with('variants')
1001 - ->when(count($query), function (Builder $q) use ($query) {
1002 - return $q->search($query, function (Builder $query) {
1003 - return $query;
1004 - }, true);
1172 + ->with(['variants' => function ($variantQuery) use ($name) {
1173 + if (!empty($name)) {
1174 + // Emit only variants the term actually hit: the variant's own
1175 + // title, or every variant of a product whose title matched.
1176 + // Without this, a product matched through one variant leaked
1177 + // all its non-matching siblings into the picker.
1178 + $variantQuery->where(function ($vq) use ($name) {
1179 + $vq->where('variation_title', 'like', '%' . $name . '%')
1180 + ->orWhereHas('product', function ($pq) use ($name) {
1181 + $pq->where('post_title', 'like', '%' . $name . '%');
1182 + });
1183 + });
1184 + }
1185 + // The relation query is shared across all matched parents, so this
1186 + // caps total child rows serialized per request for this
1187 + // remote-search picker.
1188 + $variantQuery->orderBy('id')->limit(100);
1189 + }])
1190 + ->when(!empty($name) || count($ids) > 0, function (Builder $q) use ($name, $ids) {
1191 + $q->where('post_status', 'publish');
1192 +
1193 + if (count($ids) > 0) {
1194 + $q->whereIn('ID', $ids);
1195 + }
1196 +
1197 + if (!empty($name)) {
1198 + // The endpoint's name is searchVariantByName: a term must match
1199 + // the product title OR any of its variants' variation_title.
1200 + // The previous search-helper query matched post_title only, so
1201 + // typing a variant's own title returned nothing.
1202 + $q->where(function (Builder $titleQuery) use ($name) {
1203 + $titleQuery->where('post_title', 'like', '%' . $name . '%')
1204 + ->orWhereHas('variants', function ($variantQuery) use ($name) {
1205 + $variantQuery->where('variation_title', 'like', '%' . $name . '%');
1206 + });
1207 + });
1208 + }
1005 1209 })
1006 1210 ->when(empty($name), function (Builder $q) {
1007 1211 return $q->limit(10);
1008 1212 })
@@ -1199,8 +1403,9 @@
1199 1403
1200 1404 public function fetchVariationsByIds(Request $request): array
1201 1405 {
1202 1406 $ids = $request->getSafe(['productIds.*' => 'intval']);
1407 + $ids = Arr::get($ids, 'productIds', []);
1203 1408 $ids = is_array($ids) ? $ids : [];
1204 1409 if (empty($ids)) {
1205 1410 return ['products' => []];
1206 1411 }
@@ -1335,8 +1540,13 @@
1335 1540 }
1336 1541
1337 1542 public function updateInventory(Request $request, $postId, $variantId)
1338 1543 {
1544 + if (!ModuleSettings::isActive('stock_management')) {
1545 + return $this->response->sendError([
1546 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1547 + ], 422);
1548 + }
1339 1549
1340 1550 $variant = ProductVariation::query()->find($variantId);
1341 1551
1342 1552 if (!$variant) {
@@ -1341,9 +1551,9 @@
1341 1551
1342 1552 if (!$variant) {
1343 1553 return $this->response->sendError([
1344 1554 'message' => __('Variant not found', 'fluent-cart')
1345 - ]);
1555 + ], 404);
1346 1556 }
1347 1557
1348 1558 // Capture old stock state before update
1349 1559 $oldAvailable = intval($variant->available);
@@ -1401,8 +1611,17 @@
1401 1611
1402 1612 public function updateManageStock(Request $request, $postId)
1403 1613 {
1404 1614 $manageStock = sanitize_text_field($request->get('manage_stock'));
1615 +
1616 + // Turning inventory ON requires the Stock Management module to be active.
1617 + // Turning it OFF stays allowed so a store that disables the module can
1618 + // still clean up products that were left with manage_stock = 1.
1619 + if ($manageStock == 1 && !ModuleSettings::isActive('stock_management')) {
1620 + return $this->response->sendError([
1621 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1622 + ], 422);
1623 + }
1405 1624
1406 1625 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1407 1626
1408 1627 $updateData = [