PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
← All changes | app/Http/Controllers/CustomerController.php +383 -101 1.4.22.4.0 View file →
@@ -1,159 +1,441 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 -use FluentSupport\App\Models\Attachment;
6 -use FluentSupport\App\Models\Conversation;
5 +use FluentCrm\App\Models\Subscriber;
7 6 use FluentSupport\App\Models\Customer;
8 -use FluentSupport\App\Models\Ticket;
7 +use FluentSupport\Framework\Http\Request\Request;
8 +use FluentSupport\App\Services\AvatarUploder;
9 9 use FluentSupport\App\Services\Helper;
10 -use FluentSupport\App\Services\ProfileInfoService;
11 -use FluentSupport\Framework\Request\Request;
12 10 use FluentSupport\Framework\Support\Arr;
13 11
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
18 + */
14 19 class CustomerController extends Controller
15 20 {
16 - public function index(Request $request)
21 + /**
22 + * Maximum number of customers accepted by a single bulk-delete request.
23 + */
24 + const BULK_DELETE_LIMIT = 100;
25 +
26 + /**
27 + * index method will return the list of customers
28 + * @param Request $request
29 + * @param Customer $customer
30 + * @return array
31 + */
32 + public function index(Request $request, Customer $customer)
17 33 {
18 - $customersQuery = Customer::orderBy('id', 'DESC')
19 - ->orderBy($request->get('order_by', 'id'), $request->get('order_type', 'ASC'));
34 + return [
35 + 'customers' => $customer->getCustomers($request->getSafe('search', 'sanitize_text_field'), $request->getSafe('status', 'sanitize_text_field')),
36 + ];
37 + }
20 38
21 - if ($request->get('search')) {
22 - $customersQuery->searchBy($request->get('search'));
23 - }
39 + public function customerField (Request $request,Customer $customer, $customer_id) {
24 40
41 + $userID = $request->getSafe('user_id', 'intval');
42 + return[
43 + 'customerField' => $customer->getCustomerField($customer_id,$userID)
44 + ];
45 + }
25 46
26 - $status = $request->get('status');
27 - if ($status && $status != 'all') {
28 - $customersQuery->filterByStatues([$status]);
29 - }
30 47
31 - $customers = $customersQuery->paginate();
48 + /**
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
51 + * @param Request $request
52 + * @param Customer $customer
53 + * @param $customer_id
54 + * @return array
55 + */
56 + public function getCustomer(Request $request, Customer $customer, $customer_id)
57 + {
58 + $with = $request->get('with', null);
59 + $with = is_array($with) ? array_map('sanitize_key', $with) : [];
32 60
33 - foreach ($customers as $customer) {
34 - $customer->total_tickets = $customer->getTicketCounts();
35 - $customer->total_responses = $customer->getResponseCounts();
36 - if ($customer->user_id) {
37 - $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
38 - }
39 - }
61 + return $customer->getCustomer($customer_id, $with);
62 + }
40 63
64 + /**
65 + * Create method will create new customer
66 + * @param Request $request
67 + * @param Customer $customer
68 + * @return array
69 + * @throws \FluentSupport\Framework\Validator\ValidationException
70 + */
71 + public function create(Request $request, Customer $customer)
72 + {
73 + // Define expected fields with their sanitizers
74 + $fields = [
75 + 'id' => 'intval',
76 + 'customer_id' => 'intval',
77 + 'avatar' => 'esc_url_raw',
78 + 'person_type' => 'sanitize_text_field',
79 + 'hash' => 'sanitize_text_field',
80 + 'description' => 'sanitize_text_field',
81 + 'photo' => 'esc_url_raw',
82 + 'email' => 'sanitize_email',
83 + 'first_name' => 'sanitize_text_field',
84 + 'last_name' => 'sanitize_text_field',
85 + 'title' => 'sanitize_text_field',
86 + 'user_id' => 'intval',
87 + 'remote_uid' => 'sanitize_text_field',
88 + 'status' => 'sanitize_text_field',
89 + 'address_line_1' => 'sanitize_textarea_field',
90 + 'address_line_2' => 'sanitize_textarea_field',
91 + 'city' => 'sanitize_text_field',
92 + 'state' => 'sanitize_text_field',
93 + 'zip' => 'sanitize_text_field',
94 + 'country' => 'sanitize_text_field',
95 + 'note' => 'sanitize_textarea_field',
96 + 'ip_address' => 'sanitize_text_field',
97 + 'last_ip_address' => 'sanitize_text_field',
98 + ];
99 +
100 + $data = $this->sanitizeRequestData($request, $fields);
101 +
102 + $data = $this->validate($data, [
103 + 'email' => 'required|email|unique:fs_persons',
104 + 'first_name' => 'required',
105 + 'last_name' => 'nullable|string',
106 + 'title' => 'nullable|string',
107 + 'user_id' => 'nullable|integer',
108 + 'remote_uid' => 'nullable|string',
109 + 'status' => 'nullable|string',
110 + 'address_line_1' => 'nullable|string',
111 + 'address_line_2' => 'nullable|string',
112 + 'city' => 'nullable|string',
113 + 'state' => 'nullable|string',
114 + 'zip' => 'nullable|string',
115 + 'country' => 'nullable|string',
116 + 'note' => 'nullable|string',
117 + 'ip_address' => 'nullable|string',
118 + 'last_ip_address' => 'nullable|string',
119 + ]);
120 +
41 121 return [
42 - 'customers' => $customers,
122 + 'message' => __('Customer has been added', 'fluent-support'),
123 + 'customer' => $customer->createCustomer($data)
43 124 ];
44 125 }
45 126
46 - public function getCustomer(Request $request, $customerId)
127 + /**
128 + * update method will update existing customer by customer id
129 + * @param Request $request
130 + * @param Customer $customer
131 + * @param $customerId
132 + * @return array
133 + * @throws \FluentSupport\Framework\Validator\ValidationException
134 + */
135 + public function update(Request $request, Customer $customer, $customer_id)
47 136 {
48 - $customer = Customer::findOrFail($customerId);
137 + // Sanitize only allowed fields and also sanitize any extra fields from hooks
138 + $fields = [
139 + 'id' => 'intval',
140 + 'customer_id' => 'intval',
141 + 'avatar' => 'esc_url_raw',
142 + 'person_type' => 'sanitize_text_field',
143 + 'hash' => 'sanitize_text_field',
144 + 'description' => 'sanitize_text_field',
145 + 'photo' => 'esc_url_raw',
146 + 'email' => 'sanitize_email',
147 + 'first_name' => 'sanitize_text_field',
148 + 'last_name' => 'sanitize_text_field',
149 + 'title' => 'sanitize_text_field',
150 + 'user_id' => 'intval',
151 + 'remote_uid' => 'sanitize_text_field',
152 + 'status' => 'sanitize_text_field',
153 + 'address_line_1' => 'sanitize_textarea_field',
154 + 'address_line_2' => 'sanitize_textarea_field',
155 + 'city' => 'sanitize_text_field',
156 + 'state' => 'sanitize_text_field',
157 + 'zip' => 'sanitize_text_field',
158 + 'country' => 'sanitize_text_field',
159 + 'note' => 'sanitize_textarea_field',
160 + 'ip_address' => 'sanitize_text_field',
161 + 'last_ip_address' => 'sanitize_text_field',
162 + ];
49 163
50 - $data = [
51 - 'customer' => $customer
52 - ];
164 + $data = $this->sanitizeRequestData($request, $fields);
53 165
54 - $with = $request->get('with', []);
166 + $data = $this->validate($data, [
167 + 'email' => 'required|email',
168 + 'first_name' => 'required',
169 + 'last_name' => 'nullable|string',
170 + 'title' => 'nullable|string',
171 + 'user_id' => 'nullable|integer',
172 + 'remote_uid' => 'nullable|string',
173 + 'status' => 'nullable|string',
174 + 'address_line_1' => 'nullable|string',
175 + 'address_line_2' => 'nullable|string',
176 + 'city' => 'nullable|string',
177 + 'state' => 'nullable|string',
178 + 'zip' => 'nullable|string',
179 + 'country' => 'nullable|string',
180 + 'note' => 'nullable|string',
181 + 'ip_address' => 'nullable|string',
182 + 'last_ip_address' => 'nullable|string',
183 + ]);
55 184
56 - if (in_array('widgets', $with)) {
57 - $data['widgets'] = ProfileInfoService::getProfileExtraWidgets($customer);
185 + try {
186 + return [
187 + 'message' => __('Customer has been updated', 'fluent-support'),
188 + 'customer' => $customer->updateCustomer($customer_id, $data)
189 + ];
190 + } catch (\Exception $e) {
191 + return $this->sendError([
192 + 'message' => Helper::getSafeErrorMessage($e),
193 + 'errors' => [
194 + 'email' => [
195 + 'unique' => __('Email address has been assigned to other customer', 'fluent-support'),
196 + ]
197 + ]
198 + ], 423);
58 199 }
200 + }
59 201
60 - if (in_array('tickets', $with)) {
61 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
62 - ->where('customer_id', $customer->id)
63 - ->orderBy('id', 'DESC')
64 - ->limit(20)
65 - ->get();
66 - }
202 + /**
203 + * delete method will delete a customer and all tickets by that customer
204 + * @param Request $request
205 + * @param Customer $customer
206 + * @param int $customerId
207 + * @return array
208 + */
209 + public function delete(Request $request, Customer $customer, $customer_id)
210 + {
211 + return $customer->deleteCustomer($customer_id);
212 + }
67 213
68 - if(in_array('fluentcrm_profile', $with)) {
69 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($customer);
214 + /**
215 + * bulkDelete method will delete multiple customers and all their tickets
216 + * @param Request $request
217 + * @param Customer $customer
218 + * @return array
219 + */
220 + public function bulkDelete(Request $request, Customer $customer)
221 + {
222 + // Get and sanitize customer_ids before validation
223 + $customerIds = $request->get('customer_ids', []);
224 + $customerIds = is_array($customerIds) ? array_map('intval', $customerIds) : [];
225 +
226 + // Filter out any zero values (from invalid input)
227 + $customerIds = array_filter($customerIds, function ($id) {
228 + return $id > 0;
229 + });
230 +
231 + $customerIds = array_values(array_unique($customerIds));
232 +
233 + // Each id fans out into a full cascade delete (tickets, conversations,
234 + // attachments), so an unbounded batch means an unbounded request.
235 + $this->validate(['customer_ids' => $customerIds], [
236 + 'customer_ids' => 'required|array|min:1|max:' . self::BULK_DELETE_LIMIT,
237 + 'customer_ids.*' => 'required|integer|exists:fs_persons,id'
238 + ]);
239 +
240 + return $customer->bulkDeleteCustomers($customerIds);
241 + }
242 +
243 + /**
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 + * @param Request $request
247 + * @return array
248 + */
249 + public function addOrUpdateProfileImage(Request $request, AvatarUploder $avatarUploder)
250 + {
251 + try {
252 + return $avatarUploder->addOrUpdateProfileImage($request->files(), $request->getSafe('customer_id', 'intval'), 'customer');
253 + } catch (\Exception $e) {
254 + return $this->sendError([
255 + 'message' => Helper::getSafeErrorMessage($e),
256 + ],
257 + $e->getCode()
258 + );
70 259 }
260 + }
71 261
72 - return $data;
262 + /**
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)
265 + *
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 + * @param int|string $customer
270 + * @return array
271 + */
272 + public function resetAvatar($customer)
273 + {
274 + try {
275 + $customer = Customer::findOrFail((int) $customer);
276 + $customer->restoreAvatar();
73 277
278 + return [
279 + 'message' => __('Customer avatar reset to gravatar default', 'fluent-support'),
280 + ];
281 + } catch (\Exception $e) {
282 + return [
283 + 'message' => Helper::getSafeErrorMessage($e)
284 + ];
285 + }
74 286 }
75 287
76 - public function create(Request $request)
288 + public function searchContact(Request $request)
77 289 {
78 - $data = $request->all();
79 - $this->validate($data, [
80 - 'email' => 'required|email|unique:fs_persons'
81 - ]);
290 + $search = trim($request->getSafe('search', 'sanitize_text_field'));
82 291
83 - $email = $data['email'];
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.
295 + $search = trim(str_replace('*', '', $search));
84 296
85 - $data = Arr::only($data, (new Customer)->getFillable());
297 + if (!$search) {
298 + return $this->sendError([
299 + 'message' => __('Please provide search string', 'fluent-support')
300 + ]);
301 + }
86 302
87 - $user = get_user_by('email', $email);
303 + $isEmail = is_email($search);
88 304
89 - if ($user) {
90 - $data['user_id'] = $user->ID;
91 - if (empty($data['first_name'])) {
92 - $data['first_name'] = $user->first_name;
93 - }
94 - if (empty($data['last_name'])) {
95 - $data['last_name'] = $user->last_name;
96 - }
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.
307 + if (!$isEmail && mb_strlen($search) < 3) {
308 + return $this->sendError([
309 + 'message' => __('Please provide at least 3 characters to search', 'fluent-support')
310 + ]);
97 311 }
98 312
99 - $customer = Customer::create($data);
313 + if (Helper::hitRateLimit('fs_contact_search_' . get_current_user_id(), 60, 5 * MINUTE_IN_SECONDS)) {
314 + return $this->sendError([
315 + 'message' => __('Too many contact searches. Please try again in a few minutes.', 'fluent-support')
316 + ], 429);
317 + }
100 318
101 - return [
102 - 'message' => __('Customer has been added', 'fluent-support'),
103 - 'customer' => $customer
104 - ];
105 - }
319 + // '%' and '_' are LIKE wildcards for the customer and CRM scopes below.
320 + // Escape rather than strip: underscores are legitimate in emails.
321 + global $wpdb;
322 + $likeSearch = $wpdb->esc_like($search);
106 323
107 - public function update(Request $request, $customerId)
108 - {
109 - $customer = Customer::findOrFail($customerId);
110 - $data = $request->all();
111 - $this->validate($data, [
112 - 'email' => 'required|email',
113 - 'first_name' => 'required'
114 - ]);
324 + // search the existing customers first
325 + if ($isEmail) {
326 + $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id'])
327 + ->where('email', $search)
328 + ->get();
329 + } else {
330 + $customers = Customer::select(['first_name', 'last_name', 'email', 'id', 'user_id'])
331 + ->searchBy($likeSearch)
332 + ->limit(10)
333 + ->get();
334 + }
115 335
116 - if ($otherCustomer = Customer::where('id', '!=', $customerId)->where('email', $data['email'])->first()) {
117 - return $this->sendError([
118 - 'message' => __('Another Customer has same email address', 'fluent-support'),
119 - 'errors' => [
120 - 'email' => [
121 - 'unique' => __('Email address has been assigned to other customer', 'fluent-support')
122 - ]
123 - ]
124 - ], 423);
336 + if (!$customers->isEmpty()) {
337 + return [
338 + 'type' => 'search_result',
339 + 'provider' => 'fluent_support',
340 + 'data' => $customers,
341 + 'is_email' => $isEmail,
342 + 'search' => $search
343 + ];
125 344 }
126 345
127 - $validKeys = (new Customer)->getFillable();
128 - unset($validKeys['hash']);
129 - unset($validKeys['user_id']);
346 + // If FluentCRM exist then let's search for
347 + if (defined('FLUENTCRM')) {
130 348
131 - $updateData = Arr::only($data, $validKeys);
349 + if ($isEmail) {
350 + $contacts = \FluentCrm\App\Models\Subscriber::where('email', $search)
351 + ->select(['first_name', 'last_name', 'email', 'id', 'user_id'])
352 + ->get();
353 + } else {
132 354
133 - $user = get_user_by('email', $data['email']);
355 + $contacts = \FluentCrm\App\Models\Subscriber::searchBy($likeSearch)
356 + ->select(['first_name', 'last_name', 'email', 'id', 'user_id'])
357 + ->limit(10)
358 + ->get();
359 + }
134 360
135 - if ($user) {
136 - $updateData['user_id'] = $user->ID;
361 + if (!$contacts->isEmpty()) {
362 + return [
363 + 'type' => 'search_result',
364 + 'provider' => 'fluent_crm',
365 + 'data' => $contacts,
366 + 'is_email' => $isEmail
367 + ];
368 + }
137 369 }
138 370
139 - Customer::where('id', $customer->id)
140 - ->update($updateData);
371 + // let's search from user's database
372 + $user_query = new \WP_User_Query(array('search' => $search, 'number' => 10));
141 373
374 + $users = $user_query->get_results();
375 +
376 + if ($users) {
377 + $formattedUsers = [];
378 +
379 + foreach ($users as $user) {
380 + $formattedUsers[] = [
381 + 'id' => $user->ID,
382 + 'first_name' => $user->first_name,
383 + 'last_name' => $user->last_name,
384 + 'user_id' => $user->ID,
385 + 'email' => $user->user_email
386 + ];
387 + }
388 +
389 + return [
390 + 'type' => 'search_result',
391 + 'provider' => 'wp_users',
392 + 'data' => $formattedUsers,
393 + 'is_email' => $isEmail
394 + ];
395 + }
396 +
142 397 return [
143 - 'message' => __('Customer has been updated', 'fluent-support'),
144 - 'customer' => Customer::findOrFail($customerId)
398 + 'type' => 'none',
399 + 'provider' => 'none',
400 + 'data' => [],
401 + 'is_email' => $isEmail
145 402 ];
403 +
146 404 }
147 405
148 - public function delete(Request $request, $customerId)
406 + /**
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).
410 + *
411 + * @param Request $request
412 + * @param array $fieldsMap associative array field => sanitizer callable name
413 + * @return array
414 + */
415 + private function sanitizeRequestData(Request $request, array $fieldsMap)
149 416 {
150 - $customer = Customer::findOrFail($customerId);
151 - Ticket::where('customer_id', $customerId)->delete();
152 - Attachment::where('person_id', $customerId)->delete();
153 - Conversation::where('person_id', $customerId)->delete();
154 - $customer->delete();
155 - return [
156 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
157 - ];
417 + $sanitized = [];
418 +
419 + // Use getSafe for known fields
420 + foreach ($fieldsMap as $field => $sanitizer) {
421 + $sanitized[$field] = $request->getSafe($field, $sanitizer);
422 + }
423 +
424 + // Now sanitize any other incoming keys to avoid unsanitized data
425 + $raw = $request->get();
426 + foreach ($raw as $key => $value) {
427 + if (array_key_exists($key, $sanitized)) {
428 + continue;
429 + }
430 +
431 + if (is_array($value)) {
432 + $sanitized[$key] = array_map('sanitize_text_field', $value);
433 + } else {
434 + // Fallback sanitizer for unknown fields
435 + $sanitized[$key] = is_string($value) ? sanitize_text_field($value) : $value;
436 + }
437 + }
438 +
439 + return $sanitized;
158 440 }
159 441 }