| @@ -9,23 +9,18 @@ | ||
| 9 | 9 | use FluentSupport\App\Services\Helper; |
| 10 | 10 | use FluentSupport\Framework\Support\Arr; |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | - * CustomerController class for REST API | |
| 14 | - * This class is responsible for getting data for all request related to customer | |
| 15 | - * @package FluentSupport\App\Http\Controllers | |
| 16 | - * | |
| 17 | - * @version 1.0.0 | |
| 13 | + * Customer endpoints. | |
| 18 | 14 | */ |
| 19 | 15 | class CustomerController extends Controller |
| 20 | 16 | { |
| 21 | 17 | /** |
| 22 | - * Maximum number of customers accepted by a single bulk-delete request. | |
| 18 | + * Each id cascades into tickets, conversations and attachments, so the batch is capped. | |
| 23 | 19 | */ |
| 24 | 20 | const BULK_DELETE_LIMIT = 100; |
| 25 | 21 | |
| 26 | 22 | /** |
| 27 | - * index method will return the list of customers | |
| 28 | 23 | * @param Request $request |
| 29 | 24 | * @param Customer $customer |
| 30 | 25 | * @return array |
| 31 | 26 | */ |
| @@ -45,13 +40,13 @@ | ||
| 45 | 40 | } |
| 46 | 41 | |
| 47 | 42 | |
| 48 | 43 | /** |
| 49 | - * getCustomer method will return individual customer information by customer id | |
| 50 | - * This function will also get information about extra widgets, tickets and Fluent CRM | |
| 44 | + * `with` can add widgets, tickets and FluentCRM data. | |
| 45 | + * | |
| 51 | 46 | * @param Request $request |
| 52 | 47 | * @param Customer $customer |
| 53 | - * @param $customer_id | |
| 48 | + * @param int $customer_id | |
| 54 | 49 | * @return array |
| 55 | 50 | */ |
| 56 | 51 | public function getCustomer(Request $request, Customer $customer, $customer_id) |
| 57 | 52 | { |
| @@ -61,9 +56,8 @@ | ||
| 61 | 56 | return $customer->getCustomer($customer_id, $with); |
| 62 | 57 | } |
| 63 | 58 | |
| 64 | 59 | /** |
| 65 | - * Create method will create new customer | |
| 66 | 60 | * @param Request $request |
| 67 | 61 | * @param Customer $customer |
| 68 | 62 | * @return array |
| 69 | 63 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| @@ -69,9 +63,16 @@ | ||
| 69 | 63 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| 70 | 64 | */ |
| 71 | 65 | public function create(Request $request, Customer $customer) |
| 72 | 66 | { |
| 73 | - // Define expected fields with their sanitizers | |
| 67 | + // The WordPress user is linked through the email only, so a client user_id could | |
| 68 | + // otherwise make the new record another account's portal identity. | |
| 69 | + if ($request->get('user_id')) { | |
| 70 | + return $this->sendError([ | |
| 71 | + 'message' => __('user_id is not accepted when creating a customer. The WordPress user is linked by the customer email.', 'fluent-support') | |
| 72 | + ], 422); | |
| 73 | + } | |
| 74 | + | |
| 74 | 75 | $fields = [ |
| 75 | 76 | 'id' => 'intval', |
| 76 | 77 | 'customer_id' => 'intval', |
| 77 | 78 | 'avatar' => 'esc_url_raw', |
| @@ -82,9 +83,8 @@ | ||
| 82 | 83 | 'email' => 'sanitize_email', |
| 83 | 84 | 'first_name' => 'sanitize_text_field', |
| 84 | 85 | 'last_name' => 'sanitize_text_field', |
| 85 | 86 | 'title' => 'sanitize_text_field', |
| 86 | - 'user_id' => 'intval', | |
| 87 | 87 | 'remote_uid' => 'sanitize_text_field', |
| 88 | 88 | 'status' => 'sanitize_text_field', |
| 89 | 89 | 'address_line_1' => 'sanitize_textarea_field', |
| 90 | 90 | 'address_line_2' => 'sanitize_textarea_field', |
| @@ -103,9 +103,8 @@ | ||
| 103 | 103 | 'email' => 'required|email|unique:fs_persons', |
| 104 | 104 | 'first_name' => 'required', |
| 105 | 105 | 'last_name' => 'nullable|string', |
| 106 | 106 | 'title' => 'nullable|string', |
| 107 | - 'user_id' => 'nullable|integer', | |
| 108 | 107 | 'remote_uid' => 'nullable|string', |
| 109 | 108 | 'status' => 'nullable|string', |
| 110 | 109 | 'address_line_1' => 'nullable|string', |
| 111 | 110 | 'address_line_2' => 'nullable|string', |
| @@ -124,18 +123,16 @@ | ||
| 124 | 123 | ]; |
| 125 | 124 | } |
| 126 | 125 | |
| 127 | 126 | /** |
| 128 | - * update method will update existing customer by customer id | |
| 129 | 127 | * @param Request $request |
| 130 | 128 | * @param Customer $customer |
| 131 | - * @param $customerId | |
| 129 | + * @param int $customer_id | |
| 132 | 130 | * @return array |
| 133 | 131 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| 134 | 132 | */ |
| 135 | 133 | public function update(Request $request, Customer $customer, $customer_id) |
| 136 | 134 | { |
| 137 | - // Sanitize only allowed fields and also sanitize any extra fields from hooks | |
| 138 | 135 | $fields = [ |
| 139 | 136 | 'id' => 'intval', |
| 140 | 137 | 'customer_id' => 'intval', |
| 141 | 138 | 'avatar' => 'esc_url_raw', |
| @@ -199,12 +196,13 @@ | ||
| 199 | 196 | } |
| 200 | 197 | } |
| 201 | 198 | |
| 202 | 199 | /** |
| 203 | - * delete method will delete a customer and all tickets by that customer | |
| 200 | + * Also deletes the customer's tickets. | |
| 201 | + * | |
| 204 | 202 | * @param Request $request |
| 205 | 203 | * @param Customer $customer |
| 206 | - * @param int $customerId | |
| 204 | + * @param int $customer_id | |
| 207 | 205 | * @return array |
| 208 | 206 | */ |
| 209 | 207 | public function delete(Request $request, Customer $customer, $customer_id) |
| 210 | 208 | { |
| @@ -211,9 +209,10 @@ | ||
| 211 | 209 | return $customer->deleteCustomer($customer_id); |
| 212 | 210 | } |
| 213 | 211 | |
| 214 | 212 | /** |
| 215 | - * bulkDelete method will delete multiple customers and all their tickets | |
| 213 | + * Also deletes the customers' tickets. | |
| 214 | + * | |
| 216 | 215 | * @param Request $request |
| 217 | 216 | * @param Customer $customer |
| 218 | 217 | * @return array |
| 219 | 218 | */ |
| @@ -218,13 +217,11 @@ | ||
| 218 | 217 | * @return array |
| 219 | 218 | */ |
| 220 | 219 | public function bulkDelete(Request $request, Customer $customer) |
| 221 | 220 | { |
| 222 | - // Get and sanitize customer_ids before validation | |
| 223 | 221 | $customerIds = $request->get('customer_ids', []); |
| 224 | 222 | $customerIds = is_array($customerIds) ? array_map('intval', $customerIds) : []; |
| 225 | 223 | |
| 226 | - // Filter out any zero values (from invalid input) | |
| 227 | 224 | $customerIds = array_filter($customerIds, function ($id) { |
| 228 | 225 | return $id > 0; |
| 229 | 226 | }); |
| 230 | 227 | |
| @@ -229,10 +226,8 @@ | ||
| 229 | 226 | }); |
| 230 | 227 | |
| 231 | 228 | $customerIds = array_values(array_unique($customerIds)); |
| 232 | 229 | |
| 233 | - // Each id fans out into a full cascade delete (tickets, conversations, | |
| 234 | - // attachments), so an unbounded batch means an unbounded request. | |
| 235 | 230 | $this->validate(['customer_ids' => $customerIds], [ |
| 236 | 231 | 'customer_ids' => 'required|array|min:1|max:' . self::BULK_DELETE_LIMIT, |
| 237 | 232 | 'customer_ids.*' => 'required|integer|exists:fs_persons,id' |
| 238 | 233 | ]); |
| @@ -240,11 +235,10 @@ | ||
| 240 | 235 | return $customer->bulkDeleteCustomers($customerIds); |
| 241 | 236 | } |
| 242 | 237 | |
| 243 | 238 | /** |
| 244 | - * addOrUpdateProfileImage method will update a customer avatar | |
| 245 | - * For a successful upload it's required to send file object, customer id and the user type(customer) | |
| 246 | 239 | * @param Request $request |
| 240 | + * @param AvatarUploder $avatarUploder | |
| 247 | 241 | * @return array |
| 248 | 242 | */ |
| 249 | 243 | public function addOrUpdateProfileImage(Request $request, AvatarUploder $avatarUploder) |
| 250 | 244 | { |
| @@ -259,14 +253,11 @@ | ||
| 259 | 253 | } |
| 260 | 254 | } |
| 261 | 255 | |
| 262 | 256 | /** |
| 263 | - * resetAvatar method will restore a customer avatar | |
| 264 | - * For a successful upload it's required to send file object, customer id and the user type(customer) | |
| 257 | + * No Customer type-hint: route-model binding runs before the policy, which would | |
| 258 | + * let unauthenticated callers probe customer IDs. Resolve after auth. | |
| 265 | 259 | * |
| 266 | - * No Customer type-hint here: route-model binding resolves inside the | |
| 267 | - * permission callback, before any policy runs, which lets unauthenticated | |
| 268 | - * callers probe customer ID existence (FS-PERM-001). Resolve after auth. | |
| 269 | 260 | * @param int|string $customer |
| 270 | 261 | * @return array |
| 271 | 262 | */ |
| 272 | 263 | public function resetAvatar($customer) |
| @@ -288,11 +279,10 @@ | ||
| 288 | 279 | public function searchContact(Request $request) |
| 289 | 280 | { |
| 290 | 281 | $search = trim($request->getSafe('search', 'sanitize_text_field')); |
| 291 | 282 | |
| 292 | - // '*' is a WP_User_Query wildcard and survives sanitize_text_field, so a | |
| 293 | - // lone '*' would list every user on the site. Stripping it leaves | |
| 294 | - // WP_User_Query doing an exact match. | |
| 283 | + // '*' is a WP_User_Query wildcard that survives sanitize_text_field. | |
| 284 | + // A lone '*' would list every user. | |
| 295 | 285 | $search = trim(str_replace('*', '', $search)); |
| 296 | 286 | |
| 297 | 287 | if (!$search) { |
| 298 | 288 | return $this->sendError([ |
| @@ -301,10 +291,9 @@ | ||
| 301 | 291 | } |
| 302 | 292 | |
| 303 | 293 | $isEmail = is_email($search); |
| 304 | 294 | |
| 305 | - // Require a meaningful prefix so the endpoint can't be walked one letter | |
| 306 | - // at a time. Emails are matched exactly, so they need no minimum. | |
| 295 | + // Stops walking the list one letter at a time. Emails match exactly, so no minimum. | |
| 307 | 296 | if (!$isEmail && mb_strlen($search) < 3) { |
| 308 | 297 | return $this->sendError([ |
| 309 | 298 | 'message' => __('Please provide at least 3 characters to search', 'fluent-support') |
| 310 | 299 | ]); |
| @@ -315,14 +304,13 @@ | ||
| 315 | 304 | 'message' => __('Too many contact searches. Please try again in a few minutes.', 'fluent-support') |
| 316 | 305 | ], 429); |
| 317 | 306 | } |
| 318 | 307 | |
| 319 | - // '%' and '_' are LIKE wildcards for the customer and CRM scopes below. | |
| 320 | - // Escape rather than strip: underscores are legitimate in emails. | |
| 308 | + // Escape LIKE wildcards rather than strip them: '_' is valid in emails. | |
| 321 | 309 | global $wpdb; |
| 322 | 310 | $likeSearch = $wpdb->esc_like($search); |
| 323 | 311 | |
| 324 | - // search the existing customers first | |
| 312 | + // Customers first, then FluentCRM contacts, then WP users. | |
| 325 | 313 | if ($isEmail) { |
| 326 | 314 | $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 327 | 315 | ->where('email', $search) |
| 328 | 316 | ->get(); |
| @@ -342,9 +330,8 @@ | ||
| 342 | 330 | 'search' => $search |
| 343 | 331 | ]; |
| 344 | 332 | } |
| 345 | 333 | |
| 346 | - // If FluentCRM exist then let's search for | |
| 347 | 334 | if (defined('FLUENTCRM')) { |
| 348 | 335 | |
| 349 | 336 | if ($isEmail) { |
| 350 | 337 | $contacts = \FluentCrm\App\Models\Subscriber::where('email', $search) |
| @@ -367,9 +354,8 @@ | ||
| 367 | 354 | ]; |
| 368 | 355 | } |
| 369 | 356 | } |
| 370 | 357 | |
| 371 | - // let's search from user's database | |
| 372 | 358 | $user_query = new \WP_User_Query(array('search' => $search, 'number' => 10)); |
| 373 | 359 | |
| 374 | 360 | $users = $user_query->get_results(); |
| 375 | 361 | |
| @@ -403,11 +389,10 @@ | ||
| 403 | 389 | |
| 404 | 390 | } |
| 405 | 391 | |
| 406 | 392 | /** |
| 407 | - * Sanitize request data for given fields. Uses Request::getSafe for known fields | |
| 408 | - * and falls back to sanitize_text_field for any other keys present in the raw request | |
| 409 | - * (useful when hooks inject extra data). | |
| 393 | + * Known fields use their mapped sanitizer. Any other keys (e.g. added by hooks) | |
| 394 | + * get sanitize_text_field. | |
| 410 | 395 | * |
| 411 | 396 | * @param Request $request |
| 412 | 397 | * @param array $fieldsMap associative array field => sanitizer callable name |
| 413 | 398 | * @return array |
| @@ -415,14 +400,12 @@ | ||
| 415 | 400 | private function sanitizeRequestData(Request $request, array $fieldsMap) |
| 416 | 401 | { |
| 417 | 402 | $sanitized = []; |
| 418 | 403 | |
| 419 | - // Use getSafe for known fields | |
| 420 | 404 | foreach ($fieldsMap as $field => $sanitizer) { |
| 421 | 405 | $sanitized[$field] = $request->getSafe($field, $sanitizer); |
| 422 | 406 | } |
| 423 | 407 | |
| 424 | - // Now sanitize any other incoming keys to avoid unsanitized data | |
| 425 | 408 | $raw = $request->get(); |
| 426 | 409 | foreach ($raw as $key => $value) { |
| 427 | 410 | if (array_key_exists($key, $sanitized)) { |
| 428 | 411 | continue; |
| @@ -430,9 +413,8 @@ | ||
| 430 | 413 | |
| 431 | 414 | if (is_array($value)) { |
| 432 | 415 | $sanitized[$key] = array_map('sanitize_text_field', $value); |
| 433 | 416 | } else { |
| 434 | - // Fallback sanitizer for unknown fields | |
| 435 | 417 | $sanitized[$key] = is_string($value) ? sanitize_text_field($value) : $value; |
| 436 | 418 | } |
| 437 | 419 | } |
| 438 | 420 | |