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 +333 -144 1.5.52.4.0 View file →
@@ -1,16 +1,13 @@
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
15 12 /**
16 13 * CustomerController class for REST API
@@ -21,43 +18,30 @@
21 18 */
22 19 class CustomerController extends Controller
23 20 {
24 21 /**
22 + * Maximum number of customers accepted by a single bulk-delete request.
23 + */
24 + const BULK_DELETE_LIMIT = 100;
25 +
26 + /**
25 27 * index method will return the list of customers
26 28 * @param Request $request
29 + * @param Customer $customer
27 30 * @return array
28 31 */
29 - public function index(Request $request)
32 + public function index(Request $request, Customer $customer)
30 33 {
31 - //Add order by selected by suer
32 - $customersQuery = Customer::orderBy('id', 'DESC')
33 - ->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 + }
34 38
35 - //Filter query based on the search item
36 - if ($request->get('search')) {
37 - $customersQuery->searchBy($request->get('search'));
38 - }
39 + public function customerField (Request $request,Customer $customer, $customer_id) {
39 40
40 - $status = $request->get('status');
41 - //Filter customer by selected status
42 - if ($status && $status != 'all') {
43 - $customersQuery->filterByStatues([$status]);
44 - }
45 -
46 - $customers = $customersQuery->paginate();
47 -
48 - //Get total ticket and responses in ticket by individual customer
49 - foreach ($customers as $customer) {
50 - $customer->total_tickets = $customer->getTicketCounts();
51 - $customer->total_responses = $customer->getResponseCounts();
52 - if ($customer->user_id) {
53 - //Get profile link, if they are WP user
54 - $customer->user_profile = admin_url('user-edit.php?user_id=' . $customer->user_id);
55 - }
56 - }
57 -
58 - return [
59 - 'customers' => $customers,
41 + $userID = $request->getSafe('user_id', 'intval');
42 + return[
43 + 'customerField' => $customer->getCustomerField($customer_id,$userID)
60 44 ];
61 45 }
62 46
63 47
@@ -64,75 +48,80 @@
64 48 /**
65 49 * getCustomer method will return individual customer information by customer id
66 50 * This function will also get information about extra widgets, tickets and Fluent CRM
67 51 * @param Request $request
68 - * @param $customerId
52 + * @param Customer $customer
53 + * @param $customer_id
69 54 * @return array
70 55 */
71 - public function getCustomer(Request $request, $customerId)
56 + public function getCustomer(Request $request, Customer $customer, $customer_id)
72 57 {
73 - $customer = Customer::findOrFail($customerId);
58 + $with = $request->get('with', null);
59 + $with = is_array($with) ? array_map('sanitize_key', $with) : [];
74 60
75 - $data = [
76 - 'customer' => $customer
77 - ];
78 -
79 - $with = $request->get('with', []);
80 -
81 - if (in_array('widgets', $with)) {
82 - $data['widgets'] = ProfileInfoService::getProfileExtraWidgets($customer);
83 - }
84 -
85 - if (in_array('tickets', $with)) {
86 - $data['tickets'] = Ticket::select(['id', 'title', 'status', 'customer_id', 'created_at'])
87 - ->where('customer_id', $customer->id)
88 - ->orderBy('id', 'DESC')
89 - ->limit(20)
90 - ->get();
91 - }
92 -
93 - if(in_array('fluentcrm_profile', $with)) {
94 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($customer);
95 - }
96 -
97 - return $data;
98 -
61 + return $customer->getCustomer($customer_id, $with);
99 62 }
100 63
101 64 /**
102 65 * Create method will create new customer
103 66 * @param Request $request
67 + * @param Customer $customer
104 68 * @return array
105 69 * @throws \FluentSupport\Framework\Validator\ValidationException
106 70 */
107 - public function create(Request $request)
71 + public function create(Request $request, Customer $customer)
108 72 {
109 - $data = $request->all();
110 - $this->validate($data, [
111 - 'email' => 'required|email|unique:fs_persons'
112 - ]);
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 + ];
113 99
114 - $email = $data['email'];
100 + $data = $this->sanitizeRequestData($request, $fields);
115 101
116 - $data = Arr::only($data, (new Customer)->getFillable());
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 + ]);
117 120
118 - $user = get_user_by('email', $email);
119 -
120 - if ($user) {
121 - $data['user_id'] = $user->ID;
122 - if (empty($data['first_name'])) {
123 - $data['first_name'] = $user->first_name;
124 - }
125 - if (empty($data['last_name'])) {
126 - $data['last_name'] = $user->last_name;
127 - }
128 - }
129 -
130 - $customer = Customer::create($data);
131 -
132 121 return [
133 122 'message' => __('Customer has been added', 'fluent-support'),
134 - 'customer' => $customer
123 + 'customer' => $customer->createCustomer($data)
135 124 ];
136 125 }
137 126
138 127 /**
@@ -137,116 +126,316 @@
137 126
138 127 /**
139 128 * update method will update existing customer by customer id
140 129 * @param Request $request
130 + * @param Customer $customer
141 131 * @param $customerId
142 132 * @return array
143 133 * @throws \FluentSupport\Framework\Validator\ValidationException
144 134 */
145 - public function update(Request $request, $customerId)
135 + public function update(Request $request, Customer $customer, $customer_id)
146 136 {
147 - $customer = Customer::findOrFail($customerId);
148 - $data = $request->all();
149 - $this->validate($data, [
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 + ];
163 +
164 + $data = $this->sanitizeRequestData($request, $fields);
165 +
166 + $data = $this->validate($data, [
150 167 'email' => 'required|email',
151 - 'first_name' => 'required'
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',
152 183 ]);
153 184
154 - if ($otherCustomer = Customer::where('id', '!=', $customerId)->where('email', $data['email'])->first()) {
185 + try {
186 + return [
187 + 'message' => __('Customer has been updated', 'fluent-support'),
188 + 'customer' => $customer->updateCustomer($customer_id, $data)
189 + ];
190 + } catch (\Exception $e) {
155 191 return $this->sendError([
156 - 'message' => __('Another Customer has same email address', 'fluent-support'),
192 + 'message' => Helper::getSafeErrorMessage($e),
157 193 'errors' => [
158 194 'email' => [
159 - 'unique' => __('Email address has been assigned to other customer', 'fluent-support')
195 + 'unique' => __('Email address has been assigned to other customer', 'fluent-support'),
160 196 ]
161 197 ]
162 198 ], 423);
163 199 }
200 + }
164 201
165 - $validKeys = (new Customer)->getFillable();
166 - unset($validKeys['hash']);
167 - unset($validKeys['user_id']);
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 + }
168 213
169 - $updateData = Arr::only($data, $validKeys);
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) : [];
170 225
171 - $user = get_user_by('email', $data['email']);
226 + // Filter out any zero values (from invalid input)
227 + $customerIds = array_filter($customerIds, function ($id) {
228 + return $id > 0;
229 + });
172 230
173 - if ($user) {
174 - $updateData['user_id'] = $user->ID;
175 - }
231 + $customerIds = array_values(array_unique($customerIds));
176 232
177 - Customer::where('id', $customer->id)
178 - ->update($updateData);
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 + ]);
179 239
180 - return [
181 - 'message' => __('Customer has been updated', 'fluent-support'),
182 - 'customer' => Customer::findOrFail($customerId)
183 - ];
240 + return $customer->bulkDeleteCustomers($customerIds);
184 241 }
185 242
186 243 /**
187 - * delete method will delete a customer and all ticket by that customer
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)
188 246 * @param Request $request
189 - * @param $customerId
190 247 * @return array
191 248 */
192 - public function delete(Request $request, $customerId)
249 + public function addOrUpdateProfileImage(Request $request, AvatarUploder $avatarUploder)
193 250 {
194 - $customer = Customer::findOrFail($customerId);
195 -
196 - $tickets = Ticket::where('customer_id', $customer->id)->get();
197 -
198 - foreach ($tickets as $ticket) {
199 - $ticket->deleteTicket();
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 + );
200 259 }
201 -
202 - $customer->delete();
203 -
204 - return [
205 - 'message' => __('Customer Deleted Successfully', 'fluent-support')
206 - ];
207 260 }
208 261
209 262 /**
210 - * addOrUpdateProfileImage method will update a customer avatar
211 - * @param Request $request
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
212 270 * @return array
213 271 */
214 - public function addOrUpdateProfileImage(Request $request)
272 + public function resetAvatar($customer)
215 273 {
216 - $allowExtension = [
217 - 'jpeg', 'jpe', 'jpg', 'png'
218 - ];
274 + try {
275 + $customer = Customer::findOrFail((int) $customer);
276 + $customer->restoreAvatar();
219 277
220 - $customer_id = $request->get('customer_id');
221 - $file = $request->files();
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 + }
286 + }
222 287
223 - $ext = $file['file']->getClientOriginalExtension();
288 + public function searchContact(Request $request)
289 + {
290 + $search = trim($request->getSafe('search', 'sanitize_text_field'));
224 291
225 - if(!in_array($ext, $allowExtension)){
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) {
226 298 return $this->sendError([
227 - 'message' => __('Unsupported file submitted, please select an image file', 'fluent-support')
299 + 'message' => __('Please provide search string', 'fluent-support')
228 300 ]);
229 301 }
230 302
231 - $customer = Customer::findOrFail($customer_id);
303 + $isEmail = is_email($search);
232 304
233 - $uploadedImage = FileSystem::setSubDir('customer_avatars')->put($file);
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 + }
234 312
235 - if($avatar = $uploadedImage[0]['url']){
236 - $customer->avatar = $avatar;
237 - $customer->save();
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 + }
238 318
239 - return[
240 - 'message' => __('Profile picture has been updated successfully', 'fluent-support'),
241 - 'image' => $customer->avatar,
242 - 'customer' => $customer
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();
334 + }
335 +
336 + if (!$customers->isEmpty()) {
337 + return [
338 + 'type' => 'search_result',
339 + 'provider' => 'fluent_support',
340 + 'data' => $customers,
341 + 'is_email' => $isEmail,
342 + 'search' => $search
243 343 ];
244 344 }
245 345
246 - else{
247 - return $this->sendError([
248 - 'message' => __('Something went wrong while updating the profile picture', 'fluent-support')
249 - ]);
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 + }
250 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 +
397 + return [
398 + 'type' => 'none',
399 + 'provider' => 'none',
400 + 'data' => [],
401 + 'is_email' => $isEmail
402 + ];
403 +
404 + }
405 +
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)
416 + {
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;
251 440 }
252 441 }