PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 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 All 50 releases
← All changes | app/Http/Controllers/ProductController.php +280 -43 1.5.2 → 1.7.0 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;
@@ -48,11 +50,29 @@
48 50 // Attach the resolved variation_display_title to each variation (batched,
49 51 // no N+1) so the admin order product picker matches the order item display.
50 52 AttributeHelper::attachVariationDisplayTitles($products->getCollection());
51 53
54 + $collection = $products->getCollection();
55 +
52 56 $products->setCollection(
53 - $products->getCollection()->transform(function ($product) {
54 - return $product->setAppends(['view_url', 'edit_url']);
57 + $collection->transform(function ($product) {
58 + $product->setAppends(['view_url', 'edit_url']);
59 +
60 + // Source rating from canonical detail.other_info (maintained by recalculateProductRatings).
61 + // Use array_key_exists to avoid overwriting valid data with fallback-to-zero.
62 + if ($product->detail) {
63 + $otherInfo = $product->detail->other_info ?? [];
64 +
65 + if (array_key_exists('average_rating', $otherInfo)) {
66 + $product->avg_rating = (float) $otherInfo['average_rating'];
67 + }
68 +
69 + if (array_key_exists('review_count', $otherInfo)) {
70 + $product->reviews_count = (int) $otherInfo['review_count'];
71 + }
72 + }
73 +
74 + return $product;
55 75 })
56 76 );
57 77
58 78 $products = apply_filters('fluent_cart/products_list', $products);
@@ -63,10 +83,12 @@
63 83 }
64 84
65 85 public function find(Request $request, Product $product): array
66 86 {
67 - if ($request->get('with')) {
68 - $product->load($request->get('with'));
87 + $with = $this->resolveEagerLoads($request->get('with', []));
88 +
89 + if ($with) {
90 + $product->load($with);
69 91 }
70 92 $data = [
71 93 'product' => $product,
72 94 ];
@@ -77,14 +99,178 @@
77 99
78 100 return $data;
79 101 }
80 102
103 + /**
104 + * What the `with` parameter on `GET products/{id}` may eager-load.
105 + *
106 + * ## The entry form
107 + *
108 + * Every entry is a LITERAL request key mapped to a CALLABLE. The key is never
109 + * decomposed, prefix-matched or suffix-stripped, so what the client sends is
110 + * either a key in this map or it is dropped. That is what keeps the dotted
111 + * `orderItems.order.customer`, the column-select
112 + * `orderItems.order.customer:id,email` and the nested array
113 + * `with[orderItems][]=order.customer` out — none of them is a key.
114 + *
115 + * The callback owns the whole path AND its own permission bar, and returns the
116 + * relation paths to eager-load, or an empty array when it refuses.
117 + *
118 + * ## Two tiers of key
119 + *
120 + * A SCREEN key names a calling screen and loads exactly what that screen
121 + * renders. A PUBLIC key is a plain relation name an external consumer of a
122 + * product endpoint can reasonably ask for.
123 + *
124 + * ## What stays off the map
125 + *
126 + * `Product::orderItems()` is a real relation keyed on `post_id`, so before the
127 + * request value was constrained an actor holding nothing but products/view
128 + * could walk `?with[]=orderItems.order.customer` from a catalogue product to
129 + * order and customer data — a probe pulled 104 KB of it off one product. It
130 + * must stay unreachable, along with `downloadable_files` (protected file
131 + * paths), `licensesMeta`, `postmeta` and `wpTerms`.
132 + *
133 + * `product_menu` is NOT on this map and does not belong on it: it is a
134 + * controller sentinel, not a relation. find() reads it straight off the raw
135 + * request and answers it with AdminHelper::getProductMenu(); it never reaches
136 + * load(), so it is unaffected by anything here.
137 + *
138 + * No entry declares a select. `Product::$appends` carries `thumbnail`, which
139 + * resolves through `detail->featured_media` — itself a ProductDetail append
140 + * backed by the `galleryImage` relation — and `ProductVariation::$appends`
141 + * lazy-loads `media` keyed on the variant `id`. A select would have to go
142 + * INSIDE the relation closure in any case; on the main query it would narrow
143 + * the product row itself.
144 + *
145 + * @return array<string, callable>
146 + */
147 + private function allowedWiths(): array
148 + {
149 + return [
150 + 'block_product_detail' => [$this, 'blockProductDetail'],
151 +
152 + // The public entry points. These are the two relations an external
153 + // consumer can reasonably ask a product endpoint for: the catalogue
154 + // detail row and the variation rows. Both are catalogue data already
155 + // covered by the route's own products/view, and neither chains toward
156 + // orders, customers or protected downloads, so they carry no risk the
157 + // route does not already carry.
158 + //
159 + // The screen key above exists because the block editors want both in
160 + // one request; these two give either one on its own to a consumer
161 + // that is not that screen.
162 + 'detail' => [$this, 'publicDetail'],
163 + 'variants' => [$this, 'publicVariants'],
164 + ];
165 + }
166 +
167 + /**
168 + * The Gutenberg block editors' single-product fetch. Fourteen block editors
169 + * under `resources/admin/BlockEditor/` hit this endpoint — BuySection,
170 + * Excerpt, MediaCarousel, PriceRange, ProductCard, ProductDescription,
171 + * ProductGallery, ProductImage, ProductInfo, ProductSku, ProductTitle,
172 + * RelatedProduct, SaleBadge and Stock — and between them they render the
173 + * detail row (price range, stock availability, gallery) and the variation
174 + * rows (SKU, per-variant price, buy section), so the key loads both.
175 + *
176 + * `products/view` is the route's own bar, restated here so the entry still
177 + * refuses if this map is ever reached from somewhere the route did not guard.
178 + *
179 + * @return array relation paths
180 + */
181 + private function blockProductDetail(): array
182 + {
183 + if (!PermissionManager::hasPermission('products/view')) {
184 + return [];
185 + }
186 +
187 + return ['detail', 'variants'];
188 + }
189 +
190 + /**
191 + * The catalogue detail row on its own — price range, stock availability,
192 + * variation type, featured media.
193 + *
194 + * @return array relation paths
195 + */
196 + private function publicDetail(): array
197 + {
198 + if (!PermissionManager::hasPermission('products/view')) {
199 + return [];
200 + }
201 +
202 + return ['detail'];
203 + }
204 +
205 + /**
206 + * The variation rows on their own — SKU, per-variant price, stock.
207 + *
208 + * @return array relation paths
209 + */
210 + private function publicVariants(): array
211 + {
212 + if (!PermissionManager::hasPermission('products/view')) {
213 + return [];
214 + }
215 +
216 + return ['variants'];
217 + }
218 +
219 + /**
220 + * Reduce a client-supplied `with` payload to the relation paths this endpoint
221 + * is allowed to eager-load.
222 + *
223 + * Anything that is not a literal key of allowedWiths() is dropped SILENTLY —
224 + * an unknown relation otherwise reaches Builder::getRelation() and becomes a
225 + * RelationNotFoundException, i.e. a 500, where a stale block build should
226 + * simply render without its data.
227 + *
228 + * Only STRING request entries are considered, which is what drops the nested
229 + * array shape `with[orderItems][]=order.customer`: its value is an array and
230 + * its key is never read.
231 + *
232 + * Kept local to this controller rather than folded into
233 + * `Services/Filter/BaseFilter::allowedWiths()`: that map adopts a Builder
234 + * returned by each callback, while this endpoint eager-loads onto a
235 + * route-model-bound instance, and the two maps share no entry.
236 + *
237 + * @param mixed $with raw request value
238 + * @return array relation names safe to pass to Product::load()
239 + */
240 + private function resolveEagerLoads($with): array
241 + {
242 + $map = $this->allowedWiths();
243 +
244 + $resolved = [];
245 +
246 + foreach (Arr::wrap($with) as $requestKey) {
247 + if (!is_string($requestKey) || !array_key_exists($requestKey, $map)) {
248 + continue;
249 + }
250 +
251 + $entry = $map[$requestKey];
252 +
253 + if (!is_callable($entry)) {
254 + continue;
255 + }
256 +
257 + foreach ((array) $entry() as $relation) {
258 + if (is_string($relation) && $relation !== '') {
259 + $resolved[$relation] = true;
260 + }
261 + }
262 + }
263 +
264 + return array_keys($resolved);
265 + }
266 +
81 267 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
82 268 {
83 269 $productId = absint($productId);
84 270
85 271 if (!$productId) {
86 - return $this->sendError('Invalid product ID');
272 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
87 273 }
88 274
89 275 $relatedBy = [];
90 276
@@ -289,9 +475,9 @@
289 475 } catch (\RuntimeException $e) {
290 476 if ((int)$e->getCode() === 404) {
291 477 return $this->sendError([
292 478 'message' => __('Product not found', 'fluent-cart')
293 - ]);
479 + ], 404);
294 480 }
295 481 return $this->sendError([
296 482 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
297 483 ]);
@@ -323,8 +509,15 @@
323 509 public function update(ProductUpdateRequest $request, $postId)
324 510 {
325 511 $data = $request->getSafe($request->sanitize());
326 512
513 + $isPartialUpdate = !(isset($data['detail']) && is_array($data['detail'])) &&
514 + !(isset($data['variants']) && is_array($data['variants']));
515 +
516 + if ($isPartialUpdate) {
517 + return $this->applyPartialPostUpdate($data, $postId);
518 + }
519 +
327 520 if (
328 521 Arr::get($data, 'detail.variation_type') === 'simple' &&
329 522 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
330 523 ) {
@@ -341,9 +534,8 @@
341 534 // }
342 535
343 536 $isUpdated = ProductResource::update($data, $postId);
344 537
345 -
346 538 if (is_wp_error($isUpdated)) {
347 539 return $isUpdated;
348 540 }
349 541
@@ -354,8 +546,25 @@
354 546
355 547 return $this->response->sendSuccess($isUpdated);
356 548 }
357 549
550 + private function applyPartialPostUpdate(array $data, $postId)
551 + {
552 + $result = ProductResource::partialUpdate($data, $postId);
553 +
554 + if (is_wp_error($result)) {
555 + $statusCode = $result->get_error_code() === 'not_found' ? 404 : 422;
556 + return $this->sendError(['message' => $result->get_error_message()], $statusCode);
557 + }
558 +
559 + do_action('fluent_cart/product_updated', [
560 + 'data' => $data,
561 + 'product' => $result['data'],
562 + ]);
563 +
564 + return $this->response->sendSuccess($result);
565 + }
566 +
358 567 public function updateLongDescEditorMode(Request $request, $postId)
359 568 {
360 569 // Validate input
361 570 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -848,19 +1057,24 @@
848 1057
849 1058 $termNames = explode(',', $name);
850 1059 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
851 1060
852 - if (count($ids)) {
853 - $this->response->json([
1061 + // response->json() delegates to wp_send_json(), which prints and exits —
1062 + // bypassing the REST server (and killing in-process dispatch). send()
1063 + // returns the identical JSON body and status through WP_REST_Response.
1064 + // addTaxonomyTerms returns false (not an array) for a taxonomy outside
1065 + // the registered catalog, e.g. the unshipped product-tags — that must
1066 + // fall into the 423 branch, not raise a count-on-bool warning.
1067 + if (is_array($ids) && count($ids)) {
1068 + return $this->response->send([
854 1069 'term_ids' => $ids,
855 1070 'names' => $termNames
856 1071 ]);
857 - } else {
858 - $this->response->json([
859 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
860 - ], 423);
861 1072 }
862 1073
1074 + return $this->response->sendError([
1075 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1076 + ], 423);
863 1077 }
864 1078
865 1079 public function getProductTermsList(): array
866 1080 {
@@ -970,39 +1184,47 @@
970 1184 $name = Arr::get($data, 'search', '');
971 1185 }
972 1186 $ids = Arr::get($data, 'ids', []);
973 1187 $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 1188
999 1189 $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);
1190 + ->with(['variants' => function ($variantQuery) use ($name) {
1191 + if (!empty($name)) {
1192 + // Emit only variants the term actually hit: the variant's own
1193 + // title, or every variant of a product whose title matched.
1194 + // Without this, a product matched through one variant leaked
1195 + // all its non-matching siblings into the picker.
1196 + $variantQuery->where(function ($vq) use ($name) {
1197 + $vq->where('variation_title', 'like', '%' . $name . '%')
1198 + ->orWhereHas('product', function ($pq) use ($name) {
1199 + $pq->where('post_title', 'like', '%' . $name . '%');
1200 + });
1201 + });
1202 + }
1203 + // The relation query is shared across all matched parents, so this
1204 + // caps total child rows serialized per request for this
1205 + // remote-search picker.
1206 + $variantQuery->orderBy('id')->limit(100);
1207 + }])
1208 + ->when(!empty($name) || count($ids) > 0, function (Builder $q) use ($name, $ids) {
1209 + $q->where('post_status', 'publish');
1210 +
1211 + if (count($ids) > 0) {
1212 + $q->whereIn('ID', $ids);
1213 + }
1214 +
1215 + if (!empty($name)) {
1216 + // The endpoint's name is searchVariantByName: a term must match
1217 + // the product title OR any of its variants' variation_title.
1218 + // The previous search-helper query matched post_title only, so
1219 + // typing a variant's own title returned nothing.
1220 + $q->where(function (Builder $titleQuery) use ($name) {
1221 + $titleQuery->where('post_title', 'like', '%' . $name . '%')
1222 + ->orWhereHas('variants', function ($variantQuery) use ($name) {
1223 + $variantQuery->where('variation_title', 'like', '%' . $name . '%');
1224 + });
1225 + });
1226 + }
1005 1227 })
1006 1228 ->when(empty($name), function (Builder $q) {
1007 1229 return $q->limit(10);
1008 1230 })
@@ -1199,8 +1421,9 @@
1199 1421
1200 1422 public function fetchVariationsByIds(Request $request): array
1201 1423 {
1202 1424 $ids = $request->getSafe(['productIds.*' => 'intval']);
1425 + $ids = Arr::get($ids, 'productIds', []);
1203 1426 $ids = is_array($ids) ? $ids : [];
1204 1427 if (empty($ids)) {
1205 1428 return ['products' => []];
1206 1429 }
@@ -1335,8 +1558,13 @@
1335 1558 }
1336 1559
1337 1560 public function updateInventory(Request $request, $postId, $variantId)
1338 1561 {
1562 + if (!ModuleSettings::isActive('stock_management')) {
1563 + return $this->response->sendError([
1564 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1565 + ], 422);
1566 + }
1339 1567
1340 1568 $variant = ProductVariation::query()->find($variantId);
1341 1569
1342 1570 if (!$variant) {
@@ -1341,9 +1569,9 @@
1341 1569
1342 1570 if (!$variant) {
1343 1571 return $this->response->sendError([
1344 1572 'message' => __('Variant not found', 'fluent-cart')
1345 - ]);
1573 + ], 404);
1346 1574 }
1347 1575
1348 1576 // Capture old stock state before update
1349 1577 $oldAvailable = intval($variant->available);
@@ -1401,8 +1629,17 @@
1401 1629
1402 1630 public function updateManageStock(Request $request, $postId)
1403 1631 {
1404 1632 $manageStock = sanitize_text_field($request->get('manage_stock'));
1633 +
1634 + // Turning inventory ON requires the Stock Management module to be active.
1635 + // Turning it OFF stays allowed so a store that disables the module can
1636 + // still clean up products that were left with manage_stock = 1.
1637 + if ($manageStock == 1 && !ModuleSettings::isActive('stock_management')) {
1638 + return $this->response->sendError([
1639 + 'message' => __('Stock Management module is disabled. Enable it from Settings to manage inventory.', 'fluent-cart')
1640 + ], 422);
1641 + }
1405 1642
1406 1643 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1407 1644
1408 1645 $updateData = [