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 +373 -120 1.5.42.4.0 View file →
@@ -1,188 +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\Includes\FileSystem;
11 -use FluentSupport\App\Services\ProfileInfoService;
12 -use FluentSupport\Framework\Request\Request;
13 10 use FluentSupport\Framework\Support\Arr;
14 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 + */
15 19 class CustomerController extends Controller
16 20 {
17 - 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)
18 33 {
19 - $customersQuery = Customer::orderBy('id', 'DESC')
20 - ->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 + }
21 38
22 - if ($request->get('search')) {
23 - $customersQuery->searchBy($request->get('search'));
24 - }
39 + public function customerField (Request $request,Customer $customer, $customer_id) {
25 40
41 + $userID = $request->getSafe('user_id', 'intval');
42 + return[
43 + 'customerField' => $customer->getCustomerField($customer_id,$userID)
44 + ];
45 + }
26 46
27 - $status = $request->get('status');
28 - if ($status && $status != 'all') {
29 - $customersQuery->filterByStatues([$status]);
30 - }
31 47
32 - $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) : [];
33 60
34 - foreach ($customers as $customer) {
35 - $customer->total_tickets = $customer->getTicketCounts();
36 - $customer->total_responses = $customer->getResponseCounts();
37 - if ($customer->user_id) {
38 - $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
39 - }
40 - }
61 + return $customer->getCustomer($customer_id, $with);
62 + }
41 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 +
42 121 return [
43 - 'customers' => $customers,
122 + 'message' => __('Customer has been added', 'fluent-support'),
123 + 'customer' => $customer->createCustomer($data)
44 124 ];
45 125 }
46 126
47 - 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)
48 136 {
49 - $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 + ];
50 163
51 - $data = [
52 - 'customer' => $customer
53 - ];
164 + $data = $this->sanitizeRequestData($request, $fields);
54 165
55 - $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 + ]);
56 184
57 - if (in_array('widgets', $with)) {
58 - $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);
59 199 }
200 + }
60 201
61 - if (in_array('tickets', $with)) {
62 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
63 - ->where('customer_id', $customer->id)
64 - ->orderBy('id', 'DESC')
65 - ->limit(20)
66 - ->get();
67 - }
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 + }
68 213
69 - if(in_array('fluentcrm_profile', $with)) {
70 - $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 + );
71 259 }
260 + }
72 261
73 - 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();
74 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 + }
75 286 }
76 287
77 - public function create(Request $request)
288 + public function searchContact(Request $request)
78 289 {
79 - $data = $request->all();
80 - $this->validate($data, [
81 - 'email' => 'required|email|unique:fs_persons'
82 - ]);
290 + $search = trim($request->getSafe('search', 'sanitize_text_field'));
83 291
84 - $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));
85 296
86 - $data = Arr::only($data, (new Customer)->getFillable());
297 + if (!$search) {
298 + return $this->sendError([
299 + 'message' => __('Please provide search string', 'fluent-support')
300 + ]);
301 + }
87 302
88 - $user = get_user_by('email', $email);
303 + $isEmail = is_email($search);
89 304
90 - if ($user) {
91 - $data['user_id'] = $user->ID;
92 - if (empty($data['first_name'])) {
93 - $data['first_name'] = $user->first_name;
94 - }
95 - if (empty($data['last_name'])) {
96 - $data['last_name'] = $user->last_name;
97 - }
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 + ]);
98 311 }
99 312
100 - $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 + }
101 318
102 - return [
103 - 'message' => __('Customer has been added', 'fluent-support'),
104 - 'customer' => $customer
105 - ];
106 - }
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);
107 323
108 - public function update(Request $request, $customerId)
109 - {
110 - $customer = Customer::findOrFail($customerId);
111 - $data = $request->all();
112 - $this->validate($data, [
113 - 'email' => 'required|email',
114 - 'first_name' => 'required'
115 - ]);
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 + }
116 335
117 - if ($otherCustomer = Customer::where('id', '!=', $customerId)->where('email', $data['email'])->first()) {
118 - return $this->sendError([
119 - 'message' => __('Another Customer has same email address', 'fluent-support'),
120 - 'errors' => [
121 - 'email' => [
122 - 'unique' => __('Email address has been assigned to other customer', 'fluent-support')
123 - ]
124 - ]
125 - ], 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 + ];
126 344 }
127 345
128 - $validKeys = (new Customer)->getFillable();
129 - unset($validKeys['hash']);
130 - unset($validKeys['user_id']);
346 + // If FluentCRM exist then let's search for
347 + if (defined('FLUENTCRM')) {
131 348
132 - $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 {
133 354
134 - $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 + }
135 360
136 - if ($user) {
137 - $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 + }
138 369 }
139 370
140 - Customer::where('id', $customer->id)
141 - ->update($updateData);
371 + // let's search from user's database
372 + $user_query = new \WP_User_Query(array('search' => $search, 'number' => 10));
142 373
143 - return [
144 - 'message' => __('Customer has been updated', 'fluent-support'),
145 - 'customer' => Customer::findOrFail($customerId)
146 - ];
147 - }
374 + $users = $user_query->get_results();
148 375
149 - public function delete(Request $request, $customerId)
150 - {
151 - $customer = Customer::findOrFail($customerId);
376 + if ($users) {
377 + $formattedUsers = [];
152 378
153 - $tickets = Ticket::where('customer_id', $customer->id)->get();
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 + }
154 388
155 - foreach ($tickets as $ticket) {
156 - $ticket->deleteTicket();
389 + return [
390 + 'type' => 'search_result',
391 + 'provider' => 'wp_users',
392 + 'data' => $formattedUsers,
393 + 'is_email' => $isEmail
394 + ];
157 395 }
158 396
159 - $customer->delete();
160 397 return [
161 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
398 + 'type' => 'none',
399 + 'provider' => 'none',
400 + 'data' => [],
401 + 'is_email' => $isEmail
162 402 ];
403 +
163 404 }
164 405
165 - public function addOrUpdateProfileImage(Request $request)
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)
166 416 {
167 - $customer = Customer::findOrFail($request->get('customer_id'));
417 + $sanitized = [];
168 418
169 - $uploadedImage = FileSystem::setSubDir('customer_avatars')->put($request->files());
419 + // Use getSafe for known fields
420 + foreach ($fieldsMap as $field => $sanitizer) {
421 + $sanitized[$field] = $request->getSafe($field, $sanitizer);
422 + }
170 423
171 - if($avatar = $uploadedImage[0]['url']){
172 - $customer->avatar = $avatar;
173 - $customer->save();
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 + }
174 430
175 - return[
176 - 'message' => __('Profile picture has been updated successfully', 'fluent-support'),
177 - 'image' => $customer->avatar,
178 - 'customer' => $customer
179 - ];
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 + }
180 437 }
181 438
182 - else{
183 - return $this->sendError([
184 - 'message' => __('Something went wrong while updating the profile picture', 'fluent-support')
185 - ]);
186 - }
439 + return $sanitized;
187 440 }
188 441 }