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 +285 -43 1.5.1 → 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;
@@ -24,11 +25,13 @@
24 25 use FluentCart\App\Models\ShippingClass;
25 26 use FluentCart\App\Models\TaxClass;
26 27 use FluentCart\App\Modules\ReportingModule\ProductReport;
27 28 use FluentCart\App\Services\Async\DummyProductService;
29 +use FluentCart\App\Helpers\AttributeHelper;
28 30 use FluentCart\App\Services\BulkProductInsertService;
29 31 use FluentCart\App\Services\BulkProductUpdateService;
30 32 use FluentCart\App\Services\Filter\ProductFilter;
33 +use FluentCart\App\Services\Permission\PermissionManager;
31 34 use FluentCart\App\Services\PlanUpgradeService;
32 35 use FluentCart\Framework\Database\Orm\Builder;
33 36 use FluentCart\Framework\Http\Request\Request;
34 37 use FluentCart\Framework\Support\Arr;
@@ -43,11 +46,33 @@
43 46 {
44 47 //$request->set('with', ['detail', 'variants:post_id,available,manage_stock,stock_status,variation_title,other_info']);
45 48 $products = ProductFilter::fromRequest($request)->paginate();
46 49
50 + // Attach the resolved variation_display_title to each variation (batched,
51 + // no N+1) so the admin order product picker matches the order item display.
52 + AttributeHelper::attachVariationDisplayTitles($products->getCollection());
53 +
54 + $collection = $products->getCollection();
55 +
47 56 $products->setCollection(
48 - $products->getCollection()->transform(function ($product) {
49 - 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;
50 75 })
51 76 );
52 77
53 78 $products = apply_filters('fluent_cart/products_list', $products);
@@ -58,10 +83,12 @@
58 83 }
59 84
60 85 public function find(Request $request, Product $product): array
61 86 {
62 - if ($request->get('with')) {
63 - $product->load($request->get('with'));
87 + $with = $this->resolveEagerLoads($request->get('with', []));
88 +
89 + if ($with) {
90 + $product->load($with);
64 91 }
65 92 $data = [
66 93 'product' => $product,
67 94 ];
@@ -72,14 +99,178 @@
72 99
73 100 return $data;
74 101 }
75 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 +
76 267 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
77 268 {
78 269 $productId = absint($productId);
79 270
80 271 if (!$productId) {
81 - return $this->sendError('Invalid product ID');
272 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
82 273 }
83 274
84 275 $relatedBy = [];
85 276
@@ -284,9 +475,9 @@
284 475 } catch (\RuntimeException $e) {
285 476 if ((int)$e->getCode() === 404) {
286 477 return $this->sendError([
287 478 'message' => __('Product not found', 'fluent-cart')
288 - ]);
479 + ], 404);
289 480 }
290 481 return $this->sendError([
291 482 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
292 483 ]);
@@ -318,8 +509,15 @@
318 509 public function update(ProductUpdateRequest $request, $postId)
319 510 {
320 511 $data = $request->getSafe($request->sanitize());
321 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 +
322 520 if (
323 521 Arr::get($data, 'detail.variation_type') === 'simple' &&
324 522 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
325 523 ) {
@@ -336,9 +534,8 @@
336 534 // }
337 535
338 536 $isUpdated = ProductResource::update($data, $postId);
339 537
340 -
341 538 if (is_wp_error($isUpdated)) {
342 539 return $isUpdated;
343 540 }
344 541
@@ -349,8 +546,25 @@
349 546
350 547 return $this->response->sendSuccess($isUpdated);
351 548 }
352 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 +
353 567 public function updateLongDescEditorMode(Request $request, $postId)
354 568 {
355 569 // Validate input
356 570 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -843,19 +1057,24 @@
843 1057
844 1058 $termNames = explode(',', $name);
845 1059 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
846 1060
847 - if (count($ids)) {
848 - $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([
849 1069 'term_ids' => $ids,
850 1070 'names' => $termNames
851 1071 ]);
852 - } else {
853 - $this->response->json([
854 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
855 - ], 423);
856 1072 }
857 1073
1074 + return $this->response->sendError([
1075 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1076 + ], 423);
858 1077 }
859 1078
860 1079 public function getProductTermsList(): array
861 1080 {
@@ -965,39 +1184,47 @@
965 1184 $name = Arr::get($data, 'search', '');
966 1185 }
967 1186 $ids = Arr::get($data, 'ids', []);
968 1187 $productVariations = [];
969 - $query = [];
970 - if (!empty($name) || count($ids) > 0) {
971 - $query = [
972 - "ID" =>
973 - [
974 - "column" => "ID",
975 - "operator" => "in",
976 - "value" => Arr::get($data, 'ids', [])
977 - ]
978 - ,
979 - "post_title" =>
980 - [
981 - "column" => "post_title",
982 - "operator" => "like",
983 - "value" => '%' . Arr::get($data, 'name') . '%'
984 - ],
985 - "post_status" =>
986 - [
987 - "column" => "post_status",
988 - "operator" => "=",
989 - "value" => 'publish'
990 - ]
991 - ];
992 - }
993 1188
994 1189 $products = Product::query()
995 - ->with('variants')
996 - ->when(count($query), function (Builder $q) use ($query) {
997 - return $q->search($query, function (Builder $query) {
998 - return $query;
999 - }, 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 + }
1000 1227 })
1001 1228 ->when(empty($name), function (Builder $q) {
1002 1229 return $q->limit(10);
1003 1230 })
@@ -1194,8 +1421,9 @@
1194 1421
1195 1422 public function fetchVariationsByIds(Request $request): array
1196 1423 {
1197 1424 $ids = $request->getSafe(['productIds.*' => 'intval']);
1425 + $ids = Arr::get($ids, 'productIds', []);
1198 1426 $ids = is_array($ids) ? $ids : [];
1199 1427 if (empty($ids)) {
1200 1428 return ['products' => []];
1201 1429 }
@@ -1330,8 +1558,13 @@
1330 1558 }
1331 1559
1332 1560 public function updateInventory(Request $request, $postId, $variantId)
1333 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 + }
1334 1567
1335 1568 $variant = ProductVariation::query()->find($variantId);
1336 1569
1337 1570 if (!$variant) {
@@ -1336,9 +1569,9 @@
1336 1569
1337 1570 if (!$variant) {
1338 1571 return $this->response->sendError([
1339 1572 'message' => __('Variant not found', 'fluent-cart')
1340 - ]);
1573 + ], 404);
1341 1574 }
1342 1575
1343 1576 // Capture old stock state before update
1344 1577 $oldAvailable = intval($variant->available);
@@ -1396,8 +1629,17 @@
1396 1629
1397 1630 public function updateManageStock(Request $request, $postId)
1398 1631 {
1399 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 + }
1400 1642
1401 1643 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1402 1644
1403 1645 $updateData = [