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 +265 -41 1.5.0 → 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;
@@ -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,8 +46,12 @@
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 +
47 54 $products->setCollection(
48 55 $products->getCollection()->transform(function ($product) {
49 56 return $product->setAppends(['view_url', 'edit_url']);
50 57 })
@@ -58,10 +65,12 @@
58 65 }
59 66
60 67 public function find(Request $request, Product $product): array
61 68 {
62 - if ($request->get('with')) {
63 - $product->load($request->get('with'));
69 + $with = $this->resolveEagerLoads($request->get('with', []));
70 +
71 + if ($with) {
72 + $product->load($with);
64 73 }
65 74 $data = [
66 75 'product' => $product,
67 76 ];
@@ -72,14 +81,178 @@
72 81
73 82 return $data;
74 83 }
75 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 +
76 249 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
77 250 {
78 251 $productId = absint($productId);
79 252
80 253 if (!$productId) {
81 - return $this->sendError('Invalid product ID');
254 + return $this->sendError(__('Invalid product ID', 'fluent-cart'));
82 255 }
83 256
84 257 $relatedBy = [];
85 258
@@ -284,9 +457,9 @@
284 457 } catch (\RuntimeException $e) {
285 458 if ((int)$e->getCode() === 404) {
286 459 return $this->sendError([
287 460 'message' => __('Product not found', 'fluent-cart')
288 - ]);
461 + ], 404);
289 462 }
290 463 return $this->sendError([
291 464 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
292 465 ]);
@@ -318,8 +491,15 @@
318 491 public function update(ProductUpdateRequest $request, $postId)
319 492 {
320 493 $data = $request->getSafe($request->sanitize());
321 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 +
322 502 if (
323 503 Arr::get($data, 'detail.variation_type') === 'simple' &&
324 504 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
325 505 ) {
@@ -336,9 +516,8 @@
336 516 // }
337 517
338 518 $isUpdated = ProductResource::update($data, $postId);
339 519
340 -
341 520 if (is_wp_error($isUpdated)) {
342 521 return $isUpdated;
343 522 }
344 523
@@ -349,8 +528,25 @@
349 528
350 529 return $this->response->sendSuccess($isUpdated);
351 530 }
352 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 +
353 549 public function updateLongDescEditorMode(Request $request, $postId)
354 550 {
355 551 // Validate input
356 552 $activeEditor = sanitize_text_field($request->get('active_editor'));
@@ -843,19 +1039,24 @@
843 1039
844 1040 $termNames = explode(',', $name);
845 1041 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
846 1042
847 - if (count($ids)) {
848 - $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([
849 1051 'term_ids' => $ids,
850 1052 'names' => $termNames
851 1053 ]);
852 - } else {
853 - $this->response->json([
854 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
855 - ], 423);
856 1054 }
857 1055
1056 + return $this->response->sendError([
1057 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1058 + ], 423);
858 1059 }
859 1060
860 1061 public function getProductTermsList(): array
861 1062 {
@@ -965,39 +1166,47 @@
965 1166 $name = Arr::get($data, 'search', '');
966 1167 }
967 1168 $ids = Arr::get($data, 'ids', []);
968 1169 $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 1170
994 1171 $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);
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 + }
1000 1209 })
1001 1210 ->when(empty($name), function (Builder $q) {
1002 1211 return $q->limit(10);
1003 1212 })
@@ -1194,8 +1403,9 @@
1194 1403
1195 1404 public function fetchVariationsByIds(Request $request): array
1196 1405 {
1197 1406 $ids = $request->getSafe(['productIds.*' => 'intval']);
1407 + $ids = Arr::get($ids, 'productIds', []);
1198 1408 $ids = is_array($ids) ? $ids : [];
1199 1409 if (empty($ids)) {
1200 1410 return ['products' => []];
1201 1411 }
@@ -1330,8 +1540,13 @@
1330 1540 }
1331 1541
1332 1542 public function updateInventory(Request $request, $postId, $variantId)
1333 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 + }
1334 1549
1335 1550 $variant = ProductVariation::query()->find($variantId);
1336 1551
1337 1552 if (!$variant) {
@@ -1336,9 +1551,9 @@
1336 1551
1337 1552 if (!$variant) {
1338 1553 return $this->response->sendError([
1339 1554 'message' => __('Variant not found', 'fluent-cart')
1340 - ]);
1555 + ], 404);
1341 1556 }
1342 1557
1343 1558 // Capture old stock state before update
1344 1559 $oldAvailable = intval($variant->available);
@@ -1396,8 +1611,17 @@
1396 1611
1397 1612 public function updateManageStock(Request $request, $postId)
1398 1613 {
1399 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 + }
1400 1624
1401 1625 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1402 1626
1403 1627 $updateData = [