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.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 trunk All 48 releases
← All changes | app/Http/Controllers/ProductController.php +234 -39 1.6.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;
@@ -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,8 +81,172 @@
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
@@ -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 ]);
@@ -871,19 +1039,24 @@
871 1039
872 1040 $termNames = explode(',', $name);
873 1041 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
874 1042
875 - if (count($ids)) {
876 - $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([
877 1051 'term_ids' => $ids,
878 1052 'names' => $termNames
879 1053 ]);
880 - } else {
881 - $this->response->json([
882 - 'message' => __('Unable To Create Term/s', 'fluent-cart'),
883 - ], 423);
884 1054 }
885 1055
1056 + return $this->response->sendError([
1057 + 'message' => __('Unable To Create Term/s', 'fluent-cart'),
1058 + ], 423);
886 1059 }
887 1060
888 1061 public function getProductTermsList(): array
889 1062 {
@@ -993,39 +1166,47 @@
993 1166 $name = Arr::get($data, 'search', '');
994 1167 }
995 1168 $ids = Arr::get($data, 'ids', []);
996 1169 $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 1170
1022 1171 $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);
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 + }
1028 1209 })
1029 1210 ->when(empty($name), function (Builder $q) {
1030 1211 return $q->limit(10);
1031 1212 })
@@ -1359,8 +1540,13 @@
1359 1540 }
1360 1541
1361 1542 public function updateInventory(Request $request, $postId, $variantId)
1362 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 + }
1363 1549
1364 1550 $variant = ProductVariation::query()->find($variantId);
1365 1551
1366 1552 if (!$variant) {
@@ -1365,9 +1551,9 @@
1365 1551
1366 1552 if (!$variant) {
1367 1553 return $this->response->sendError([
1368 1554 'message' => __('Variant not found', 'fluent-cart')
1369 - ]);
1555 + ], 404);
1370 1556 }
1371 1557
1372 1558 // Capture old stock state before update
1373 1559 $oldAvailable = intval($variant->available);
@@ -1425,8 +1611,17 @@
1425 1611
1426 1612 public function updateManageStock(Request $request, $postId)
1427 1613 {
1428 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 + }
1429 1624
1430 1625 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1431 1626
1432 1627 $updateData = [