PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Http / Controllers / CustomerController.php

CustomerController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Http/Controllers/CustomerController.php

521 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Http\Controllers;
4
5 use FluentCart\Api\Resource\CustomerAddressResource;
6 use FluentCart\Api\Resource\CustomerResource;
7 use FluentCart\App\Helpers\CustomerHelper;
8 use FluentCart\App\Http\Requests\AttachUserRequest;
9 use FluentCart\App\Http\Requests\CustomerAddressRequest;
10 use FluentCart\App\Http\Requests\CustomerRequest;
11 use FluentCart\App\Models\Customer;
12 use FluentCart\App\Models\User;
13 use FluentCart\App\Services\Filter\CustomerFilter;
14 use FluentCart\App\Services\Filter\OrderFilter;
15 use FluentCart\App\Services\Permission\PermissionManager;
16 use FluentCart\Framework\Database\Orm\Collection;
17 use FluentCart\Framework\Http\Request\Request;
18 use FluentCart\Framework\Support\Arr;
19
20 class CustomerController extends Controller
21 {
22 public function index(Request $request): \WP_REST_Response
23 {
24 return $this->sendSuccess(
25 [
26 'customers' => CustomerFilter::fromRequest($request)->paginate()
27 ]
28 );
29 }
30
31 public function store(CustomerRequest $request)
32 {
33 $data = $request->getSafe($request->sanitize());
34 $isCreated = CustomerResource::create($data);
35
36 if (is_wp_error($isCreated)) {
37 return $isCreated;
38 }
39 return $this->response->sendSuccess($isCreated);
40 }
41
42 public function update(CustomerRequest $request, $customerId)
43 {
44 $data = $request->getSafe($request->sanitize());
45 $isUpdated = CustomerResource::update($data, $customerId);
46
47 if (is_wp_error($isUpdated)) {
48 return $isUpdated;
49 }
50 return $this->response->sendSuccess($isUpdated);
51 }
52
53 public function find(Request $request, $customerId)
54 {
55
56 $with = $this->resolveEagerLoads($request->get('with', []));
57
58 $customer = Customer::with($with)->find($customerId);
59
60 if (empty($customer)) {
61 return $this->entityNotFoundError(
62 __('Customer not found', 'fluent-cart'),
63 __('Back to Customer List', 'fluent-cart'),
64 '/customers'
65 );
66 }
67
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
77 if ($request->get('params.customer_only') === 'yes') {
78 return $this->sendSuccess(['customer' => $customer]);
79 }
80
81 $customer['selected_labels'] = $selectedLabels;
82
83 $customer = apply_filters('fluent_cart/customer/view', $customer, $request->all());
84 return $this->sendSuccess(['customer' => $customer]);
85 }
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
325 public function findOrder(Request $request, $customerId)
326 {
327 return ['data' => CustomerResource::findOrder($customerId)];
328 }
329
330 public function updateAdditionalInfo(Request $request, $customerId)
331 {
332 $isUpdated = CustomerResource::updateAdditionalInfo($request->all(), $customerId);
333
334 if (is_wp_error($isUpdated)) {
335 return $isUpdated;
336 }
337 return $this->response->sendSuccess($isUpdated);
338 }
339
340 public function getAddress(Request $request, $customerId)
341 {
342 return [
343 'addresses' => CustomerAddressResource::get([
344 'customer_id' => $customerId,
345 'type' => $request->type
346 ])
347 ];
348 }
349
350 public function createAddress(CustomerAddressRequest $request, $customerId)
351 {
352
353 $data = $request->getSafe($request->sanitize());
354 $data = CustomerAddressResource::normalizeBusinessFields($data);
355 $isCreated = CustomerAddressResource::create($data, ['id' => $customerId, 'order_id' => intval(Arr::get($request->all(), 'order_id', null))]);
356
357 if (is_wp_error($isCreated)) {
358 return $isCreated;
359 }
360 return $this->response->sendSuccess($isCreated);
361 }
362
363 public function updateAddress(CustomerAddressRequest $request)
364 {
365
366 $data = $request->getSafe($request->sanitize());
367 $data = CustomerAddressResource::normalizeBusinessFields($data);
368 $id = Arr::get($request->all(), 'id');
369 $isUpdated = CustomerAddressResource::update($data, $id, ['order_id' => intval(Arr::get($request->all(), 'order_id', null))]);
370
371 if (is_wp_error($isUpdated)) {
372 return $isUpdated;
373 }
374 return $this->response->sendSuccess($isUpdated);
375 }
376
377 public function removeAddress(Request $request)
378 {
379
380 $id = Arr::get($request->address, 'id', false);
381 $isDeleted = CustomerAddressResource::delete($id);
382
383 if (is_wp_error($isDeleted)) {
384 return $isDeleted;
385 }
386 return $this->response->sendSuccess($isDeleted);
387 }
388
389 public function setAddressPrimary(Request $request, $customerId)
390 {
391 $isUpdated = CustomerAddressResource::makePrimary(
392 $customerId,
393 $request->getSafe('addressId', 'intval'),
394 $request->getSafe('type', 'sanitize_text_field')
395 );
396
397 if (is_wp_error($isUpdated)) {
398 return $isUpdated;
399 }
400 return $this->response->sendSuccess($isUpdated);
401 }
402
403 public function getCustomerOrders(Request $request, $customerId): array
404 {
405 $orderFilter = OrderFilter::fromRequest($request);
406 $orderFilter->query = $orderFilter->query->where('customer_id', $customerId);
407 return [
408 'orders' => $orderFilter->paginate()
409 ];
410 }
411
412 public function handleBulkActions(Request $request, CustomerHelper $customerHelper)
413 {
414 $isUpdated = CustomerResource::manageCustomer($request->all());
415
416 if (is_wp_error($isUpdated)) {
417 return $isUpdated;
418 }
419 return $this->response->sendSuccess($isUpdated);
420 }
421
422 public function getStats($customerId): \WP_REST_Response
423 {
424 $customer = CustomerResource::find($customerId);
425 return $this->sendSuccess([
426 'widgets' => apply_filters('fluent_cart/widgets/single_customer', [], $customer)
427 ]);
428 }
429
430
431 public function getAttachableUser(): \WP_REST_Response
432 {
433 return $this->sendSuccess([
434 'users' => User::query()->select('ID', 'display_name', 'user_email')->whereDoesntHave('customer')->get()
435 ]);
436 }
437
438 public function setAttachableUser(AttachUserRequest $request, $customerId): \WP_REST_Response
439 {
440
441 $customer = Customer::query()->with('wpUser')->find($customerId);
442
443 if (empty($customer)) {
444
445 return $this->sendError([
446 'message' => __('Customer not found.', 'fluent-cart')
447 ]);
448 }
449
450 if (!empty($customer->wpUser)) {
451 return $this->sendError([
452 'message' => __('Can not attach user', 'fluent-cart')
453 ]);
454 }
455
456 $data = $request->getSafe($request->sanitize());
457 $userId = Arr::get($data, 'user_id');
458
459
460 $customer->user_id = $userId;
461 $attached = $customer->save();
462
463 if ($attached) {
464 return $this->sendSuccess([
465 'message' => __('User attached successfully', 'fluent-cart')
466 ]);
467 } else {
468 return $this->sendError([
469 'message' => __('Can not attach user', 'fluent-cart')
470 ]);
471 }
472
473
474 }
475
476 public function detachCustomer(Request $request, $customerId): \WP_REST_Response
477 {
478
479 $customer = Customer::query()->find($customerId);
480
481 if (empty($customer)) {
482 return $this->sendError([
483 'message' => __('Customer not found.', 'fluent-cart')
484 ]);
485 }
486
487
488 $customer->user_id = null;
489 $detached = $customer->save();
490
491 if ($detached) {
492 return $this->sendSuccess([
493 'message' => __('User detached successfully', 'fluent-cart')
494 ]);
495 } else {
496 return $this->sendError([
497 'message' => __('Can not detach user', 'fluent-cart')
498 ]);
499 }
500 }
501
502 public function recalculateLtv(Request $request, $customerId): \WP_REST_Response
503 {
504 $customer = Customer::query()->find($customerId);
505
506 if (empty($customer)) {
507 return $this->sendError([
508 'message' => __('Customer not found.', 'fluent-cart')
509 ]);
510 }
511
512 $customer->recountStat();
513
514 return $this->sendSuccess([
515 'message' => __('Lifetime value recalculated successfully', 'fluent-cart'),
516 'customer' => $customer
517 ]);
518 }
519
520 }
521