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 +255 -41 1.5.3 → 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,8 +99,172 @@
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
@@ -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 ]);
@@ -871,19 +1057,24 @@
871 1057
872 1058 $termNames = explode(',', $name);
873 1059 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
874 1060
875 - if (count($ids)) {
876 - $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([
877 1069 'term_ids' => $ids,
878 1070 'names' => $termNames
879 1071 ]);
880 - } else {
881 - $this->response->json([
882 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
883 - ], 423);
884 1072 }
885 1073
1074 + return $this->response->sendError([
1075 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1076 + ], 423);
886 1077 }
887 1078
888 1079 public function getProductTermsList(): array
889 1080 {
@@ -993,39 +1184,47 @@
993 1184 $name = Arr::get($data, 'search', '');
994 1185 }
995 1186 $ids = Arr::get($data, 'ids', []);
996 1187 $productVariations = [];
997 - $query = [];
998 - if (!empty($name) || count($ids) > 0) {
999 - $query = [
1000 - "ID" =>
1001 - [
1002 - "column" => "ID",
1003 - "operator" => "in",
1004 - "value" => Arr::get($data, 'ids', [])
1005 - ]
1006 - ,
1007 - "post_title" =>
1008 - [
1009 - "column" => "post_title",
1010 - "operator" => "like",
1011 - "value" => '%' . Arr::get($data, 'name') . '%'
1012 - ],
1013 - "post_status" =>
1014 - [
1015 - "column" => "post_status",
1016 - "operator" => "=",
1017 - "value" => 'publish'
1018 - ]
1019 - ];
1020 - }
1021 1188
1022 1189 $products = Product::query()
1023 - ->with('variants')
1024 - ->when(count($query), function (Builder $q) use ($query) {
1025 - return $q->search($query, function (Builder $query) {
1026 - return $query;
1027 - }, 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 + }
1028 1227 })
1029 1228 ->when(empty($name), function (Builder $q) {
1030 1229 return $q->limit(10);
1031 1230 })
@@ -1222,8 +1421,9 @@
1222 1421
1223 1422 public function fetchVariationsByIds(Request $request): array
1224 1423 {
1225 1424 $ids = $request->getSafe(['productIds.*' => 'intval']);
1425 + $ids = Arr::get($ids, 'productIds', []);
1226 1426 $ids = is_array($ids) ? $ids : [];
1227 1427 if (empty($ids)) {
1228 1428 return ['products' => []];
1229 1429 }
@@ -1358,8 +1558,13 @@
1358 1558 }
1359 1559
1360 1560 public function updateInventory(Request $request, $postId, $variantId)
1361 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 + }
1362 1567
1363 1568 $variant = ProductVariation::query()->find($variantId);
1364 1569
1365 1570 if (!$variant) {
@@ -1364,9 +1569,9 @@
1364 1569
1365 1570 if (!$variant) {
1366 1571 return $this->response->sendError([
1367 1572 'message' => __('Variant not found', 'fluent-cart')
1368 - ]);
1573 + ], 404);
1369 1574 }
1370 1575
1371 1576 // Capture old stock state before update
1372 1577 $oldAvailable = intval($variant->available);
@@ -1424,8 +1629,17 @@
1424 1629
1425 1630 public function updateManageStock(Request $request, $postId)
1426 1631 {
1427 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 + }
1428 1642
1429 1643 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1430 1644
1431 1645 $updateData = [