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/TicketController.php +1546 -238 1.10.02.4.0 View file →
@@ -1,22 +1,31 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 +use FluentSupport\App\Models\Agent;
6 +use FluentSupport\App\Models\Attachment;
5 7 use FluentSupport\App\Models\Meta;
8 +use FluentSupport\App\Models\Customer;
9 +use FluentSupport\Framework\Http\Request\Request;
6 10 use FluentSupport\Framework\Support\Arr;
7 11 use FluentSupport\App\Http\Requests\TicketRequest;
8 12 use FluentSupport\App\Http\Requests\TicketResponseRequest;
9 13 use FluentSupport\App\Models\Conversation;
14 +use FluentSupport\App\Models\MailBox;
15 +use FluentSupport\App\Models\Product;
10 16 use FluentSupport\App\Models\Ticket;
11 -use FluentSupport\App\Services\FluentBoardsService;
12 17 use FluentSupport\App\Services\FluentCRMServices;
13 18 use FluentSupport\App\Services\Helper;
14 19 use FluentSupport\App\Services\ProfileInfoService;
15 20 use FluentSupport\App\Services\TicketHelper;
16 -use FluentSupport\Framework\Request\Request;
21 +use FluentSupport\App\Services\TicketQueryService;
17 22 use FluentSupport\App\Modules\PermissionManager;
23 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
24 +use FluentSupport\App\Services\Tickets\ResponseService;
25 +use FluentSupport\App\Models\AgentGroup;
18 26 use FluentSupport\App\Services\Tickets\TicketService;
27 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
19 28
20 29 /**
21 30 * TicketController class for REST API related to ticket
22 31 * This class is responsible for getting / inserting/ modifying data for all request related to ticket
@@ -28,25 +37,50 @@
28 37 {
29 38 /**
30 39 * This `me` method will return the current user profile info
31 40 * @param Request $request
32 - * @param ProfileInfoService $profileInfoService
33 41 * @return array
34 42 */
35 - public function me(Request $request, ProfileInfoService $profileInfoService)
43 + public function me(Request $request)
36 44 {
37 45 $user = wp_get_current_user();
46 + $requestData = $request->all();
47 + $sanitizedRequest = [];
48 + foreach ($requestData as $key => $value) {
49 + if (is_array($value)) {
50 + $sanitizedRequest[$key] = map_deep($value, 'sanitize_text_field');
51 + } else {
52 + $sanitizedRequest[$key] = sanitize_text_field($value);
53 + }
54 + }
55 +
38 56 $settings = [
39 - 'user_id' => $user->ID,
40 - 'email' => $user->user_email,
41 - 'person' => Helper::getAgentByUserId($user->ID),
57 + 'user_id' => $user->ID,
58 + 'email' => $user->user_email,
59 + 'person' => Helper::getAgentByUserId($user->ID),
42 60 'permissions' => PermissionManager::currentUserPermissions(),
43 - 'request' => $request->all()
61 + 'request' => $sanitizedRequest
44 62 ];
45 63
46 - $withPortalSettings = $request->getSafe('with_portal_settings');
64 + if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
65 + $mimeHeadings = Helper::getAcceptedMimeHeadings();
66 + $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
67 + $maxFileSize = absint($businessSettings['max_file_size']);
47 68
48 - return $profileInfoService->me($settings, $withPortalSettings);
69 + $portalSettings = [
70 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
71 + 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
72 + 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
73 + 'has_rich_text_editor' => true,
74 + 'max_file_size' => $maxFileSize,
75 + 'mime_headings' => $mimeHeadings
76 + ];
77 +
78 + $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
79 + $settings['portal_settings'] = $portalSettings;
80 + }
81 +
82 + return $settings;
49 83 }
50 84
51 85 /**
52 86 * index method will return the list of ticket based on the selected filter
@@ -52,47 +86,152 @@
52 86 * index method will return the list of ticket based on the selected filter
53 87 * @param Request $request
54 88 * @return array
55 89 */
56 - public function index(Request $request, TicketService $ticketService)
90 + public function index(Request $request)
57 91 {
58 92 //Selected filter type, either simple or Advanced
59 - $filterType = $request->getText('filter_type', 'simple');
60 - $data = $request->all();
61 - return $ticketService->getTickets($data, $filterType);
93 + $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
94 +
95 + /*Prepare Query Arguments*/
96 + $queryArgs = [
97 + 'with' => [],
98 + 'filter_type' => $filterType,
99 + 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
100 + 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
101 + ];
102 +
103 + //If the selected filter type is advanced
104 + if ($filterType == 'advanced') {
105 + $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
106 + //Get the selected query params for advanced filter
107 + $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
108 + } else {
109 + //Selected filter type is simple
110 + $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
111 + $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
112 +
113 + if ($customerId = $request->getSafe('customer_id', 'intval')) {
114 + $queryArgs['customer_id'] = $customerId;
115 + }
116 + }
117 + /*End Prepare Query Arguments*/
118 +
119 + $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
120 +
121 + $ticketsModel = $ticketsModel->with([
122 + 'customer' => function ($query) {
123 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
124 + }, 'agent' => function ($query) {
125 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
126 + },
127 + 'mailbox',
128 + 'product',
129 + 'tags',
130 + 'preview_response' => function ($query) {
131 + $query->latest('id');
132 + }
133 + ]);
134 +
135 + // apply filters by access level
136 + do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
137 +
138 + $tickets = $ticketsModel->paginate();
139 +
140 + $perPage = $request->getSafe('per_page', 'intval', 15);
141 +
142 + // Load live activity for small page sizes (board/kanban view)
143 + if ($perPage < 15) {
144 + TicketHelper::loadBatchLiveActivities($tickets);
145 + }
146 +
147 + return [
148 + 'tickets' => $tickets
149 + ];
62 150 }
63 151
64 152 /**
65 153 * createTicket method will create new ticket as well as customer or WP user
66 - * @param Request $request
67 - * @param Ticket $ticket
154 + * @param TicketRequest $request
68 155 * @return array
69 - * @throws \Exception
70 156 */
71 - public function createTicket(TicketRequest $request, Ticket $ticket)
157 + public function createTicket(TicketRequest $request)
72 158 {
73 - $data = $request->sanitize();
159 + try {
160 + //Sanitize and validate request data via TicketRequest
161 + $data = $request->sanitize();
162 + $ticketData = $data['ticket'];
163 + $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
74 164
75 - $ticketData = $data['ticket'];
165 + //Include attachments if provided
166 + if (!empty($data['attachments'])) {
167 + $ticketData['attachments'] = $data['attachments'];
168 + }
76 169
77 - if (!empty($data['attachments'])) {
78 - $ticketData['attachments'] = $data['attachments'];
79 - }
170 + /*
171 + * If customer_id is not provided, attempt to create a new customer
172 + * This handles WP user creation and customer creation
173 + */
174 + if (empty($ticketData['customer_id'])) {
175 + $createdUserId = false;
80 176
81 - $maybeNewCustomer = $data['newCustomer'];
177 + //If user selected create WP user during ticket creation
178 + if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
179 + //Check if username already in use, if not create new user
180 + if (!username_exists($maybeNewCustomer['username'])) {
181 + $authController = new AuthController();
182 + $createdUserId = $authController->createUser($maybeNewCustomer);
183 + $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
184 + }
185 + }
82 186
83 - $createdTicket = $ticket->createTicket($ticketData, $maybeNewCustomer);
187 + $email = Arr::get($maybeNewCustomer, 'email');
188 + if (!$email || !is_email($email)) {
189 + return $this->sendError([
190 + 'message' => __('A valid email is required to create a ticket', 'fluent-support')
191 + ]);
192 + }
84 193
85 - if (is_wp_error($createdTicket)) {
194 + //Check if customer already exists by email
195 + $existingCustomer = Customer::where('email', $email)->first();
196 +
197 + if ($existingCustomer) {
198 + $ticketData['customer_id'] = $existingCustomer->id;
199 + } else {
200 + //Create the customer now
201 + $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
202 + $customerData['user_id'] = $createdUserId;
203 + $customerData = array_filter($customerData);
204 +
205 + $createCustomer = Customer::create($customerData);
206 +
207 + do_action('fluent_support/customer_created', $createCustomer);
208 +
209 + if (!$createCustomer) {
210 + return $this->sendError([
211 + 'message' => __('Customer could not be created', 'fluent-support')
212 + ]);
213 + }
214 +
215 + $ticketData['customer_id'] = $createCustomer->id;
216 + }
217 + }
218 +
219 + //Get customer information from db
220 + $customer = Customer::findOrFail($ticketData['customer_id']);
221 +
222 + //Sanitize, store ticket, handle attachments, fire hooks
223 + $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
224 +
225 + return [
226 + 'message' => __('Ticket has been created successfully', 'fluent-support'),
227 + 'ticket' => $createdTicket
228 + ];
229 + } catch (\Exception $e) {
86 230 return $this->sendError([
87 - 'message' => $createdTicket->get_error_message()
231 + 'message' => Helper::getSafeErrorMessage($e)
88 232 ]);
89 233 }
90 -
91 - return [
92 - 'message' => 'Ticket has been successfully created',
93 - 'ticket' => $createdTicket
94 - ];
95 234 }
96 235
97 236 /**
98 237 * getTicket method will return ticket information by ticket id
@@ -99,25 +238,277 @@
99 238 * @param Request $request
100 239 * @param $ticket_id
101 240 * @return array
102 241 */
103 - public function getTicket(Request $request, Ticket $ticket, $ticket_id)
242 + public function getTicket(Request $request, $ticket_id)
104 243 {
105 244 try {
106 - $ticketWith = $request->getSafe('with', 'sanitize_text_field');
245 + //Get logged in agent information
246 + $agent = Helper::getAgentByUserId();
247 +
248 + $ticketWith = $request->get('with');
249 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
250 +
107 251 if (!$ticketWith) {
108 252 $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
109 - $q->whereIn('status', ['active', 'inline']);
253 + $q->where('status', 'active');
110 254 }];
111 255 }
112 - $withCrmData = in_array('fluentcrm_profile', $request->query('with_data', []));
113 256
114 - return $ticket->getTicket($ticketWith, $withCrmData, $ticket_id);
257 + //Get ticket by id
258 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
259 +
260 + //Eager load responses with their nested relations to avoid N+1 queries
261 + $ticket->load(['responses' => function ($q) {
262 + $q->with([
263 + 'person',
264 + 'ccinfo',
265 + 'attachments' => function ($q) {
266 + $q->where('status', 'active');
267 + }
268 + ]);
269 + }]);
270 +
271 + //Check if ticket is in a restricted mailbox
272 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
273 +
274 + if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
275 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
276 + }
277 +
278 + $this->ensureCanAccessTicket($ticket);
279 +
280 + //If ticket has customer, set custom fields and profile url
281 + if ($ticket->customer) {
282 + $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
283 + $ticket->customer->custom_field_keys = $customFieldsKey;
284 +
285 + if ($ticket->customer->user_id) {
286 + $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
287 + if (!empty($customFieldKeysUsingHook)) {
288 + $allUserMeta = get_user_meta($ticket->customer->user_id);
289 + foreach ($customFieldKeysUsingHook as $key) {
290 + if (isset($allUserMeta[$key][0]) && $allUserMeta[$key][0]) {
291 + $ticket->customer->$key = $allUserMeta[$key][0];
292 + }
293 + }
294 + }
295 + }
296 +
297 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 + }
299 +
300 + //If ticket is closed, load closed by person
301 + if ($ticket->status == 'closed') {
302 + $ticket->load('closed_by_person');
303 + }
304 +
305 + //Load agent feedback ratings if pro is active and feature is enabled
306 + if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
307 + $responseIds = $ticket->responses->pluck('id')->toArray();
308 + $feedbacks = Meta::where('object_type', 'conversation_meta')
309 + ->where('key', 'agent_feedback_ratings')
310 + ->whereIn('object_id', $responseIds)
311 + ->get()
312 + ->keyBy('object_id');
313 +
314 + foreach ($ticket->responses as $response) {
315 + if ($feedbacks->has($response->id)) {
316 + $response->agent_feedback = $feedbacks->get($response->id)->value;
317 + }
318 + }
319 + }
320 +
321 + $contents = ['ticket' => $ticket->content];
322 + foreach ($ticket->responses as $response) {
323 + $contents['response_' . $response->id] = $response->content;
324 + }
325 +
326 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 + $ticket->content = $contents['ticket'];
328 +
329 + //Format response content
330 + foreach ($ticket->responses as $response) {
331 + $responseKey = 'response_' . $response->id;
332 + if (isset($contents[$responseKey])) {
333 + $response->content = $contents[$responseKey];
334 + }
335 +
336 + $responseContent = apply_filters(
337 + 'fluent_support/response_content_before_render',
338 + $response->content,
339 + $response,
340 + $ticket
341 + );
342 +
343 + if ($response->conversation_type === 'note') {
344 + $responseContent = wpautop($responseContent, false);
345 + } else {
346 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
347 + }
348 +
349 +
350 + $response->content = apply_filters(
351 + 'fluent_support/response_content_after_render',
352 + $responseContent,
353 + $response,
354 + $ticket
355 + );
356 +
357 + if (!empty($response->ccinfo)) {
358 + $val = Helper::safeUnserialize($response->ccinfo->value);
359 + if (isset($val['cc_email']) && !empty($val['cc_email'])) {
360 + $response->cc_info = $val['cc_email'];
361 + } else {
362 + $response->cc_info = '';
363 + }
364 + } else {
365 + $response->cc_info = '';
366 + }
367 + }
368 +
369 + $ticketContent = apply_filters(
370 + 'fluent_support/ticket_content_before_render',
371 + $ticket->content,
372 + $ticket
373 + );
374 +
375 + $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
376 +
377 + $ticket->content = apply_filters(
378 + 'fluent_support/ticket_content_after_render',
379 + $ticketContent,
380 + $ticket
381 + );
382 +
383 + //Get last activity by agent
384 + $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385 +
386 + //Get all carbon copy customer
387 + $ccInfo = $ticket->getSettingsValue('cc_email', []);
388 + $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
389 +
390 + if (defined('FLUENTSUPPORTPRO')) {
391 + $ticket->custom_fields = $ticket->customData('admin', true);
392 + }
393 +
394 + // Load agent info if ticket was created on behalf of customer
395 + if ($ticket->created_by) {
396 + $ticket->load('created_by_person');
397 + if ($ticket->created_by_person) {
398 + $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
399 + $ticket->created_by_agent = [
400 + 'id' => $ticket->created_by_person->id,
401 + 'full_name' => $ticket->created_by_person->full_name,
402 + 'agent_initiated' => $isAgentInitiated,
403 + ];
404 + }
405 + }
406 +
407 + $data = [
408 + 'ticket' => $ticket,
409 + 'responses' => $ticket->responses,
410 + 'agent_id' => $agent->id
411 + ];
412 +
413 + if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
414 + $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
415 + }
416 +
417 + $withData = $request->get('with_data', null);
418 + $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
419 +
420 + if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
421 + $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
422 + }
423 +
424 + return $data;
115 425 } catch (\Exception $e) {
116 - return $this->sendError($e->getMessage());
426 + return $this->sendError([
427 + 'message' => Helper::getSafeErrorMessage($e)
428 + ]);
117 429 }
118 430 }
119 431
432 + public function getMentionableAgents(Request $request, $ticket_id)
433 + {
434 + try {
435 + $ticket = Ticket::findOrFail($ticket_id);
436 +
437 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
438 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
439 + }
440 +
441 + $this->ensureCanAccessTicket($ticket);
442 +
443 + $search = trim($request->getSafe('search', 'sanitize_text_field', ''));
444 + $limit = min(max(absint($request->getSafe('limit', 'intval', 20)), 1), 50);
445 +
446 + return [
447 + 'agents' => $this->getMentionableAgentList($ticket, $search, $limit)
448 + ];
449 + } catch (\Exception $e) {
450 + return $this->sendError([
451 + 'message' => Helper::getSafeErrorMessage($e)
452 + ]);
453 + }
454 + }
455 +
456 + protected function getMentionableAgentList($ticket, $search, $limit)
457 + {
458 + $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
459 + ->mentionBy($search)
460 + ->orderBy('first_name')
461 + ->orderBy('last_name')
462 + ->get();
463 +
464 + if ($allAgents->isEmpty()) {
465 + return [];
466 + }
467 +
468 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 + $ticketAccess = new AgentTicketAccess();
470 + $results = [];
471 +
472 + foreach ($allAgents as $agent) {
473 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
474 + continue;
475 + }
476 +
477 + $results[] = [
478 + 'id' => strval($agent->id),
479 + 'first_name' => $agent->first_name,
480 + 'last_name' => $agent->last_name,
481 + 'email' => $agent->email,
482 + ];
483 +
484 + if (count($results) >= $limit) {
485 + break;
486 + }
487 + }
488 +
489 + return $results;
490 + }
491 +
492 + protected function getAgentRestrictionsMap(array $agentIds)
493 + {
494 + if (!$agentIds) {
495 + return [];
496 + }
497 +
498 + $metas = Meta::where('object_type', 'person_meta')
499 + ->where('key', 'agent_restrictions')
500 + ->whereIn('object_id', $agentIds)
501 + ->get();
502 +
503 + $restrictions = [];
504 + foreach ($metas as $meta) {
505 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
506 + }
507 +
508 + return $restrictions;
509 + }
510 +
120 511 /**
121 512 * createResponse method will create response by agent for the ticket
122 513 * @param Request $request
123 514 * @param Ticket $ticket
@@ -124,20 +515,163 @@
124 515 * @param int $ticket_id
125 516 * @return array
126 517 * @throws \FluentSupport\Framework\Validator\ValidationException
127 518 */
128 - public function createResponse(TicketResponseRequest $request, Ticket $ticket, $ticket_id)
519 + public function createResponse(TicketResponseRequest $request, $ticket_id)
129 520 {
521 + $data = $request->sanitize();
130 522
131 - $data = $request->sanitize();
523 + try {
524 + $convoType = Arr::get($data, 'conversation_type', 'response');
525 + $isDraft = $convoType === 'draft_response';
132 526
527 + if (!$isDraft) {
528 + $this->ensureCanManageTickets();
529 + }
530 +
531 + //Get logged-in agent information
532 + $agent = Helper::getAgentByUserId();
533 +
534 + if (!$agent) {
535 + return $this->sendError([
536 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
537 + ]);
538 + }
539 +
540 + $ticket = Ticket::findOrFail($ticket_id);
541 +
542 + $this->ensureCanAccessTicket($ticket);
543 +
544 + $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
545 +
546 + $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
547 + $responseData['response']->load([
548 + 'attachments' => function ($q) {
549 + $q->where('status', 'active');
550 + }
551 + ]);
552 + $responseData['response']->content = wp_specialchars_decode(wpautop($responseData['response']->content, false));
553 +
554 + return [
555 + 'message' => __('Response has been added', 'fluent-support'),
556 + 'response' => $responseData['response'],
557 + 'ticket' => $responseData['ticket'],
558 + 'update_data' => $responseData['update_data']
559 + ];
560 + } catch (\Exception $e) {
561 + return $this->sendError([
562 + 'message' => Helper::getSafeErrorMessage($e)
563 + ]);
564 + }
565 + }
566 +
567 + public function getFluentBookingEventTypes()
568 + {
133 569 try {
134 - return $ticket->createResponse($data, $ticket_id);
570 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
571 + $this->ensureCanManageTickets();
572 +
573 + $service = new FluentBookingService();
574 + $eventTypes = $service->getEventTypes();
575 +
576 + return [
577 + 'status' => $service->getStatus($eventTypes),
578 + 'event_types' => $eventTypes
579 + ];
135 580 } catch (\Exception $e) {
136 - return $this->sendError($e->getMessage());
581 + return $this->sendError([
582 + 'message' => Helper::getSafeErrorMessage($e)
583 + ]);
137 584 }
138 585 }
139 586
587 + public function createFluentBookingLink(Request $request, $ticket_id)
588 + {
589 + try {
590 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
591 + $this->ensureCanManageTickets();
592 +
593 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
594 +
595 + // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
596 + $this->ensureCanAccessTicket($ticket);
597 +
598 + $eventId = $request->getSafe('event_type_id', 'intval');
599 +
600 + if (!$eventId) {
601 + throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
602 + }
603 +
604 + return (new FluentBookingService())->createBookingLink(
605 + $ticket,
606 + $eventId,
607 + $request->getSafe('message', 'wp_kses_post'),
608 + $request->get('selected_slots', []),
609 + $request->getSafe('timezone', 'sanitize_text_field', '')
610 + );
611 + } catch (\Exception $e) {
612 + return $this->sendError([
613 + 'message' => Helper::getSafeErrorMessage($e)
614 + ]);
615 + }
616 + }
617 +
618 + public function getFluentBookingAvailability(Request $request, $ticket_id)
619 + {
620 + try {
621 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
622 + $this->ensureCanManageTickets();
623 +
624 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
625 +
626 + // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
627 + $this->ensureCanAccessTicket($ticket);
628 +
629 + $eventId = $request->getSafe('event_type_id', 'intval');
630 +
631 + if (!$eventId) {
632 + throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
633 + }
634 +
635 + return [
636 + 'availability' => (new FluentBookingService())->getAvailabilitySlots(
637 + $eventId,
638 + $request->getSafe('range', 'sanitize_key', 'next_3_days'),
639 + $request->getSafe('timezone', 'sanitize_text_field'),
640 + $request->getSafe('duration', 'intval'),
641 + $ticket,
642 + $request->get('selected_dates', []),
643 + $request->getSafe('calendar_month', 'sanitize_text_field', '')
644 + )
645 + ];
646 + } catch (\Exception $e) {
647 + return $this->sendError([
648 + 'message' => Helper::getSafeErrorMessage($e)
649 + ]);
650 + }
651 + }
652 +
653 + public function getFluentBookingMeetings($ticket_id)
654 + {
655 + try {
656 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
657 + $this->ensureCanManageTickets();
658 +
659 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
660 +
661 + // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
662 + $this->ensureCanAccessTicket($ticket);
663 +
664 + return [
665 + 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
666 + ];
667 + } catch (\Exception $e) {
668 + return $this->sendError([
669 + 'message' => Helper::getSafeErrorMessage($e)
670 + ]);
671 + }
672 + }
673 +
140 674 /**
141 675 * createDraft method will create draft by agent for the ticket
142 676 * @param Request $request
143 677 * @param Ticket $ticket
@@ -144,37 +678,142 @@
144 678 * @param int $ticket_id
145 679 * @return array
146 680 * @throws \FluentSupport\Framework\Validator\ValidationException
147 681 */
148 - public function createOrUpdatDraft(TicketResponseRequest $request, Ticket $ticket, $ticket_id)
682 + public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
149 683 {
150 -
151 684 $data = $request->sanitize();
152 685
153 686 try {
154 - return $ticket->addOrUpdatDraft($data, $ticket_id);
687 + //Get logged-in agent information
688 + $agent = Helper::getAgentByUserId();
689 +
690 + if (!$agent) {
691 + return $this->sendError([
692 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
693 + ]);
694 + }
695 +
696 + $ticket = Ticket::findOrFail($ticket_id);
697 +
698 + $this->ensureCanAccessTicket($ticket);
699 +
700 + $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
701 + $previousDraft = Meta::where('key', $key)->first();
702 +
703 + if ($data['draftID'] || $previousDraft) {
704 + Meta::where('key', $key)->update([
705 + 'value' => maybe_serialize($data)
706 + ]);
707 +
708 + return [
709 + 'message' => __('Draft has been updated', 'fluent-support'),
710 + 'draftID' => $data['draftID']
711 + ];
712 + }
713 +
714 + $draftID = Meta::insertGetId([
715 + 'object_type' => '_fs_auto_draft',
716 + 'object_id' => $ticket_id,
717 + 'key' => $key,
718 + 'value' => maybe_serialize($data)
719 + ]);
720 +
721 + return [
722 + 'message' => __('Draft has been added', 'fluent-support'),
723 + 'draftID' => $draftID
724 + ];
155 725 } catch (\Exception $e) {
156 - return $this->sendError($e->getMessage());
726 + return $this->sendError([
727 + 'message' => Helper::getSafeErrorMessage($e)
728 + ]);
157 729 }
158 730 }
159 731
160 - public function getDraft(Ticket $ticket, $ticket_id)
732 + public function getDraft($ticket_id)
161 733 {
162 734 try {
163 - return $ticket->fetchDraft($ticket_id);
735 + //Get logged-in agent information
736 + $agent = Helper::getAgentByUserId();
737 +
738 + if (!$agent) {
739 + return $this->sendError([
740 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
741 + ]);
742 + }
743 +
744 + $ticket = Ticket::findOrFail($ticket_id);
745 +
746 + $this->ensureCanAccessTicket($ticket);
747 +
748 + $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
749 +
750 + $draft = Meta::where([
751 + 'object_type' => '_fs_auto_draft',
752 + 'key' => $key,
753 + ])->first();
754 +
755 + if ($draft) {
756 + $draft->value = Helper::safeUnserialize($draft->value);
757 + }
758 +
759 + return [
760 + 'draft' => $draft
761 + ];
164 762 } catch (\Exception $e) {
165 - return $this->sendError($e->getMessage());
763 + return $this->sendError([
764 + 'message' => Helper::getSafeErrorMessage($e)
765 + ]);
166 766 }
167 767 }
168 768
169 - public function deleteDraft(Ticket $ticket, $draft_id)
769 + public function deleteDraft($draft_id)
170 770 {
171 771 $draft_id = intval($draft_id);
172 772
173 773 try {
174 - return $ticket->removeDraft($draft_id);
774 + $agent = Helper::getAgentByUserId();
775 +
776 + if (!$agent) {
777 + return $this->sendError([
778 + 'message' => __('You do not have permission to perform this action', 'fluent-support'),
779 + ]);
780 + }
781 +
782 + $draft = Meta::where('id', $draft_id)
783 + ->where('object_type', '_fs_auto_draft')
784 + ->first();
785 +
786 + if (!$draft) {
787 + return $this->sendError([
788 + 'message' => __('Draft not found', 'fluent-support'),
789 + ]);
790 + }
791 +
792 + // Authorize the ticket this draft belongs to (closes the mailbox/visibility
793 + // dimension for managers deleting other agents' drafts).
794 + $ticket = Ticket::findOrFail($draft->object_id);
795 +
796 + $this->ensureCanAccessTicket($ticket);
797 +
798 + // Verify ownership: draft key contains agent_id, only managers can delete others' drafts
799 + $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
800 +
801 + if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
802 + return $this->sendError([
803 + 'message' => __('You do not have permission to delete this draft', 'fluent-support'),
804 + ]);
805 + }
806 +
807 + $draft->delete();
808 +
809 + return [
810 + 'message' => __('Discard draft successfully', 'fluent-support'),
811 + ];
175 812 } catch (\Exception $e) {
176 - return $this->sendError($e->getMessage());
813 + return $this->sendError([
814 + 'message' => Helper::getSafeErrorMessage($e)
815 + ]);
177 816 }
178 817 }
179 818
180 819 /**
@@ -182,14 +821,49 @@
182 821 * @param Ticket $ticket
183 822 * @param $ticket_id
184 823 * @return array
185 824 */
186 - public function getTicketWidgets(Ticket $ticket, $ticket_id)
825 + public function getTicketWidgets(Request $request, $ticket_id)
187 826 {
188 827 try {
189 - return $ticket->getTicketWidgets($ticket_id);
828 + //Get ticket with customer by ticket id
829 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
830 +
831 + $this->ensureCanAccessTicket($ticket);
832 +
833 + $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
834 + $page = max(1, absint($request->get('page', 1)));
835 + $offset = ($page - 1) * $perPage;
836 +
837 + $baseQuery = Ticket::where('id', '!=', $ticket_id)
838 + ->where('customer_id', $ticket->customer_id);
839 +
840 + (new AgentTicketAccess())->applyAccessScope($baseQuery);
841 +
842 + $total = $baseQuery->count();
843 +
844 + $otherTickets = (clone $baseQuery)
845 + ->select(['id', 'title', 'status', 'created_at'])
846 + ->latest('id')
847 + ->limit($perPage)
848 + ->offset($offset)
849 + ->get();
850 +
851 + $response = [
852 + 'other_tickets' => $otherTickets,
853 + 'other_tickets_total' => $total,
854 + 'other_tickets_more' => ($offset + $perPage) < $total,
855 + ];
856 +
857 + if (in_array('extra_widgets', $request->get('with', []))) {
858 + $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
859 + }
860 +
861 + return $response;
190 862 } catch (\Exception $e) {
191 - return $this->sendError($e->getMessage());
863 + return $this->sendError([
864 + 'message' => Helper::getSafeErrorMessage($e)
865 + ]);
192 866 }
193 867 }
194 868
195 869 /**
@@ -198,32 +872,259 @@
198 872 * @param Ticket $ticket
199 873 * @param $ticket_id
200 874 * @return array
201 875 */
202 - public function updateTicketProperty(Request $request, Ticket $ticket, $ticket_id)
876 + public function updateTicketProperty(Request $request, $ticket_id)
203 877 {
204 - $propName = $request->getSafe('prop_name', 'sanitize_text_field');
205 - $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
878 + try {
879 + $assigner = Helper::getAgentByUserId();
880 + $ticket = Ticket::findOrFail($ticket_id);
206 881
207 - try {
208 - return $ticket->updateTicketProperty($propName, $propValue, $ticket_id);
882 + $this->ensureCanAccessTicket($ticket);
883 +
884 + $propName = $request->getSafe('prop_name', 'sanitize_text_field');
885 + $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
886 +
887 + // This generic endpoint may only touch a fixed set of
888 + // ticket columns. Previously prop_name was assigned straight onto the
889 + // model ($ticket->{$propName} = $propValue), letting a caller rewrite
890 + // ownership, mailbox, privacy, hash, serial_number, created_by and
891 + // other sensitive columns and bypass $fillable entirely. Every
892 + // property is now allowlisted and its value validated/capability-
893 + // gated below; anything else is rejected outright.
894 + if (!in_array($propName, $this->updatableTicketProperties(), true)) {
895 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
896 + }
897 +
898 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
899 +
900 + $prevValue = $ticket->{$propName};
901 +
902 + if ($propName && $propValue !== null && $prevValue != $propValue) {
903 + $ticket->{$propName} = $propValue;
904 + $ticket->save();
905 +
906 + // Log an internal note for status changes so the activity is
907 + // traceable, mirroring the close/reopen flows.
908 + if ($propName === 'status') {
909 + $statuses = Helper::ticketStatuses();
910 + $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
911 + $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
912 +
913 + $internalNote = sprintf(
914 + /* translators: 1: previous status, 2: new status */
915 + __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
916 + esc_html($fromLabel),
917 + esc_html($toLabel)
918 + );
919 +
920 + Conversation::create([
921 + 'ticket_id' => $ticket->id,
922 + 'person_id' => $assigner->id,
923 + 'conversation_type' => 'internal_info',
924 + 'content' => $internalNote
925 + ]);
926 + }
927 + }
928 +
929 + $updateData = [];
930 +
931 + if ($propName == 'product_id') {
932 + $ticket->load('product');
933 + $updateData['product'] = $ticket->product;
934 + } else if ($propName == 'agent_id') {
935 + $previousAgentId = (int) $prevValue;
936 + $ticket->load('agent');
937 + $updateData['agent'] = $ticket->agent;
938 + $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
939 + if ($prevValue != $ticket->{$propName}) {
940 + do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner, $previousAgentId);
941 + }
942 + }
943 +
944 + $message = sprintf(
945 + /* translators: %s: The name of the property that was updated */
946 + __('%s has been updated', 'fluent-support'),
947 + esc_html(str_replace('_', ' ', ucwords((string) $propName)))
948 + );
949 +
950 + return [
951 + 'message' => $message,
952 + 'update_data' => $updateData
953 + ];
209 954 } catch (\Exception $e) {
210 - return $this->sendError($e->getMessage());
955 + return $this->sendError([
956 + 'message' => Helper::getSafeErrorMessage($e)
957 + ]);
211 958 }
212 959 }
213 960
214 961 /**
962 + * The only ticket columns that may be changed through updateTicketProperty.
963 + * This mirrors exactly what the admin UI edits (agent, title, mailbox,
964 + * product, status and the two priority fields). Ownership, audit,
965 + * public-identifier and other sensitive columns are intentionally absent
966 + * and must go through their dedicated workflows.
967 + *
968 + * @return array
969 + */
970 + protected function updatableTicketProperties()
971 + {
972 + return [
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
980 + ];
981 + }
982 +
983 + /**
984 + * Validate and normalize a single ticket-property update. Each allowlisted
985 + * property is checked against its own value domain and capability, so a
986 + * caller can neither set an out-of-range value nor perform a change the UI
987 + * gates behind a stronger permission.
988 + *
989 + * @param Ticket $ticket
990 + * @param string $propName Already confirmed to be in the allowlist.
991 + * @param string $propValue Raw (text-sanitized) value from the request.
992 + * @return mixed Normalized value ready to assign to the model.
993 + * @throws \Exception When the value is invalid or the caller lacks permission.
994 + */
995 + protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
996 + {
997 + switch ($propName) {
998 + case 'title':
999 + $propValue = trim(sanitize_text_field($propValue));
1000 + if ($propValue === '') {
1001 + throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1002 + }
1003 + return $propValue;
1004 +
1005 + case 'status':
1006 + // Mirror the ticket-view status dropdown, which is built from
1007 + // changeable_ticket_statuses. The dropdown submits the group
1008 + // KEY as the status value (getTicketStatus in ViewTicket.vue
1009 + // keys the options by group name and el-option binds :value to
1010 + // that key), and only groups with a non-empty value list are
1011 + // shown. Validate against those same keys so the endpoint honors
1012 + // the fluent_support/changeable_ticket_statuses filter exactly.
1013 + $allowedStatuses = [];
1014 + foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1015 + if (!empty($statusGroup)) {
1016 + $allowedStatuses[] = $statusKey;
1017 + }
1018 + }
1019 +
1020 + if (!in_array($propValue, $allowedStatuses, true)) {
1021 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1022 + }
1023 +
1024 + // This route only assigns the column and saves, so closing or
1025 + // reopening here would skip TicketService's closure fields, hooks and cleanup.
1026 + // Use closeTicket() / reOpenTicket(); the status dropdown already does.
1027 + if ($propValue === 'closed' || $ticket->status === 'closed') {
1028 + throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1029 + }
1030 +
1031 + return $propValue;
1032 +
1033 + case 'priority':
1034 + if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1035 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1036 + }
1037 + return $propValue;
1038 +
1039 + case 'client_priority':
1040 + if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1041 + throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1042 + }
1043 + return $propValue;
1044 +
1045 + case 'product_id':
1046 + $productId = (int) $propValue;
1047 + if (!$productId || !Product::where('id', $productId)->exists()) {
1048 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1049 + }
1050 + return $productId;
1051 +
1052 + case 'agent_id':
1053 + if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1054 + throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1055 + }
1056 +
1057 + $agentId = (int) $propValue;
1058 + $agent = Agent::findOrFail($agentId);
1059 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1060 +
1061 + if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1062 + throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1063 + }
1064 + return $agentId;
1065 +
1066 + case 'mailbox_id':
1067 + // The admin UI only exposes the mailbox switcher to agents with
1068 + // fst_manage_settings; enforce the same gate on the API so the
1069 + // permission can't be bypassed by calling the endpoint directly.
1070 + if (!PermissionManager::currentUserCan('fst_manage_settings')) {
1071 + throw new \Exception(esc_html__('Permission denied to move this ticket to another mailbox.', 'fluent-support'), 403);
1072 + }
1073 +
1074 + $mailboxId = (int) $propValue;
1075 + $restrictedBoxes = array_map('intval', PermissionManager::getRestrictedMailboxIds());
1076 +
1077 + if (!MailBox::where('id', $mailboxId)->exists() || in_array($mailboxId, $restrictedBoxes, true)) {
1078 + throw new \Exception(esc_html__('Invalid or restricted mailbox.', 'fluent-support'), 422);
1079 + }
1080 +
1081 + // Preserve the agent/mailbox compatibility invariant that the
1082 + // agent_id branch enforces on assignment: a ticket must not be
1083 + // moved into a mailbox its currently assigned agent is restricted
1084 + // from, which would otherwise persist an assignment the assign
1085 + // flow would have rejected.
1086 + if ($ticket->agent_id) {
1087 + $assignedAgent = Agent::find($ticket->agent_id);
1088 + if ($assignedAgent) {
1089 + $agentRestrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignedAgent);
1090 + if (in_array($mailboxId, $agentRestrictedBoxes, true)) {
1091 + throw new \Exception(esc_html__('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), 403);
1092 + }
1093 + }
1094 + }
1095 + return $mailboxId;
1096 + }
1097 +
1098 + // Unreachable: updateTicketProperty already rejected non-allowlisted
1099 + // properties before calling this method. Fail closed regardless.
1100 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1101 + }
1102 +
1103 + /**
215 1104 * closeTicket method close the ticket by id
216 1105 * @param Ticket $ticket
217 1106 * @param int $ticket_id
218 1107 * @return array
219 1108 */
220 - public function closeTicket(Ticket $ticket, $ticket_id)
1109 + public function closeTicket(Request $request, $ticket_id)
221 1110 {
222 1111 try {
223 - return $ticket->closeTicket($ticket_id, $this->request->getSafe('close_ticket_silently'));
1112 + $agent = Helper::getAgentByUserId();
1113 + $ticket = Ticket::findOrFail($ticket_id);
1114 +
1115 + $this->ensureCanAccessTicket($ticket);
1116 +
1117 + $closeSilently = $request->getSafe('close_ticket_silently', 'sanitize_text_field');
1118 +
1119 + return [
1120 + 'message' => __('Ticket has been closed', 'fluent-support'),
1121 + 'ticket' => (new TicketService())->close($ticket, $agent, '', $closeSilently)
1122 + ];
224 1123 } catch (\Exception $e) {
225 - return $this->sendError($e->getMessage());
1124 + return $this->sendError([
1125 + 'message' => Helper::getSafeErrorMessage($e)
1126 + ]);
226 1127 }
227 1128 }
228 1129
229 1130 /**
@@ -231,14 +1132,24 @@
231 1132 * @param Request $request
232 1133 * @param $ticket_id
233 1134 * @return array
234 1135 */
235 - public function reOpenTicket(Ticket $ticket, $ticket_id)
1136 + public function reOpenTicket($ticket_id)
236 1137 {
237 1138 try {
238 - return $ticket->reOpenTicket($ticket_id);
1139 + $agent = Helper::getAgentByUserId();
1140 + $ticket = Ticket::findOrFail($ticket_id);
1141 +
1142 + $this->ensureCanAccessTicket($ticket);
1143 +
1144 + return [
1145 + 'message' => __('Ticket has been opened again', 'fluent-support'),
1146 + 'ticket' => (new TicketService())->reopen($ticket, $agent)
1147 + ];
239 1148 } catch (\Exception $e) {
240 - return $this->sendError($e->getMessage());
1149 + return $this->sendError([
1150 + 'message' => Helper::getSafeErrorMessage($e)
1151 + ]);
241 1152 }
242 1153 }
243 1154
244 1155 /**
@@ -248,34 +1159,193 @@
248 1159 * @param Ticket $ticket
249 1160 * @return array|string[]|void
250 1161 * @throws \Exception
251 1162 */
252 - public function doBulkActions(Request $request, Ticket $ticket)
1163 + public function doBulkActions(Request $request)
253 1164 {
254 - $action = $request->getSafe('bulk_action', 'sanitize_text_field'); //get action
255 - $ticket_ids = $request->get('ticket_ids', []);
256 - $sanitizedTicketIds = array_map('intval', $ticket_ids);
1165 + try {
1166 + $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1167 + $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
257 1168
258 - try {
259 - return $ticket->handleBulkActions($action, $sanitizedTicketIds);
1169 + $agent = Helper::getAgentByUserId();
1170 + $query = Ticket::whereIn('id', $ticketIds);
1171 +
1172 + //Scope selected tickets to what the agent can access, matching the
1173 + //per-ticket ensureCanAccessTicket() check on the single-ticket routes
1174 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1175 +
1176 + //If bulk action is close tickets
1177 + if ($action == 'close_tickets') {
1178 + $tickets = $query->get();
1179 + $tickets->each(function ($ticket) use ($agent) {
1180 + (new TicketService())->close($ticket, $agent);
1181 + });
1182 +
1183 + return [
1184 + 'message' => sprintf(
1185 + /* translators: %d represents the number of closed tickets. */
1186 + __('%d tickets have been closed.', 'fluent-support'),
1187 + count($tickets)
1188 + )
1189 + ];
1190 + } else if ($action == 'delete_tickets') {
1191 + $tickets = $query->get();
1192 + $ticketService = new TicketService();
1193 +
1194 + foreach ($tickets as $ticket) {
1195 + $ticketService->deleteTicket($ticket, $agent);
1196 + }
1197 +
1198 + return [
1199 + 'message' => sprintf(
1200 + /* translators: %d is the number of tickets that were deleted */
1201 + __('%d tickets have been deleted', 'fluent-support'),
1202 + count($tickets)
1203 + )
1204 + ];
1205 + } else if ($action == 'assign_agent') {
1206 + if (!$request->has('agent_id')) {
1207 + throw new \Exception(esc_html__('agent_id param is required', 'fluent-support'));
1208 + }
1209 +
1210 + $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
1211 +
1212 + $query->where(function ($q) use ($assignAgent) {
1213 + $q->where('agent_id', '!=', $assignAgent->id)
1214 + ->orWhereNull('agent_id');
1215 + });
1216 +
1217 + $tickets = $query->get();
1218 + $assignedCount = 0;
1219 + $skippedCount = 0;
1220 +
1221 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1222 +
1223 + $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1224 + $previousAgentId = (int) $ticket->agent_id;
1225 +
1226 + //Skip ticket if mailbox is restricted for the agent
1227 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1228 + $skippedCount++;
1229 + return;
1230 + }
1231 +
1232 + $ticket->agent_id = $assignAgent->id;
1233 + $ticket->save();
1234 + $assignedCount++;
1235 +
1236 + do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
1237 + });
1238 +
1239 + $assignedMessage = sprintf(
1240 + /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
1241 + __('%1$d tickets have been assigned to %2$s.', 'fluent-support'),
1242 + $assignedCount,
1243 + $assignAgent->full_name
1244 + );
1245 +
1246 + $skippedMessage = $skippedCount > 0
1247 + ? sprintf(
1248 + /* translators: %1$d is the number of skipped tickets due to mailbox restrictions. */
1249 + __('%1$d tickets were skipped due to mailbox restrictions or already being assigned.', 'fluent-support'),
1250 + $skippedCount
1251 + )
1252 + : '';
1253 +
1254 + return [
1255 + 'message' => trim($assignedMessage . ' ' . $skippedMessage)
1256 + ];
1257 + } else if ($action == 'assign_agent_group') {
1258 + if (!$request->has('agent_group_id')) {
1259 + throw new \Exception(esc_html__('agent_group_id param is required', 'fluent-support'));
1260 + }
1261 +
1262 + $groupId = $request->getSafe('agent_group_id', 'intval');
1263 + $group = AgentGroup::findOrFail($groupId);
1264 +
1265 + if ($group->agents()->count() === 0) {
1266 + throw new \Exception(esc_html__('No agents found in this group', 'fluent-support'));
1267 + }
1268 +
1269 + $tickets = $query->get();
1270 + $assignedCount = 0;
1271 + $skippedCount = 0;
1272 + $currentCounts = [];
1273 +
1274 + foreach ($tickets as $ticket) {
1275 + $previousAgentId = (int) $ticket->agent_id;
1276 + $selectedAgent = $group->getLeastLoadedAgent(
1277 + $ticket->mailbox_id, $currentCounts
1278 + );
1279 +
1280 + if (!$selectedAgent) {
1281 + $skippedCount++;
1282 + continue;
1283 + }
1284 +
1285 + $ticket->agent_id = $selectedAgent->id;
1286 + $ticket->save();
1287 + $assignedCount++;
1288 + $currentCounts[$selectedAgent->id]++;
1289 +
1290 + as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1291 + $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1292 + ], 'fluent-support');
1293 + }
1294 +
1295 + return [
1296 + 'message' => sprintf(
1297 + /* translators: %1$d is tickets assigned, %2$d is tickets skipped. */
1298 + __('%1$d tickets assigned via agent group. %2$d skipped.', 'fluent-support'),
1299 + $assignedCount,
1300 + $skippedCount
1301 + )
1302 + ];
1303 + } else if ($action == 'assign_tags') {
1304 + $tagIds = $request->get('tag_ids', null);
1305 + if (!is_array($tagIds)) {
1306 + $tagIds = [];
1307 + }
1308 + $tags = array_filter(array_map('absint', $tagIds));
1309 +
1310 + $query->get()->each(function ($ticket) use ($tags) {
1311 + $ticket->applyTags($tags);
1312 + });
1313 +
1314 + return [
1315 + 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1316 + ];
1317 + }
1318 +
1319 + throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
260 1320 } catch (\Exception $e) {
261 - return $this->sendError($e->getMessage());
1321 + return $this->sendError([
1322 + 'message' => Helper::getSafeErrorMessage($e)
1323 + ]);
262 1324 }
263 1325 }
264 1326
265 1327 /**
266 1328 * deleteTicket method will delete a ticket
267 - * @param Request $request
268 - * @param TicketService $ticketService
1329 + * @param int $ticket_id
269 1330 * @return array
270 1331 */
271 - public function deleteTicket(TicketService $ticketService, $ticket_id)
1332 + public function deleteTicket($ticket_id)
272 1333 {
273 - $ticket = Ticket::findOrFail($ticket_id);
274 1334 try {
275 - return $ticketService->delete($ticket);
1335 + $ticket = Ticket::findOrFail($ticket_id);
1336 +
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1339 + (new TicketService())->deleteTicket($ticket);
1340 +
1341 + return [
1342 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
1343 + ];
276 1344 } catch (\Exception $e) {
277 - return $this->sendError($e->getMessage());
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
278 1348 }
279 1349 }
280 1350
281 1351 /**
@@ -285,20 +1355,99 @@
285 1355 * @param Conversation $conversation
286 1356 * @return array
287 1357 * @throws \Exception
288 1358 */
289 - public function doBulkReplies(Request $request, Conversation $conversation)
1359 + public function doBulkReplies(Request $request)
290 1360 {
291 - $data = $request->get();
292 - $this->validate($data, [
293 - 'content' => 'required',
294 - 'ticket_ids' => 'required|array'
295 - ]);
1361 + try {
1362 + // Sanitize all request data before validation
1363 + $requestData = $request->all();
1364 + $data = [];
1365 + foreach ($requestData as $key => $value) {
1366 + if (is_array($value)) {
1367 + if ($key === 'ticket_ids') {
1368 + $data[$key] = array_map('intval', $value);
1369 + } elseif ($key === 'content') {
1370 + $data[$key] = wp_kses_post($value);
1371 + } else {
1372 + $data[$key] = map_deep($value, 'sanitize_text_field');
1373 + }
1374 + } else {
1375 + $data[$key] = sanitize_text_field($value);
1376 + }
1377 + }
296 1378
297 - try {
298 - return $conversation->doBulkReplies($data);
1379 + $this->validate($data, [
1380 + 'content' => 'required',
1381 + 'ticket_ids' => 'required|array'
1382 + ]);
1383 +
1384 + //Get logged in agent information
1385 + $agent = Helper::getAgentByUserId();
1386 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
1387 +
1388 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1389 +
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392 +
1393 + $tickets = $query->get();
1394 +
1395 + if ($tickets->isEmpty()) {
1396 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1397 + }
1398 +
1399 + $responseData = [
1400 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1401 + 'conversation_type' => 'response',
1402 + 'close_ticket' => Arr::get($data, 'close_ticket'),
1403 + ];
1404 +
1405 + //If request with file attachments
1406 + $attachmentHashes = Arr::get($data, 'attachments', []);
1407 + $attachments = false;
1408 + if ($attachmentHashes) {
1409 + $attachments = Attachment::whereNull('ticket_id')
1410 + ->orderBy('id', 'asc')
1411 + ->whereIn('file_hash', $attachmentHashes)
1412 + ->get();
1413 + }
1414 +
1415 + $responseService = new ResponseService();
1416 +
1417 + foreach ($tickets as $ticket) {
1418 + if ($attachments) {
1419 + $responseData['attachments'] = [];
1420 + $attachmentRecords = [];
1421 + foreach ($attachments as $attachment) {
1422 + $fileHash = bin2hex(random_bytes(16));
1423 + $attachmentRecords[] = [
1424 + 'ticket_id' => $ticket->id,
1425 + 'file_path' => $attachment->file_path,
1426 + 'full_url' => $attachment->full_url,
1427 + 'title' => $attachment->title,
1428 + 'driver' => $attachment->driver,
1429 + 'file_size' => $attachment->file_size,
1430 + 'status' => $attachment->status,
1431 + 'file_hash' => $fileHash,
1432 + ];
1433 + $responseData['attachments'][] = $fileHash;
1434 + }
1435 + if ($attachmentRecords) {
1436 + Attachment::insert($attachmentRecords);
1437 + }
1438 + }
1439 +
1440 + $responseService->createResponse($responseData, $agent, $ticket);
1441 + }
1442 +
1443 + return [
1444 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1445 + ];
299 1446 } catch (\Exception $e) {
300 - return $this->sendError($e->getMessage());
1447 + return $this->sendError([
1448 + 'message' => Helper::getSafeErrorMessage($e)
1449 + ]);
301 1450 }
302 1451 }
303 1452
304 1453 /**
@@ -308,14 +1457,43 @@
308 1457 * @param $ticket_id
309 1458 * @param $response_id
310 1459 * @return array
311 1460 */
312 - public function deleteResponse(Conversation $conversation, $ticket_id, $response_id)
1461 + public function deleteResponse($ticket_id, $response_id)
313 1462 {
314 1463 try {
315 - return $conversation->deleteResponse($ticket_id, $response_id);
1464 + $ticket = Ticket::findOrFail($ticket_id);
1465 +
1466 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1467 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1468 + }
1469 +
1470 + // The caller must have access to this specific ticket (visibility +
1471 + // ownership + mailbox), not merely a global manage capability.
1472 + $this->ensureCanAccessTicket($ticket);
1473 +
1474 + // Deleting a response always requires the explicit delete capability,
1475 + // mirroring deleteTicket(). Assignment alone is not sufficient.
1476 + if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1477 + throw new \Exception(
1478 + esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1479 + );
1480 + }
1481 +
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
1485 +
1486 + $response->delete();
1487 + $response->ccinfo()->delete();
1488 +
1489 + return [
1490 + 'message' => __('Selected response has been deleted', 'fluent-support')
1491 + ];
316 1492 } catch (\Exception $e) {
317 - return $this->sendError($e->getMessage());
1493 + return $this->sendError([
1494 + 'message' => Helper::getSafeErrorMessage($e)
1495 + ]);
318 1496 }
319 1497 }
320 1498
321 1499 /**
@@ -320,37 +1498,156 @@
320 1498
321 1499 /**
322 1500 * updateResponse method will update ticket response using ticket and response id
323 1501 * @param Request $request
324 - * @param Conversation $conversation
325 1502 * @param int $ticket_id
326 1503 * @param int $response_id
327 1504 * @return array
328 1505 * @throws \Exception
329 1506 */
330 - public function updateResponse(TicketResponseRequest $request, Conversation $conversation, $ticket_id, $response_id)
1507 + public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
331 1508 {
332 - $data = $request->getSafe(['content', 'ticket_id', 'response_id']);
1509 + try {
1510 + $ticket = Ticket::findOrFail($ticket_id);
333 1511
1512 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1513 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
1514 + }
1515 +
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
1519 +
1520 + $response = Conversation::where('id', $response_id)
1521 + ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1523 + ->firstOrFail();
1524 + $agent = Helper::getAgentByUserId();
1525 +
1526 + // Only agent-authored conversation types may be edited here. Customer
1527 + // replies and system entries must not be rewritten via this endpoint.
1528 + $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1529 + if (!in_array($response->conversation_type, $editableTypes, true)) {
1530 + throw new \Exception(
1531 + esc_html__('This response type cannot be edited.', 'fluent-support')
1532 + );
1533 + }
1534 +
1535 + // Customer messages share the 'response' type but are authored by a
1536 + // customer person; they are never editable by an agent.
1537 + if ($response->person && $response->person->person_type !== 'agent') {
1538 + throw new \Exception(
1539 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1540 + );
1541 + }
1542 +
1543 + $isDraft = $response->conversation_type == 'draft_response';
1544 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1546 +
1547 + if ($isDraft && !$isAuthor) {
1548 + // Another agent's draft can only be edited/approved by an approver.
1549 + if (!$canApproveDraft) {
1550 + throw new \Exception(
1551 + esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 + );
1553 + }
1554 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 + // Editing another agent's response requires manage-others capability.
1556 + throw new \Exception(
1557 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 + );
1559 + }
1560 +
1561 + // Request input is already unslashed at the boundary; unslashing again
1562 + // would strip literal backslashes out of the edited reply.
1563 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 + $response->content = $content;
1565 +
1566 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1568 + } else {
1569 + $response->save();
1570 + }
1571 +
1572 + return [
1573 + 'message' => __('Selected response has been updated', 'fluent-support'),
1574 + 'response' => $response
1575 + ];
1576 + } catch (\Exception $e) {
1577 + return $this->sendError([
1578 + 'message' => Helper::getSafeErrorMessage($e)
1579 + ]);
1580 + }
1581 + }
1582 +
1583 + public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1584 + {
334 1585 try {
335 - return $conversation->updateResponse($data, $ticket_id, $response_id);
1586 + if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1587 + throw new \Exception(
1588 + esc_html__('You do not have permission to approve draft responses.', 'fluent-support')
1589 + );
1590 + }
1591 +
1592 + $ticket = Ticket::findOrFail($ticket_id);
1593 +
1594 + $this->ensureCanAccessTicket($ticket);
1595 +
1596 + $response = Conversation::where('id', $response_id)
1597 + ->where('ticket_id', $ticket_id)
1598 + ->where('conversation_type', 'draft_response')
1599 + ->firstOrFail();
1600 +
1601 + $person = Helper::getAgentByUserId();
1602 +
1603 + $response = $this->approveDraftConversation(
1604 + $ticket,
1605 + $response,
1606 + $person,
1607 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1608 + );
1609 +
1610 + return [
1611 + 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1612 + 'response' => $response,
1613 + ];
336 1614 } catch (\Exception $e) {
337 - return $this->sendError($e->getMessage());
1615 + return $this->sendError([
1616 + 'message' => Helper::getSafeErrorMessage($e)
1617 + ]);
338 1618 }
339 1619 }
340 1620
341 - public function approveDraftResponse(TicketResponseRequest $request, Conversation $conversation, $ticket_id, $response_id)
1621 + protected function approveDraftConversation($ticket, $response, $person, $content)
342 1622 {
343 - $data = [
344 - 'content' => $request->getSafe('content', 'sanitize_text_field')
345 - ];
346 - $conversationType = 'response';
1623 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
347 1624
348 - try {
349 - return $conversation->publishDraftResponse($data, $ticket_id, $response_id, $conversationType);
350 - } catch (\Exception $e) {
351 - return $this->sendError($e->getMessage());
1625 + $response->content = $content;
1626 + $response->conversation_type = 'response';
1627 + $response->created_at = current_time('mysql');
1628 + $response->save();
1629 +
1630 + if ($person->person_type == 'agent' && $ticket->status == 'new') {
1631 + $ticket->status = 'active';
1632 + if ($ticket->created_at) {
1633 + $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
1634 + } else {
1635 + $ticket->first_response_time = 300;
1636 + }
352 1637 }
1638 +
1639 + if ($resetWaitingSince) {
1640 + $ticket->last_agent_response = current_time('mysql');
1641 + $ticket->waiting_since = current_time('mysql');
1642 + }
1643 +
1644 + $ticket->response_count += 1;
1645 + $ticket->save();
1646 +
1647 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 +
1649 + return $response;
353 1650 }
354 1651
355 1652 /**
356 1653 * getLiveActivity method will return the activity in a ticket by agents
@@ -359,13 +1656,23 @@
359 1656 * @return array
360 1657 */
361 1658 public function getLiveActivity(Request $request, $ticket_id)
362 1659 {
363 - $agent = Helper::getAgentByUserId();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
364 1662
365 - return [
366 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
367 - ];
1663 + $this->ensureCanAccessTicket($ticket);
1664 +
1665 + $agent = Helper::getAgentByUserId();
1666 +
1667 + return [
1668 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1669 + ];
1670 + } catch (\Exception $e) {
1671 + return $this->sendError([
1672 + 'message' => Helper::getSafeErrorMessage($e)
1673 + ]);
1674 + }
368 1675 }
369 1676
370 1677 /**
371 1678 * removeLiveActivity method will remove activities that
@@ -374,14 +1681,24 @@
374 1681 * @return array
375 1682 */
376 1683 public function removeLiveActivity(Request $request, $ticket_id)
377 1684 {
378 - $agent = Helper::getAgentByUserId();
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
379 1687
380 - return [
381 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
382 - 'agent_id' => $agent->id
383 - ];
1688 + $this->ensureCanAccessTicket($ticket);
1689 +
1690 + $agent = Helper::getAgentByUserId();
1691 +
1692 + return [
1693 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1694 + 'agent_id' => $agent->id
1695 + ];
1696 + } catch (\Exception $e) {
1697 + return $this->sendError([
1698 + 'message' => Helper::getSafeErrorMessage($e)
1699 + ]);
1700 + }
384 1701 }
385 1702
386 1703 /**
387 1704 * addTag method will add tag in ticket by ticket id
@@ -390,16 +1707,24 @@
390 1707 * @return array
391 1708 */
392 1709 public function addTag(Request $request, $ticket_id)
393 1710 {
394 - $ticket = Ticket::findOrFail($ticket_id);
1711 + try {
1712 + $ticket = Ticket::findOrFail($ticket_id);
395 1713
396 - $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1714 + $this->ensureCanAccessTicket($ticket);
397 1715
398 - return [
399 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
400 - 'tags' => $ticket->tags
401 - ];
1716 + $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1717 +
1718 + return [
1719 + 'message' => __('Tag has been added to this ticket', 'fluent-support'),
1720 + 'tags' => $ticket->tags
1721 + ];
1722 + } catch (\Exception $e) {
1723 + return $this->sendError([
1724 + 'message' => Helper::getSafeErrorMessage($e)
1725 + ]);
1726 + }
402 1727 }
403 1728
404 1729 /**
405 1730 * detachTag method will remove all tags from tickets
@@ -408,15 +1733,24 @@
408 1733 * @return array
409 1734 */
410 1735 public function detachTag($ticket_id, $tag_id)
411 1736 {
412 - $ticket = Ticket::findOrFail($ticket_id);
413 - $ticket->detachTags($tag_id);
1737 + try {
1738 + $ticket = Ticket::findOrFail($ticket_id);
414 1739
415 - return [
416 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
417 - 'tags' => $ticket->tags
418 - ];
1740 + $this->ensureCanAccessTicket($ticket);
1741 +
1742 + $ticket->detachTags($tag_id);
1743 +
1744 + return [
1745 + 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
1746 + 'tags' => $ticket->tags
1747 + ];
1748 + } catch (\Exception $e) {
1749 + return $this->sendError([
1750 + 'message' => Helper::getSafeErrorMessage($e)
1751 + ]);
1752 + }
419 1753 }
420 1754
421 1755 /**
422 1756 * changeTicketCustomer method will update customer in a ticket
@@ -423,11 +1757,11 @@
423 1757 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
424 1758 * @param Request $request
425 1759 * @return array
426 1760 */
427 - public function changeTicketCustomer(Request $request)
1761 + public function changeTicketCustomer(Request $request, $ticket_id)
428 1762 {
429 - $ticketId = $request->getSafe('ticket_id', 'intval');
1763 + $ticketId = (int) $ticket_id;
430 1764 $newCustomerId = $request->getSafe('customer', 'intval');
431 1765
432 1766 if (!$newCustomerId) {
433 1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -432,19 +1766,41 @@
432 1766 if (!$newCustomerId) {
433 1767 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
434 1768 }
435 1769
1770 + // Rebinding a ticket to another customer exposes that customer's private
1771 + // data (profile, custom fields) through the ticket, so it requires the same
1772 + // sensitive-data capability that gates the customer routes.
1773 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 + }
1776 +
436 1777 try {
437 - $updated = Ticket::where('id', $ticketId)
438 - ->where('customer_id', '!=', $newCustomerId)
439 - ->update(['customer_id' => $newCustomerId]);
1778 + $ticket = Ticket::findOrFail($ticketId);
440 1779
441 - return $updated
442 - ? ['message' => __('Customer has been updated', 'fluent-support')]
443 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1780 + $this->ensureCanAccessTicket($ticket);
444 1781
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
1785 +
1786 + if (!$targetCustomer) {
1787 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1788 + }
1789 +
1790 + if ($ticket->customer_id == $newCustomerId) {
1791 + return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1792 + }
1793 +
1794 + $ticket->customer_id = $newCustomerId;
1795 + $ticket->save();
1796 +
1797 + return ['message' => __('Customer has been updated', 'fluent-support')];
1798 +
445 1799 } catch (\Exception $e) {
446 - return $this->sendError($e->getMessage());
1800 + return $this->sendError([
1801 + 'message' => Helper::getSafeErrorMessage($e)
1802 + ]);
447 1803 }
448 1804 }
449 1805
450 1806 /**
@@ -456,19 +1812,27 @@
456 1812 public function getTicketCustomData(Request $request, $ticket_id)
457 1813 {
458 1814 if (!defined('FLUENTSUPPORTPRO')) {
459 1815 return [
460 - 'custom_data' => [],
1816 + 'custom_data' => [],
461 1817 'rendered_fields' => []
462 1818 ];
463 1819 }
464 1820
465 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
466 1823
467 - return [
468 - 'custom_data' => (object)$ticket->customData(),
469 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
470 - ];
1824 + $this->ensureCanAccessTicket($ticket);
1825 +
1826 + return [
1827 + 'custom_data' => (object)$ticket->customData(),
1828 + 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
1829 + ];
1830 + } catch (\Exception $e) {
1831 + return $this->sendError([
1832 + 'message' => Helper::getSafeErrorMessage($e)
1833 + ]);
1834 + }
471 1835 }
472 1836
473 1837 /**
474 1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
@@ -478,13 +1842,24 @@
478 1842 * @return array
479 1843 */
480 1844 public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
481 1845 {
482 - $data = $request->only(['contact_id', 'tags']);
1846 + $data = [
1847 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 + 'tags' => $request->get('tags', null)
1849 + ];
1850 +
1851 + // Sanitize tags array if it's an array
1852 + if (is_array($data['tags'])) {
1853 + $data['tags'] = array_map('intval', $data['tags']);
1854 + }
1855 +
483 1856 try {
484 1857 return $fluentCRMServices->syncCrmTags($data);
485 1858 } catch (\Exception $e) {
486 - return $this->sendError($e->getMessage());
1859 + return $this->sendError([
1860 + 'message' => Helper::getSafeErrorMessage($e)
1861 + ]);
487 1862 }
488 1863 }
489 1864
490 1865 /**
@@ -496,99 +1871,24 @@
496 1871 */
497 1872
498 1873 public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
499 1874 {
500 - $data = $request->only(['contact_id', 'lists']);
501 - try {
502 - return $fluentCRMServices->syncCrmLists($data);
503 - } catch (\Exception $e) {
504 - return $this->sendError($e->getMessage());
505 - }
506 - }
1875 + $data = [
1876 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1877 + 'lists' => $request->get('lists', null, [])
1878 + ];
507 1879
508 - /**
509 - * Retrieve boards from Fluent Boards API.
510 - *
511 - * @return array Formatted array of boards.
512 - */
513 - public function getBoards()
514 - {
515 - $boards = FluentBoardsApi('boards')->getBoards();
516 - $formattedBoards = [];
517 -
518 - foreach ($boards as $board) {
519 - $formattedBoard = [
520 - 'id' => $board->id,
521 - 'title' => $board->title,
522 - 'tasks' => [],
523 - ];
524 -
525 - $formattedBoards[] = $formattedBoard;
1880 + // Sanitize lists array if it's an array
1881 + if (is_array($data['lists'])) {
1882 + $data['lists'] = array_map('intval', $data['lists']);
526 1883 }
527 1884
528 - return ['boards' => $formattedBoards];
529 - }
530 -
531 - /**
532 - * Retrieve stages for a specific board from Fluent Boards API.
533 - *
534 - * @param Request $request Request object containing 'board_id'.
535 - * @return array Formatted array of stages.
536 - */
537 - public function getStages(Request $request)
538 - {
539 - $boardId = $request->input('board_id');
540 - $boardStages = FluentBoardsApi('boards')->getStagesByBoard($boardId);
541 -
542 - $formattedStages = [];
543 - if (!empty($boardStages)) {
544 - foreach ($boardStages[0]->stages as $stage) {
545 - $formattedStages[] = [
546 - 'id' => $stage->id,
547 - 'title' => $stage->title,
548 - ];
549 - }
550 - }
551 -
552 - return ['stages' => $formattedStages];
553 - }
554 -
555 - /**
556 - * Create a task using data provided in the request.
557 - *
558 - * @param Request $request Request object containing task data.
559 - * @return array Response containing message and task data.
560 - */
561 - public function createTask(Request $request, FluentBoardsService $fluentBoardsService)
562 - {
563 1885 try {
564 - $taskData = [
565 - 'source_id' => $request->getSafe('source_id', 'intval'),
566 - 'board_id' => $request->getSafe('board_id', 'intval'),
567 - 'stage_id' => $request->getSafe('stage_id', 'intval'),
568 - 'crm_contact_id' => $request->getSafe('crm_contact_id', 'intval') ?: null,
569 - 'title' => $request->getSafe('title', 'sanitize_text_field'),
570 - 'description' => $request->getSafe('description', 'wp_kses_post'),
571 - 'source' => $request->getSafe('source', 'sanitize_text_field'),
572 - 'started_at' => $request->getSafe('started_at', 'sanitize_text_field'),
573 - 'due_at' => $request->getSafe('due_at', 'sanitize_text_field'),
574 - ];
575 -
576 - $task = FluentBoardsApi('tasks')->create($taskData);
577 -
578 - if (!$task) {
579 - return $this->sendError(__('Failed to create task.', 'fluent-support'));
580 - }
581 -
582 - $fluentBoardsService->addInternalNote($task);
583 - $fluentBoardsService->addComment($task);
584 -
585 - return [
586 - 'message' => __('Task successfully added to Fluent Boards', 'fluent-support'),
587 - 'task' => $task
588 - ];
1886 + return $fluentCRMServices->syncCrmLists($data);
589 1887 } catch (\Exception $e) {
590 - return $this->sendError($e->getMessage());
1888 + return $this->sendError([
1889 + 'message' => Helper::getSafeErrorMessage($e)
1890 + ]);
591 1891 }
592 1892 }
593 1893
594 1894 /**
@@ -598,20 +1898,22 @@
598 1898 * @return array The ticket essentials data.
599 1899 */
600 1900 public function getTicketEssentials(Request $request)
601 1901 {
602 - $type = $request->get('type');
1902 + $type = $request->getSafe('type', 'sanitize_text_field');
603 1903
604 1904 return TicketHelper::getTicketEssentials($type);
605 1905 }
606 1906
607 - public function fetchLabelSearch(Ticket $ticket)
1907 + public function fetchLabelSearch()
608 1908 {
609 1909 try {
610 1910 $agent_id = get_current_user_id();
611 1911 return TicketHelper::getLabelSearch($agent_id);
612 1912 } catch (\Exception $e) {
613 - return $this->sendError($e->getMessage());
1913 + return $this->sendError([
1914 + 'message' => Helper::getSafeErrorMessage($e)
1915 + ]);
614 1916 }
615 1917 }
616 1918
617 1919 public function storeOrUpdateLabelSearch(Request $request)
@@ -617,12 +1919,15 @@
617 1919 public function storeOrUpdateLabelSearch(Request $request)
618 1920 {
619 1921 try {
620 1922 $agent_id = get_current_user_id();
621 - $searchData = $request->get('query');
1923 + $searchData = $request->get('query', null, []);
1924 + if (is_array($searchData)) {
1925 + $searchData = map_deep($searchData, 'sanitize_text_field');
1926 + }
622 1927 $filterType = Arr::get($searchData, 'filter_type', '');
623 1928 if ($filterType == 'advanced') {
624 - return TicketHelper::saveSearchLabel($agent_id,$searchData,$filterType);
1929 + return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
625 1930 }
626 1931
627 1932 return [
628 1933 'message' => __('Invalid filter type.', 'fluent-support'),
@@ -628,9 +1933,11 @@
628 1933 'message' => __('Invalid filter type.', 'fluent-support'),
629 1934 ];
630 1935
631 1936 } catch (\Exception $e) {
632 - return $this->sendError($e->getMessage());
1937 + return $this->sendError([
1938 + 'message' => Helper::getSafeErrorMessage($e)
1939 + ]);
633 1940 }
634 1941 }
635 1942
636 1943 public function deleteLabelSearch(Request $request, $search_id)
@@ -638,9 +1945,10 @@
638 1945 try {
639 1946 $agent_id = get_current_user_id();
640 1947 return TicketHelper::deleteSavedSearch($search_id);
641 1948 } catch (\Exception $e) {
642 - return $this->sendError($e->getMessage());
1949 + return $this->sendError([
1950 + 'message' => Helper::getSafeErrorMessage($e)
1951 + ]);
643 1952 }
644 1953 }
645 1954 }
646 -