| @@ -9,18 +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 | - * index method will return the list of customers | |
| 18 | + * Each id cascades into tickets, conversations and attachments, so the batch is capped. | |
| 19 | + */ | |
| 20 | + const BULK_DELETE_LIMIT = 100; | |
| 21 | + | |
| 22 | + /** | |
| 23 | 23 | * @param Request $request |
| 24 | 24 | * @param Customer $customer |
| 25 | 25 | * @return array |
| 26 | 26 | */ |
| @@ -40,13 +40,13 @@ | ||
| 40 | 40 | } |
| 41 | 41 | |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | - * getCustomer method will return individual customer information by customer id | |
| 45 | - * This function will also get information about extra widgets, tickets and Fluent CRM | |
| 44 | + * `with` can add widgets, tickets and FluentCRM data. | |
| 45 | + * | |
| 46 | 46 | * @param Request $request |
| 47 | 47 | * @param Customer $customer |
| 48 | - * @param $customer_id | |
| 48 | + * @param int $customer_id | |
| 49 | 49 | * @return array |
| 50 | 50 | */ |
| 51 | 51 | public function getCustomer(Request $request, Customer $customer, $customer_id) |
| 52 | 52 | { |
| @@ -56,9 +56,8 @@ | ||
| 56 | 56 | return $customer->getCustomer($customer_id, $with); |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | /** |
| 60 | - * Create method will create new customer | |
| 61 | 60 | * @param Request $request |
| 62 | 61 | * @param Customer $customer |
| 63 | 62 | * @return array |
| 64 | 63 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| @@ -64,9 +63,16 @@ | ||
| 64 | 63 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| 65 | 64 | */ |
| 66 | 65 | public function create(Request $request, Customer $customer) |
| 67 | 66 | { |
| 68 | - // 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 | + | |
| 69 | 75 | $fields = [ |
| 70 | 76 | 'id' => 'intval', |
| 71 | 77 | 'customer_id' => 'intval', |
| 72 | 78 | 'avatar' => 'esc_url_raw', |
| @@ -77,9 +83,8 @@ | ||
| 77 | 83 | 'email' => 'sanitize_email', |
| 78 | 84 | 'first_name' => 'sanitize_text_field', |
| 79 | 85 | 'last_name' => 'sanitize_text_field', |
| 80 | 86 | 'title' => 'sanitize_text_field', |
| 81 | - 'user_id' => 'intval', | |
| 82 | 87 | 'remote_uid' => 'sanitize_text_field', |
| 83 | 88 | 'status' => 'sanitize_text_field', |
| 84 | 89 | 'address_line_1' => 'sanitize_textarea_field', |
| 85 | 90 | 'address_line_2' => 'sanitize_textarea_field', |
| @@ -98,9 +103,8 @@ | ||
| 98 | 103 | 'email' => 'required|email|unique:fs_persons', |
| 99 | 104 | 'first_name' => 'required', |
| 100 | 105 | 'last_name' => 'nullable|string', |
| 101 | 106 | 'title' => 'nullable|string', |
| 102 | - 'user_id' => 'nullable|integer', | |
| 103 | 107 | 'remote_uid' => 'nullable|string', |
| 104 | 108 | 'status' => 'nullable|string', |
| 105 | 109 | 'address_line_1' => 'nullable|string', |
| 106 | 110 | 'address_line_2' => 'nullable|string', |
| @@ -119,18 +123,16 @@ | ||
| 119 | 123 | ]; |
| 120 | 124 | } |
| 121 | 125 | |
| 122 | 126 | /** |
| 123 | - * update method will update existing customer by customer id | |
| 124 | 127 | * @param Request $request |
| 125 | 128 | * @param Customer $customer |
| 126 | - * @param $customerId | |
| 129 | + * @param int $customer_id | |
| 127 | 130 | * @return array |
| 128 | 131 | * @throws \FluentSupport\Framework\Validator\ValidationException |
| 129 | 132 | */ |
| 130 | 133 | public function update(Request $request, Customer $customer, $customer_id) |
| 131 | 134 | { |
| 132 | - // Sanitize only allowed fields and also sanitize any extra fields from hooks | |
| 133 | 135 | $fields = [ |
| 134 | 136 | 'id' => 'intval', |
| 135 | 137 | 'customer_id' => 'intval', |
| 136 | 138 | 'avatar' => 'esc_url_raw', |
| @@ -194,12 +196,13 @@ | ||
| 194 | 196 | } |
| 195 | 197 | } |
| 196 | 198 | |
| 197 | 199 | /** |
| 198 | - * delete method will delete a customer and all tickets by that customer | |
| 200 | + * Also deletes the customer's tickets. | |
| 201 | + * | |
| 199 | 202 | * @param Request $request |
| 200 | 203 | * @param Customer $customer |
| 201 | - * @param int $customerId | |
| 204 | + * @param int $customer_id | |
| 202 | 205 | * @return array |
| 203 | 206 | */ |
| 204 | 207 | public function delete(Request $request, Customer $customer, $customer_id) |
| 205 | 208 | { |
| @@ -206,9 +209,10 @@ | ||
| 206 | 209 | return $customer->deleteCustomer($customer_id); |
| 207 | 210 | } |
| 208 | 211 | |
| 209 | 212 | /** |
| 210 | - * bulkDelete method will delete multiple customers and all their tickets | |
| 213 | + * Also deletes the customers' tickets. | |
| 214 | + * | |
| 211 | 215 | * @param Request $request |
| 212 | 216 | * @param Customer $customer |
| 213 | 217 | * @return array |
| 214 | 218 | */ |
| @@ -213,19 +217,19 @@ | ||
| 213 | 217 | * @return array |
| 214 | 218 | */ |
| 215 | 219 | public function bulkDelete(Request $request, Customer $customer) |
| 216 | 220 | { |
| 217 | - // Get and sanitize customer_ids before validation | |
| 218 | 221 | $customerIds = $request->get('customer_ids', []); |
| 219 | 222 | $customerIds = is_array($customerIds) ? array_map('intval', $customerIds) : []; |
| 220 | 223 | |
| 221 | - // Filter out any zero values (from invalid input) | |
| 222 | 224 | $customerIds = array_filter($customerIds, function ($id) { |
| 223 | 225 | return $id > 0; |
| 224 | 226 | }); |
| 225 | 227 | |
| 228 | + $customerIds = array_values(array_unique($customerIds)); | |
| 229 | + | |
| 226 | 230 | $this->validate(['customer_ids' => $customerIds], [ |
| 227 | - 'customer_ids' => 'required|array|min:1', | |
| 231 | + 'customer_ids' => 'required|array|min:1|max:' . self::BULK_DELETE_LIMIT, | |
| 228 | 232 | 'customer_ids.*' => 'required|integer|exists:fs_persons,id' |
| 229 | 233 | ]); |
| 230 | 234 | |
| 231 | 235 | return $customer->bulkDeleteCustomers($customerIds); |
| @@ -231,11 +235,10 @@ | ||
| 231 | 235 | return $customer->bulkDeleteCustomers($customerIds); |
| 232 | 236 | } |
| 233 | 237 | |
| 234 | 238 | /** |
| 235 | - * addOrUpdateProfileImage method will update a customer avatar | |
| 236 | - * For a successful upload it's required to send file object, customer id and the user type(customer) | |
| 237 | 239 | * @param Request $request |
| 240 | + * @param AvatarUploder $avatarUploder | |
| 238 | 241 | * @return array |
| 239 | 242 | */ |
| 240 | 243 | public function addOrUpdateProfileImage(Request $request, AvatarUploder $avatarUploder) |
| 241 | 244 | { |
| @@ -250,17 +253,18 @@ | ||
| 250 | 253 | } |
| 251 | 254 | } |
| 252 | 255 | |
| 253 | 256 | /** |
| 254 | - * resetAvatar method will restore a customer avatar | |
| 255 | - * For a successful upload it's required to send file object, customer id and the user type(customer) | |
| 256 | - * @param Request $request | |
| 257 | - * @param $id | |
| 257 | + * No Customer type-hint: route-model binding runs before the policy, which would | |
| 258 | + * let unauthenticated callers probe customer IDs. Resolve after auth. | |
| 259 | + * | |
| 260 | + * @param int|string $customer | |
| 258 | 261 | * @return array |
| 259 | 262 | */ |
| 260 | - public function resetAvatar(Customer $customer) | |
| 263 | + public function resetAvatar($customer) | |
| 261 | 264 | { |
| 262 | 265 | try { |
| 266 | + $customer = Customer::findOrFail((int) $customer); | |
| 263 | 267 | $customer->restoreAvatar(); |
| 264 | 268 | |
| 265 | 269 | return [ |
| 266 | 270 | 'message' => __('Customer avatar reset to gravatar default', 'fluent-support'), |
| @@ -273,9 +277,14 @@ | ||
| 273 | 277 | } |
| 274 | 278 | |
| 275 | 279 | public function searchContact(Request $request) |
| 276 | 280 | { |
| 277 | - $search = $request->getSafe('search', 'sanitize_text_field'); | |
| 281 | + $search = trim($request->getSafe('search', 'sanitize_text_field')); | |
| 282 | + | |
| 283 | + // '*' is a WP_User_Query wildcard that survives sanitize_text_field. | |
| 284 | + // A lone '*' would list every user. | |
| 285 | + $search = trim(str_replace('*', '', $search)); | |
| 286 | + | |
| 278 | 287 | if (!$search) { |
| 279 | 288 | return $this->sendError([ |
| 280 | 289 | 'message' => __('Please provide search string', 'fluent-support') |
| 281 | 290 | ]); |
| @@ -282,9 +291,26 @@ | ||
| 282 | 291 | } |
| 283 | 292 | |
| 284 | 293 | $isEmail = is_email($search); |
| 285 | 294 | |
| 286 | - // search the existing customers first | |
| 295 | + // Stops walking the list one letter at a time. Emails match exactly, so no minimum. | |
| 296 | + if (!$isEmail && mb_strlen($search) < 3) { | |
| 297 | + return $this->sendError([ | |
| 298 | + 'message' => __('Please provide at least 3 characters to search', 'fluent-support') | |
| 299 | + ]); | |
| 300 | + } | |
| 301 | + | |
| 302 | + if (Helper::hitRateLimit('fs_contact_search_' . get_current_user_id(), 60, 5 * MINUTE_IN_SECONDS)) { | |
| 303 | + return $this->sendError([ | |
| 304 | + 'message' => __('Too many contact searches. Please try again in a few minutes.', 'fluent-support') | |
| 305 | + ], 429); | |
| 306 | + } | |
| 307 | + | |
| 308 | + // Escape LIKE wildcards rather than strip them: '_' is valid in emails. | |
| 309 | + global $wpdb; | |
| 310 | + $likeSearch = $wpdb->esc_like($search); | |
| 311 | + | |
| 312 | + // Customers first, then FluentCRM contacts, then WP users. | |
| 287 | 313 | if ($isEmail) { |
| 288 | 314 | $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 289 | 315 | ->where('email', $search) |
| 290 | 316 | ->get(); |
| @@ -289,9 +315,9 @@ | ||
| 289 | 315 | ->where('email', $search) |
| 290 | 316 | ->get(); |
| 291 | 317 | } else { |
| 292 | 318 | $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 293 | - ->searchBy($search) | |
| 319 | + ->searchBy($likeSearch) | |
| 294 | 320 | ->limit(10) |
| 295 | 321 | ->get(); |
| 296 | 322 | } |
| 297 | 323 | |
| @@ -304,9 +330,8 @@ | ||
| 304 | 330 | 'search' => $search |
| 305 | 331 | ]; |
| 306 | 332 | } |
| 307 | 333 | |
| 308 | - // If FluentCRM exist then let's search for | |
| 309 | 334 | if (defined('FLUENTCRM')) { |
| 310 | 335 | |
| 311 | 336 | if ($isEmail) { |
| 312 | 337 | $contacts = \FluentCrm\App\Models\Subscriber::where('email', $search) |
| @@ -313,9 +338,9 @@ | ||
| 313 | 338 | ->select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 314 | 339 | ->get(); |
| 315 | 340 | } else { |
| 316 | 341 | |
| 317 | - $contacts = \FluentCrm\App\Models\Subscriber::searchBy($search) | |
| 342 | + $contacts = \FluentCrm\App\Models\Subscriber::searchBy($likeSearch) | |
| 318 | 343 | ->select(['first_name', 'last_name', 'email', 'id', 'user_id']) |
| 319 | 344 | ->limit(10) |
| 320 | 345 | ->get(); |
| 321 | 346 | } |
| @@ -329,9 +354,8 @@ | ||
| 329 | 354 | ]; |
| 330 | 355 | } |
| 331 | 356 | } |
| 332 | 357 | |
| 333 | - // let's search from user's database | |
| 334 | 358 | $user_query = new \WP_User_Query(array('search' => $search, 'number' => 10)); |
| 335 | 359 | |
| 336 | 360 | $users = $user_query->get_results(); |
| 337 | 361 | |
| @@ -365,11 +389,10 @@ | ||
| 365 | 389 | |
| 366 | 390 | } |
| 367 | 391 | |
| 368 | 392 | /** |
| 369 | - * Sanitize request data for given fields. Uses Request::getSafe for known fields | |
| 370 | - * and falls back to sanitize_text_field for any other keys present in the raw request | |
| 371 | - * (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. | |
| 372 | 395 | * |
| 373 | 396 | * @param Request $request |
| 374 | 397 | * @param array $fieldsMap associative array field => sanitizer callable name |
| 375 | 398 | * @return array |
| @@ -377,14 +400,12 @@ | ||
| 377 | 400 | private function sanitizeRequestData(Request $request, array $fieldsMap) |
| 378 | 401 | { |
| 379 | 402 | $sanitized = []; |
| 380 | 403 | |
| 381 | - // Use getSafe for known fields | |
| 382 | 404 | foreach ($fieldsMap as $field => $sanitizer) { |
| 383 | 405 | $sanitized[$field] = $request->getSafe($field, $sanitizer); |
| 384 | 406 | } |
| 385 | 407 | |
| 386 | - // Now sanitize any other incoming keys to avoid unsanitized data | |
| 387 | 408 | $raw = $request->get(); |
| 388 | 409 | foreach ($raw as $key => $value) { |
| 389 | 410 | if (array_key_exists($key, $sanitized)) { |
| 390 | 411 | continue; |
| @@ -392,9 +413,8 @@ | ||
| 392 | 413 | |
| 393 | 414 | if (is_array($value)) { |
| 394 | 415 | $sanitized[$key] = array_map('sanitize_text_field', $value); |
| 395 | 416 | } else { |
| 396 | - // Fallback sanitizer for unknown fields | |
| 397 | 417 | $sanitized[$key] = is_string($value) ? sanitize_text_field($value) : $value; |
| 398 | 418 | } |
| 399 | 419 | } |
| 400 | 420 | |