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/CustomerController.php +252 -2 1.3.25 → 1.6.5 View file →
@@ -11,8 +11,9 @@
11 11 use FluentCart\App\Models\Customer;
12 12 use FluentCart\App\Models\User;
13 13 use FluentCart\App\Services\Filter\CustomerFilter;
14 14 use FluentCart\App\Services\Filter\OrderFilter;
15 +use FluentCart\App\Services\Permission\PermissionManager;
15 16 use FluentCart\Framework\Database\Orm\Collection;
16 17 use FluentCart\Framework\Http\Request\Request;
17 18 use FluentCart\Framework\Support\Arr;
18 19
@@ -51,9 +52,9 @@
51 52
52 53 public function find(Request $request, $customerId)
53 54 {
54 55
55 - $with = $request->get('with', []);
56 + $with = $this->resolveEagerLoads($request->get('with', []));
56 57
57 58 $customer = Customer::with($with)->find($customerId);
58 59
59 60 if (empty($customer)) {
@@ -63,9 +64,17 @@
63 64 '/customers'
64 65 );
65 66 }
66 67
67 - $selectedLabels = Collection::make($customer['labels'])->pluck('label_id');
68 + // Read the labels relation ONLY when the allow-list actually loaded it.
69 + // `$customer['labels']` on its own lazy-loads the relation and caches it
70 + // into $customer->relations, which relationsToArray() then serializes —
71 + // so the plain array access put the whole labels subtree in the response
72 + // for a caller who neither asked for it nor holds labels/view, and the
73 + // gate below it was decorative.
74 + $labels = $customer->relationLoaded('labels') ? $customer->getRelation('labels') : [];
75 + $selectedLabels = Collection::make($labels)->pluck('label_id');
76 +
68 77 if ($request->get('params.customer_only') === 'yes') {
69 78 return $this->sendSuccess(['customer' => $customer]);
70 79 }
71 80
@@ -74,8 +83,246 @@
74 83 $customer = apply_filters('fluent_cart/customer/view', $customer, $request->all());
75 84 return $this->sendSuccess(['customer' => $customer]);
76 85 }
77 86
87 + /**
88 + * What the `with` parameter on `GET customers/{id}` may eager-load.
89 + *
90 + * ## The entry form
91 + *
92 + * Every entry is a LITERAL request key mapped to a CALLABLE. The key is never
93 + * decomposed, prefix-matched or suffix-stripped, so what the client sends is
94 + * either a key in this map or it is dropped. That is what keeps every ORM
95 + * `with` shape out on its own: the dotted `orders.customer.wpUser`, the
96 + * column-select `wpUser:ID,user_pass` and the nested array
97 + * `with[orders][]=customer.wpUser` are all non-keys here.
98 + *
99 + * The callback owns the whole path AND its own permission bar, and returns
100 + * the relation paths to eager-load — an empty array when it refuses. The
101 + * caller merges what comes back; a refusing callback contributes nothing.
102 + *
103 + * ## Two tiers of key
104 + *
105 + * A SCREEN key names a calling screen and loads exactly the subtree that
106 + * screen renders, so the screen can be re-scoped without widening the payload
107 + * for anybody else. A PUBLIC key is a plain relation name an external
108 + * consumer of a customer endpoint can reasonably ask for; each one carries
109 + * the same gate its screen-key counterpart carries.
110 + *
111 + * ## What stays off the map
112 + *
113 + * `wpUser` is absent and must stay absent at EVERY nesting depth: it is a
114 + * BelongsTo onto the WordPress `users` table, so loading it hands the caller
115 + * the password hash (`user_pass`) and the password-reset token
116 + * (`user_activation_key`). No callback below names it, and no dotted path can
117 + * reach it because dotted paths are not keys.
118 + *
119 + * Kept local to this controller rather than folded into
120 + * `Services/Filter/BaseFilter::allowedWiths()`: that map adopts a Builder
121 + * returned by each callback, while this endpoint eager-loads onto a model
122 + * lookup, and the two maps share no entry.
123 + *
124 + * @return array<string, callable>
125 + */
126 + private function allowedWiths(): array
127 + {
128 + return [
129 + 'admin_customer_detail' => [$this, 'adminCustomerDetail'],
130 +
131 + // The public entry points. Each is the plain, unnested version of a
132 + // relation the screen key above loads a shaped subtree of, and each
133 + // repeats that key's gate. Same callbacks would be misleading here —
134 + // the screen key returns four relations at once, these return one.
135 + 'shipping_address' => [$this, 'publicShippingAddress'],
136 + 'billing_address' => [$this, 'publicBillingAddress'],
137 + 'primary_shipping_address' => [$this, 'publicPrimaryShippingAddress'],
138 + 'primary_billing_address' => [$this, 'publicPrimaryBillingAddress'],
139 + 'labels' => [$this, 'publicLabels'],
140 + 'subscriptions' => [$this, 'publicSubscriptions'],
141 + ];
142 + }
143 +
144 + /**
145 + * `Modules/Customers/SingleCustomer.vue` fetch() — the customer detail screen,
146 + * and the only caller of this endpoint in the admin app.
147 + *
148 + * It renders the two address blocks, the label chips and the subscriptions
149 + * table, so it gets exactly those four relations and no `orders`: the same
150 + * screen pulls its order table from `GET customers/{id}/orders`, which
151 + * paginates.
152 + *
153 + * The gates match what each relation reaches, not what the screen wants:
154 + * addresses are customer-owned rows and carry nothing beyond the customer
155 + * record the route already granted, while labels and subscriptions are
156 + * separate resources with their own permission.
157 + *
158 + * @return array relation paths
159 + */
160 + private function adminCustomerDetail(): array
161 + {
162 + if (!PermissionManager::hasPermission('customers/view')) {
163 + return [];
164 + }
165 +
166 + $relations = ['shipping_address', 'billing_address'];
167 +
168 + if (PermissionManager::hasPermission('labels/view')) {
169 + $relations[] = 'labels';
170 + }
171 +
172 + if (PermissionManager::hasPermission('subscriptions/view')) {
173 + $relations[] = 'subscriptions';
174 + }
175 +
176 + return $relations;
177 + }
178 +
179 + /**
180 + * The customer's shipping addresses. A customer-owned row: the route's own
181 + * `customers/view` is the whole bar, restated here so the entry still refuses
182 + * if this method is ever reached from somewhere the route did not guard.
183 + *
184 + * @return array relation paths
185 + */
186 + private function publicShippingAddress(): array
187 + {
188 + if (!PermissionManager::hasPermission('customers/view')) {
189 + return [];
190 + }
191 +
192 + return ['shipping_address'];
193 + }
194 +
195 + /**
196 + * The customer's billing addresses. Same bar as the shipping addresses.
197 + *
198 + * @return array relation paths
199 + */
200 + private function publicBillingAddress(): array
201 + {
202 + if (!PermissionManager::hasPermission('customers/view')) {
203 + return [];
204 + }
205 +
206 + return ['billing_address'];
207 + }
208 +
209 + /**
210 + * The single address flagged primary for shipping. Same bar again — it is a
211 + * narrowed `shipping_address`, not a different resource.
212 + *
213 + * @return array relation paths
214 + */
215 + private function publicPrimaryShippingAddress(): array
216 + {
217 + if (!PermissionManager::hasPermission('customers/view')) {
218 + return [];
219 + }
220 +
221 + return ['primary_shipping_address'];
222 + }
223 +
224 + /**
225 + * The single address flagged primary for billing.
226 + *
227 + * @return array relation paths
228 + */
229 + private function publicPrimaryBillingAddress(): array
230 + {
231 + if (!PermissionManager::hasPermission('customers/view')) {
232 + return [];
233 + }
234 +
235 + return ['primary_billing_address'];
236 + }
237 +
238 + /**
239 + * The labels attached to this customer. A separate resource with its own
240 + * permission, so `customers/view` alone is not enough.
241 + *
242 + * @return array relation paths
243 + */
244 + private function publicLabels(): array
245 + {
246 + if (!PermissionManager::hasPermission('labels/view')) {
247 + return [];
248 + }
249 +
250 + return ['labels'];
251 + }
252 +
253 + /**
254 + * The customer's subscriptions. `subscriptions/view` is the bar.
255 + *
256 + * UNBOUNDED, and knowingly so. This is a to-many with no LIMIT, so a
257 + * customer with a long history costs a proportional number of rows,
258 + * hydrated models and serialized output on one request. `orders` used to sit
259 + * beside it and was removed for exactly that reason — but `orders` had a
260 + * paginated endpoint to redirect to (`GET customers/{id}/orders`) and no
261 + * production caller, while this one has neither.
262 + *
263 + * SingleCustomer.vue renders `customer.subscriptions` as a complete list
264 + * with a count, so capping it here would silently truncate a list the UI
265 + * presents as whole — worse than the unbounded read.
266 + *
267 + * The fix is a paginated subscriptions endpoint, after which this key goes
268 + * the same way `orders` did. Until then the exposure is real and this
269 + * comment is the record of it.
270 + *
271 + * @return array relation paths
272 + */
273 + private function publicSubscriptions(): array
274 + {
275 + if (!PermissionManager::hasPermission('subscriptions/view')) {
276 + return [];
277 + }
278 +
279 + return ['subscriptions'];
280 + }
281 +
282 + /**
283 + * Reduce a client-supplied `with` payload to the relation paths this endpoint
284 + * is allowed to eager-load.
285 + *
286 + * Anything that is not a literal key of allowedWiths() is dropped SILENTLY.
287 + * An unknown relation otherwise reaches Builder::getRelation(), which turns a
288 + * BadMethodCallException into a RelationNotFoundException — a 500 — so a
289 + * stale admin build would hard-fail where it should degrade.
290 + *
291 + * Only STRING request entries are considered. Dropping the rest is what kills
292 + * the nested-array shape `with[orders][]=customer.wpUser`: its value is an
293 + * array, and its key is never read.
294 + *
295 + * @param mixed $with raw request value
296 + * @return array relation names safe to pass to Customer::with()
297 + */
298 + private function resolveEagerLoads($with): array
299 + {
300 + $map = $this->allowedWiths();
301 +
302 + $resolved = [];
303 +
304 + foreach (Arr::wrap($with) as $requestKey) {
305 + if (!is_string($requestKey) || !array_key_exists($requestKey, $map)) {
306 + continue;
307 + }
308 +
309 + $entry = $map[$requestKey];
310 +
311 + if (!is_callable($entry)) {
312 + continue;
313 + }
314 +
315 + foreach ((array) $entry() as $relation) {
316 + if (is_string($relation) && $relation !== '') {
317 + $resolved[$relation] = true;
318 + }
319 + }
320 + }
321 +
322 + return array_keys($resolved);
323 + }
324 +
78 325 public function findOrder(Request $request, $customerId)
79 326 {
80 327 return ['data' => CustomerResource::findOrder($customerId)];
81 328 }
@@ -103,8 +350,9 @@
103 350 public function createAddress(CustomerAddressRequest $request, $customerId)
104 351 {
105 352
106 353 $data = $request->getSafe($request->sanitize());
354 + $data = CustomerAddressResource::normalizeBusinessFields($data);
107 355 $isCreated = CustomerAddressResource::create($data, ['id' => $customerId, 'order_id' => intval(Arr::get($request->all(), 'order_id', null))]);
108 356
109 357 if (is_wp_error($isCreated)) {
110 358 return $isCreated;
@@ -115,8 +363,9 @@
115 363 public function updateAddress(CustomerAddressRequest $request)
116 364 {
117 365
118 366 $data = $request->getSafe($request->sanitize());
367 + $data = CustomerAddressResource::normalizeBusinessFields($data);
119 368 $id = Arr::get($request->all(), 'id');
120 369 $isUpdated = CustomerAddressResource::update($data, $id, ['order_id' => intval(Arr::get($request->all(), 'order_id', null))]);
121 370
122 371 if (is_wp_error($isUpdated)) {
@@ -266,5 +515,6 @@
266 515 'message' => __('Lifetime value recalculated successfully', 'fluent-cart'),
267 516 'customer' => $customer
268 517 ]);
269 518 }
519 +
270 520 }