| @@ -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 | } |