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 +384 -97 1.4.02.4.0 View file →
@@ -1,154 +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;
9 -use FluentSupport\App\Services\ProfileInfoService;
10 -use FluentSupport\Framework\Request\Request;
7 +use FluentSupport\Framework\Http\Request\Request;
8 +use FluentSupport\App\Services\AvatarUploder;
9 +use FluentSupport\App\Services\Helper;
11 10 use FluentSupport\Framework\Support\Arr;
12 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 + */
13 19 class CustomerController extends Controller
14 20 {
15 - 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)
16 33 {
17 - $customersQuery = Customer::orderBy('id', 'DESC')
18 - ->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 + }
19 38
20 - if ($request->get('search')) {
21 - $customersQuery->searchBy($request->get('search'));
22 - }
39 + public function customerField (Request $request,Customer $customer, $customer_id) {
23 40
41 + $userID = $request->getSafe('user_id', 'intval');
42 + return[
43 + 'customerField' => $customer->getCustomerField($customer_id,$userID)
44 + ];
45 + }
24 46
25 - $status = $request->get('status');
26 - if ($status && $status != 'all') {
27 - $customersQuery->filterByStatues([$status]);
28 - }
29 47
30 - $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) : [];
31 60
32 - foreach ($customers as $customer) {
33 - $customer->total_tickets = $customer->getTicketCounts();
34 - $customer->total_responses = $customer->getResponseCounts();
35 - if ($customer->user_id) {
36 - $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
37 - }
38 - }
61 + return $customer->getCustomer($customer_id, $with);
62 + }
39 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 +
40 121 return [
41 - 'customers' => $customers,
122 + 'message' => __('Customer has been added', 'fluent-support'),
123 + 'customer' => $customer->createCustomer($data)
42 124 ];
43 125 }
44 126
45 - 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)
46 136 {
47 - $customer = Customer::findOrFail($customerId);
48 -
49 - $data = [
50 - 'customer' => $customer
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',
51 162 ];
52 163
53 - $with = $request->get('with', []);
164 + $data = $this->sanitizeRequestData($request, $fields);
54 165
55 - if (in_array('widgets', $with)) {
56 - $data['widgets'] = ProfileInfoService::getProfileExtraWidgets($customer);
57 - }
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 + ]);
58 184
59 - if (in_array('tickets', $with)) {
60 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
61 - ->where('customer_id', $customer->id)
62 - ->orderBy('id', 'DESC')
63 - ->limit(20)
64 - ->get();
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);
65 199 }
200 + }
66 201
67 - return $data;
68 -
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);
69 212 }
70 213
71 - public function create(Request $request)
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)
72 221 {
73 - $data = $request->all();
74 - $this->validate($data, [
75 - 'email' => 'required|email|unique:fs_persons'
76 - ]);
222 + // Get and sanitize customer_ids before validation
223 + $customerIds = $request->get('customer_ids', []);
224 + $customerIds = is_array($customerIds) ? array_map('intval', $customerIds) : [];
77 225
78 - $email = $data['email'];
226 + // Filter out any zero values (from invalid input)
227 + $customerIds = array_filter($customerIds, function ($id) {
228 + return $id > 0;
229 + });
79 230
80 - $data = Arr::only($data, (new Customer)->getFillable());
231 + $customerIds = array_values(array_unique($customerIds));
81 232
82 - $user = get_user_by('email', $email);
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 + ]);
83 239
84 - if ($user) {
85 - $data['user_id'] = $user->ID;
86 - if (empty($data['first_name'])) {
87 - $data['first_name'] = $user->first_name;
88 - }
89 - if (empty($data['last_name'])) {
90 - $data['last_name'] = $user->last_name;
91 - }
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 + );
92 259 }
260 + }
93 261
94 - $customer = Customer::create($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();
95 277
96 - return [
97 - 'message' => __('Customer has been added', 'fluent-support'),
98 - 'customer' => $customer
99 - ];
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 + }
100 286 }
101 287
102 - public function update(Request $request, $customerId)
288 + public function searchContact(Request $request)
103 289 {
104 - $customer = Customer::findOrFail($customerId);
105 - $data = $request->all();
106 - $this->validate($data, [
107 - 'email' => 'required|email',
108 - 'first_name' => 'required'
109 - ]);
290 + $search = trim($request->getSafe('search', 'sanitize_text_field'));
110 291
111 - if ($otherCustomer = Customer::where('id', '!=', $customerId)->where('email', $data['email'])->first()) {
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));
296 +
297 + if (!$search) {
112 298 return $this->sendError([
113 - 'message' => __('Another Customer has same email address', 'fluent-support'),
114 - 'errors' => [
115 - 'email' => [
116 - 'unique' => __('Email address has been assigned to other customer', 'fluent-support')
117 - ]
118 - ]
119 - ], 423);
299 + 'message' => __('Please provide search string', 'fluent-support')
300 + ]);
120 301 }
121 302
122 - $validKeys = (new Customer)->getFillable();
123 - unset($validKeys['hash']);
124 - unset($validKeys['user_id']);
303 + $isEmail = is_email($search);
125 304
126 - $updateData = Arr::only($data, $validKeys);
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 + ]);
311 + }
127 312
128 - $user = get_user_by('email', $data['email']);
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 + }
129 318
130 - if ($user) {
131 - $updateData['user_id'] = $user->ID;
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);
323 +
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();
132 334 }
133 335
134 - Customer::where('id', $customer->id)
135 - ->update($updateData);
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 + ];
344 + }
136 345
346 + // If FluentCRM exist then let's search for
347 + if (defined('FLUENTCRM')) {
348 +
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 {
354 +
355 + $contacts = \FluentCrm\App\Models\Subscriber::searchBy($likeSearch)
356 + ->select(['first_name', 'last_name', 'email', 'id', 'user_id'])
357 + ->limit(10)
358 + ->get();
359 + }
360 +
361 + if (!$contacts->isEmpty()) {
362 + return [
363 + 'type' => 'search_result',
364 + 'provider' => 'fluent_crm',
365 + 'data' => $contacts,
366 + 'is_email' => $isEmail
367 + ];
368 + }
369 + }
370 +
371 + // let's search from user's database
372 + $user_query = new \WP_User_Query(array('search' => $search, 'number' => 10));
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 +
137 397 return [
138 - 'message' => __('Customer has been updated', 'fluent-support'),
139 - 'customer' => Customer::findOrFail($customerId)
398 + 'type' => 'none',
399 + 'provider' => 'none',
400 + 'data' => [],
401 + 'is_email' => $isEmail
140 402 ];
403 +
141 404 }
142 405
143 - 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)
144 416 {
145 - $customer = Customer::findOrFail($customerId);
146 - Ticket::where('customer_id', $customerId)->delete();
147 - Attachment::where('person_id', $customerId)->delete();
148 - Conversation::where('person_id', $customerId)->delete();
149 - $customer->delete();
150 - return [
151 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
152 - ];
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;
153 440 }
154 441 }