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 +1676 -382 1.5.22.4.0 View file →
@@ -3,593 +3,1814 @@
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 5 use FluentSupport\App\Models\Agent;
6 6 use FluentSupport\App\Models\Attachment;
7 +use FluentSupport\App\Models\Meta;
7 8 use FluentSupport\App\Models\Customer;
9 +use FluentSupport\Framework\Http\Request\Request;
10 +use FluentSupport\Framework\Support\Arr;
11 +use FluentSupport\App\Http\Requests\TicketRequest;
12 +use FluentSupport\App\Http\Requests\TicketResponseRequest;
13 +use FluentSupport\App\Models\Conversation;
8 14 use FluentSupport\App\Models\MailBox;
9 -use FluentSupport\App\Models\Conversation;
15 +use FluentSupport\App\Models\Product;
10 16 use FluentSupport\App\Models\Ticket;
11 -use FluentSupport\App\Modules\PermissionManager;
17 +use FluentSupport\App\Services\FluentCRMServices;
12 18 use FluentSupport\App\Services\Helper;
13 19 use FluentSupport\App\Services\ProfileInfoService;
14 20 use FluentSupport\App\Services\TicketHelper;
21 +use FluentSupport\App\Services\TicketQueryService;
22 +use FluentSupport\App\Modules\PermissionManager;
23 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
15 24 use FluentSupport\App\Services\Tickets\ResponseService;
25 +use FluentSupport\App\Models\AgentGroup;
16 26 use FluentSupport\App\Services\Tickets\TicketService;
17 -use FluentSupport\Framework\Request\Request;
18 -use FluentSupport\Framework\Support\Arr;
27 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
19 28
29 +/**
30 + * TicketController class for REST API related to ticket
31 + * This class is responsible for getting / inserting/ modifying data for all request related to ticket
32 + * @package FluentSupport\App\Http\Controllers
33 + *
34 + * @version 1.0.0
35 + */
20 36 class TicketController extends Controller
21 37 {
38 + /**
39 + * This `me` method will return the current user profile info
40 + * @param Request $request
41 + * @return array
42 + */
22 43 public function me(Request $request)
23 44 {
24 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 + }
25 55
26 - return [
27 - 'user_id' => $user->id,
56 + $settings = [
57 + 'user_id' => $user->ID,
28 58 'email' => $user->user_email,
29 59 'person' => Helper::getAgentByUserId($user->ID),
30 60 'permissions' => PermissionManager::currentUserPermissions(),
31 - 'request' => $request->all()
61 + 'request' => $sanitizedRequest
32 62 ];
63 +
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']);
68 +
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;
33 83 }
34 84
85 + /**
86 + * index method will return the list of ticket based on the selected filter
87 + * @param Request $request
88 + * @return array
89 + */
35 90 public function index(Request $request)
36 91 {
37 - $ticketsQuery = Ticket::with([
92 + //Selected filter type, either simple or Advanced
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([
38 122 'customer' => function ($query) {
39 - $query->select(['first_name', 'last_name', 'email', 'id']);
123 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
40 124 }, 'agent' => function ($query) {
41 - $query->select(['first_name', 'last_name', 'id']);
125 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
42 126 },
127 + 'mailbox',
43 128 'product',
44 129 'tags',
45 130 'preview_response' => function ($query) {
46 - $query->orderBy('id', 'desc');
131 + $query->latest('id');
47 132 }
48 133 ]);
49 134
50 135 // apply filters by access level
51 - do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsQuery, false]);
136 + do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
52 137
53 - if ($customerId = $request->get('customer_id')) {
54 - $ticketsQuery = $ticketsQuery->where('customer_id', $customerId);
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);
55 145 }
56 146
57 - if ($filters = $request->get('filters', [])) {
58 - $ticketsQuery->applyFilters($filters);
59 - }
147 + return [
148 + 'tickets' => $tickets
149 + ];
150 + }
60 151
61 - if ($search = $request->get('search')) {
62 - $ticketsQuery->searchBy($search);
63 - }
152 + /**
153 + * createTicket method will create new ticket as well as customer or WP user
154 + * @param TicketRequest $request
155 + * @return array
156 + */
157 + public function createTicket(TicketRequest $request)
158 + {
159 + try {
160 + //Sanitize and validate request data via TicketRequest
161 + $data = $request->sanitize();
162 + $ticketData = $data['ticket'];
163 + $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
64 164
65 - $ticketsQuery->orderBy($request->get('order_by', 'id'), $request->get('order_type', 'ASC'));
165 + //Include attachments if provided
166 + if (!empty($data['attachments'])) {
167 + $ticketData['attachments'] = $data['attachments'];
168 + }
66 169
67 - $tickets = $ticketsQuery->paginate();
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;
68 176
69 - $perPage = $request->get('per_page');
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 + }
70 186
71 - foreach ($tickets as $ticket) {
72 - if ($perPage < 15) {
73 - if ($ticket->status != 'closed') {
74 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
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 + }
193 +
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;
75 199 } else {
76 - $ticket->live_activity = [];
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;
77 216 }
78 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) {
230 + return $this->sendError([
231 + 'message' => Helper::getSafeErrorMessage($e)
232 + ]);
79 233 }
80 -
81 - return [
82 - 'tickets' => $tickets
83 - ];
84 234 }
85 235
86 - public function createTicket(Request $request)
236 + /**
237 + * getTicket method will return ticket information by ticket id
238 + * @param Request $request
239 + * @param $ticket_id
240 + * @return array
241 + */
242 + public function getTicket(Request $request, $ticket_id)
87 243 {
88 - $ticketData = $request->get('ticket', []);
89 - $this->validate($ticketData, [
90 - 'customer_id' => 'required',
91 - 'title' => 'required',
92 - 'content' => 'required'
93 - ]);
244 + try {
245 + //Get logged in agent information
246 + $agent = Helper::getAgentByUserId();
94 247
95 - $customer = Customer::findOrFail($ticketData['customer_id']);
248 + $ticketWith = $request->get('with');
249 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
96 250
97 - if (empty($ticketData['mailbox_id'])) {
98 - $mailbox = Helper::getDefaultMailBox();
99 - $ticketData['mailbox_id'] = $mailbox->id;
100 - } else {
101 - $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); // just for validation
102 - }
251 + if (!$ticketWith) {
252 + $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
253 + $q->where('status', 'active');
254 + }];
255 + }
103 256
104 - if (!empty($ticketData['product_id'])) {
105 - $data['product_source'] = 'local';
106 - }
257 + //Get ticket by id
258 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
107 259
108 - $ticketData['title'] = sanitize_text_field(wp_unslash($ticketData['title']));
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 + }]);
109 270
110 - $ticketData['content'] = wp_unslash(wp_kses_post($ticketData['content']));
271 + //Check if ticket is in a restricted mailbox
272 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
111 273
112 - if (!empty($ticketData['priority'])) {
113 - $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
114 - }
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 + }
115 277
116 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
278 + $this->ensureCanAccessTicket($ticket);
117 279
118 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
119 - do_action('fluent_support/before_ticket_create', $ticketData, $customer);
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;
120 284
121 - $createdTicket = Ticket::create($ticketData);
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 + }
122 296
123 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
124 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
125 - $createdTicket->custom_fields = $createdTicket->customData();
126 - }
297 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 + }
127 299
128 - do_action('fluent_support/ticket_created', $createdTicket, $customer);
300 + //If ticket is closed, load closed by person
301 + if ($ticket->status == 'closed') {
302 + $ticket->load('closed_by_person');
303 + }
129 304
130 - return [
131 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
132 - 'ticket' => $createdTicket
133 - ];
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');
134 313
135 - }
314 + foreach ($ticket->responses as $response) {
315 + if ($feedbacks->has($response->id)) {
316 + $response->agent_feedback = $feedbacks->get($response->id)->value;
317 + }
318 + }
319 + }
136 320
137 - public function getTicket(Request $request, $ticketId)
138 - {
139 - $agent = Helper::getAgentByUserId();
140 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
141 - $q->where('status', 'active');
142 - }]);
143 - $responseWith = $request->get('response_with', ['person', 'attachments']);
321 + $contents = ['ticket' => $ticket->content];
322 + foreach ($ticket->responses as $response) {
323 + $contents['response_' . $response->id] = $response->content;
324 + }
144 325
145 - $ticket = Ticket::with($ticketWith)
146 - ->findOrFail($ticketId);
326 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 + $ticket->content = $contents['ticket'];
147 328
148 - if ($ticket->customer) {
149 - $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
150 - }
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 + }
151 335
152 - if (!PermissionManager::hasTicketPermission($ticket)) {
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;
425 + } catch (\Exception $e) {
153 426 return $this->sendError([
154 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
427 + 'message' => Helper::getSafeErrorMessage($e)
155 428 ]);
156 429 }
430 + }
157 431
432 + public function getMentionableAgents(Request $request, $ticket_id)
433 + {
434 + try {
435 + $ticket = Ticket::findOrFail($ticket_id);
158 436
159 - if ($ticket->status == 'closed') {
160 - $ticket->load('closed_by_person');
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 + ]);
161 453 }
454 + }
162 455
163 - $responses = Conversation::where('ticket_id', $ticketId)
164 - ->with($responseWith)
165 - ->orderBy('id', 'DESC')
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')
166 462 ->get();
167 463
168 - foreach ($responses as $response) {
169 - $response->content = make_clickable(wpautop($response->content, false));
464 + if ($allAgents->isEmpty()) {
465 + return [];
170 466 }
171 467
172 - $ticket->content = make_clickable(wpautop($ticket->content, false));
468 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 + $ticketAccess = new AgentTicketAccess();
470 + $results = [];
173 471
174 - $ticket->live_activity = TicketHelper::getActivity($ticketId, $agent->id);
472 + foreach ($allAgents as $agent) {
473 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
474 + continue;
475 + }
175 476
176 - if (defined('FLUENTSUPPORTPRO')) {
177 - $ticket->custom_fields = $ticket->customData('admin', true);
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 + }
178 487 }
179 488
180 - $data = [
181 - 'ticket' => $ticket,
182 - 'responses' => $responses,
183 - 'agent_id' => $agent->id
184 - ];
489 + return $results;
490 + }
185 491
186 - if (in_array('fluentcrm_profile', $request->get('with_data', [])) && defined('FLUENTCRM')) {
187 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
492 + protected function getAgentRestrictionsMap(array $agentIds)
493 + {
494 + if (!$agentIds) {
495 + return [];
188 496 }
189 497
190 - return $data;
498 + $metas = Meta::where('object_type', 'person_meta')
499 + ->where('key', 'agent_restrictions')
500 + ->whereIn('object_id', $agentIds)
501 + ->get();
191 502
503 + $restrictions = [];
504 + foreach ($metas as $meta) {
505 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
506 + }
507 +
508 + return $restrictions;
192 509 }
193 510
194 - public function createResponse(Request $request, $ticketId)
511 + /**
512 + * createResponse method will create response by agent for the ticket
513 + * @param Request $request
514 + * @param Ticket $ticket
515 + * @param int $ticket_id
516 + * @return array
517 + * @throws \FluentSupport\Framework\Validator\ValidationException
518 + */
519 + public function createResponse(TicketResponseRequest $request, $ticket_id)
195 520 {
196 - $data = $request->all();
521 + $data = $request->sanitize();
197 522
198 - $this->validate($data, [
199 - 'content' => 'required'
200 - ]);
523 + try {
524 + $convoType = Arr::get($data, 'conversation_type', 'response');
525 + $isDraft = $convoType === 'draft_response';
201 526
202 - $agent = Helper::getAgentByUserId(get_current_user_id());
527 + if (!$isDraft) {
528 + $this->ensureCanManageTickets();
529 + }
203 530
204 - if (!$agent) {
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) {
205 561 return $this->sendError([
206 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
562 + 'message' => Helper::getSafeErrorMessage($e)
207 563 ]);
208 564 }
565 + }
209 566
210 - $ticket = Ticket::findOrFail($ticketId);
567 + public function getFluentBookingEventTypes()
568 + {
569 + try {
570 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
571 + $this->ensureCanManageTickets();
211 572
212 - if (!PermissionManager::hasTicketPermission($ticket)) {
573 + $service = new FluentBookingService();
574 + $eventTypes = $service->getEventTypes();
575 +
576 + return [
577 + 'status' => $service->getStatus($eventTypes),
578 + 'event_types' => $eventTypes
579 + ];
580 + } catch (\Exception $e) {
213 581 return $this->sendError([
214 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
582 + 'message' => Helper::getSafeErrorMessage($e)
215 583 ]);
216 584 }
585 + }
217 586
218 - $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
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();
219 592
220 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
593 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
221 594
222 - return [
223 - 'message' => __('Response has been added'),
224 - 'response' => $responseData['response'],
225 - 'ticket' => $responseData['ticket'],
226 - 'update_data' => $responseData['update_data']
227 - ];
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 + }
228 616 }
229 617
230 - public function getTicketWidgets(Request $request, $ticketId)
618 + public function getFluentBookingAvailability(Request $request, $ticket_id)
231 619 {
232 - $ticket = Ticket::with('customer')->findOrFail($ticketId);
620 + try {
621 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
622 + $this->ensureCanManageTickets();
233 623
234 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
235 647 return $this->sendError([
236 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
648 + 'message' => Helper::getSafeErrorMessage($e)
237 649 ]);
238 650 }
651 + }
239 652
240 - $otherTickets = Ticket::where('id', '!=', $ticketId)
241 - ->select(['id', 'title', 'status', 'created_at'])
242 - ->where('customer_id', $ticket->customer_id)
243 - ->orderBy('id', 'DESC')
244 - ->limit(10)
245 - ->get();
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();
246 658
247 - return [
248 - 'other_tickets' => $otherTickets,
249 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
250 - ];
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 + }
251 672 }
252 673
253 - public function updateTicketProperty(Request $request, $ticketId)
674 + /**
675 + * createDraft method will create draft by agent for the ticket
676 + * @param Request $request
677 + * @param Ticket $ticket
678 + * @param int $ticket_id
679 + * @return array
680 + * @throws \FluentSupport\Framework\Validator\ValidationException
681 + */
682 + public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
254 683 {
255 - $assigner = Helper::getAgentByUserId(get_current_user_id());
256 - $ticket = Ticket::findOrFail($ticketId);
257 - $propName = $request->get('prop_name');
258 - $propValue = $request->get('prop_value');
684 + $data = $request->sanitize();
259 685
260 - if (!PermissionManager::hasTicketPermission($ticket)) {
686 + try {
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 + ];
725 + } catch (\Exception $e) {
261 726 return $this->sendError([
262 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
727 + 'message' => Helper::getSafeErrorMessage($e)
263 728 ]);
264 729 }
730 + }
265 731
266 - $prevValue = $ticket->{$propName};
267 - if ($propName && $propValue && $prevValue != $propValue) {
268 - $ticket->{$propName} = $propValue;
269 - $ticket->save();
732 + public function getDraft($ticket_id)
733 + {
734 + try {
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 + ];
762 + } catch (\Exception $e) {
763 + return $this->sendError([
764 + 'message' => Helper::getSafeErrorMessage($e)
765 + ]);
270 766 }
767 + }
271 768
272 - $updateData = [];
769 + public function deleteDraft($draft_id)
770 + {
771 + $draft_id = intval($draft_id);
273 772
274 - if ($propName == 'product_id') {
275 - $ticket->load('product');
276 - $updateData['product'] = $ticket->product;
277 - } else if ($propName == 'agent_id') {
278 - $ticket->load('agent');
279 - $updateData['agent'] = $ticket->agent;
280 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
281 - if ($prevValue != $ticket->{$propName}) {
282 - do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket);
773 + try {
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 + ]);
283 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 + ];
812 + } catch (\Exception $e) {
813 + return $this->sendError([
814 + 'message' => Helper::getSafeErrorMessage($e)
815 + ]);
284 816 }
817 + }
285 818
286 - return [
287 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
288 - 'update_data' => $updateData
289 - ];
819 + /**
820 + * getTicketWidgets method generate additional information for a ticket by customer
821 + * @param Ticket $ticket
822 + * @param $ticket_id
823 + * @return array
824 + */
825 + public function getTicketWidgets(Request $request, $ticket_id)
826 + {
827 + try {
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;
862 + } catch (\Exception $e) {
863 + return $this->sendError([
864 + 'message' => Helper::getSafeErrorMessage($e)
865 + ]);
866 + }
290 867 }
291 868
292 - public function closeTicket(Request $request, $ticketId)
869 + /**
870 + * updateTicketProperty method will update ticket property
871 + * @param Request $request
872 + * @param Ticket $ticket
873 + * @param $ticket_id
874 + * @return array
875 + */
876 + public function updateTicketProperty(Request $request, $ticket_id)
293 877 {
294 - $agent = Helper::getAgentByUserId(get_current_user_id());
878 + try {
879 + $assigner = Helper::getAgentByUserId();
880 + $ticket = Ticket::findOrFail($ticket_id);
295 881
296 - $ticket = Ticket::findOrFail($ticketId);
882 + $this->ensureCanAccessTicket($ticket);
297 883
298 - if (!PermissionManager::hasTicketPermission($ticket)) {
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 + ];
954 + } catch (\Exception $e) {
299 955 return $this->sendError([
300 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
956 + 'message' => Helper::getSafeErrorMessage($e)
301 957 ]);
302 958 }
959 + }
303 960
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 + {
304 972 return [
305 - 'message' => __('Ticket has been closed', 'fluent_support'),
306 - 'ticket' => (new TicketService())->close($ticket, $agent)
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
307 980 ];
308 981 }
309 982
310 - public function reOpenTicket(Request $request, $ticketId)
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)
311 996 {
312 - $agent = Helper::getAgentByUserId(get_current_user_id());
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;
313 1004
314 - $ticket = Ticket::findOrFail($ticketId);
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 + }
315 1019
316 - if (!PermissionManager::hasTicketPermission($ticket)) {
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 + /**
1104 + * closeTicket method close the ticket by id
1105 + * @param Ticket $ticket
1106 + * @param int $ticket_id
1107 + * @return array
1108 + */
1109 + public function closeTicket(Request $request, $ticket_id)
1110 + {
1111 + try {
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 + ];
1123 + } catch (\Exception $e) {
317 1124 return $this->sendError([
318 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1125 + 'message' => Helper::getSafeErrorMessage($e)
319 1126 ]);
320 1127 }
1128 + }
321 1129
322 - return [
323 - 'message' => __('Ticket has been opened again', 'fluent_support'),
324 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
325 - ];
1130 + /**
1131 + * reOpenTicket method will reopen a closed ticket
1132 + * @param Request $request
1133 + * @param $ticket_id
1134 + * @return array
1135 + */
1136 + public function reOpenTicket($ticket_id)
1137 + {
1138 + try {
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 + ];
1148 + } catch (\Exception $e) {
1149 + return $this->sendError([
1150 + 'message' => Helper::getSafeErrorMessage($e)
1151 + ]);
1152 + }
326 1153 }
327 1154
1155 + /**
1156 + * doBulkActions method is responsible for bulk action
1157 + * This function will get ticket ids and action as parameter and perform action based on the selection
1158 + * @param Request $request
1159 + * @param Ticket $ticket
1160 + * @return array|string[]|void
1161 + * @throws \Exception
1162 + */
328 1163 public function doBulkActions(Request $request)
329 1164 {
330 - $ticketIds = $request->get('ticket_ids', []);
331 - $action = $request->get('bulk_action');
332 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
333 - $agent = Helper::getAgentByUserId();
334 - $query = Ticket::whereIn('id', $ticketIds);
1165 + try {
1166 + $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1167 + $ticketIds = array_map('intval', $request->get('ticket_ids', null, []));
335 1168
336 - if (!$hasAllPermission) {
337 - $query->where('agent_id', $agent->id);
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'));
1320 + } catch (\Exception $e) {
1321 + return $this->sendError([
1322 + 'message' => Helper::getSafeErrorMessage($e)
1323 + ]);
338 1324 }
1325 + }
339 1326
340 - if ($action == 'close_tickets') {
341 - $query->where('status', '!=', 'closed');
342 - $tickets = $query->get();
343 - foreach ($tickets as $ticket) {
344 - (new TicketService())->close($ticket, $agent);
345 - }
1327 + /**
1328 + * deleteTicket method will delete a ticket
1329 + * @param int $ticket_id
1330 + * @return array
1331 + */
1332 + public function deleteTicket($ticket_id)
1333 + {
1334 + try {
1335 + $ticket = Ticket::findOrFail($ticket_id);
346 1336
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1339 + (new TicketService())->deleteTicket($ticket);
1340 +
347 1341 return [
348 - 'message' => sprintf(__('%d tickets have been closed', 'fluent-support'), count($tickets))
1342 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
349 1343 ];
350 - } else if ($action == 'delete_tickets') {
351 - $tickets = $query->get();
1344 + } catch (\Exception $e) {
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
1348 + }
1349 + }
352 1350
353 - foreach ($tickets as $ticket) {
354 - $ticket->deleteTicket();
1351 + /**
1352 + * doBulkReplies method will create response for bulk tickets
1353 + * This function will get ticket ids, content, attachment etc and create response for tickets
1354 + * @param Request $request
1355 + * @param Conversation $conversation
1356 + * @return array
1357 + * @throws \Exception
1358 + */
1359 + public function doBulkReplies(Request $request)
1360 + {
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 + }
355 1377 }
356 1378
357 - return [
358 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
359 - ];
360 - } else if ($action == 'assign_agent') {
361 - $agentId = absint($request->get('agent_id'));
362 - if (!$agentId) {
363 - $this->sendError([
364 - 'message' => __('agent_id param is required', 'fluent-support')
365 - ]);
366 - }
1379 + $this->validate($data, [
1380 + 'content' => 'required',
1381 + 'ticket_ids' => 'required|array'
1382 + ]);
367 1383
368 - $agent = Agent::findOrFail($agentId);
1384 + //Get logged in agent information
1385 + $agent = Helper::getAgentByUserId();
1386 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
369 1387
370 - $query->where(function ($q) use ($agent) {
371 - $q->where('agent_id', '!=', $agent->id)
372 - ->orWhereNull('agent_id');
373 - });
1388 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
374 1389
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392 +
375 1393 $tickets = $query->get();
376 1394
377 - foreach ($tickets as $ticket) {
378 - $ticket->agent_id = $agent->id;
379 - $ticket->save();
380 - do_action('fluent_support/agent_assigned_to_ticket', $agent, $ticket);
1395 + if ($tickets->isEmpty()) {
1396 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
381 1397 }
382 1398
383 - return [
384 - 'message' => __(count($tickets) . ' tickets has been assigned to', 'fluent-support') . ' ' . $agent->full_name
1399 + $responseData = [
1400 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1401 + 'conversation_type' => 'response',
1402 + 'close_ticket' => Arr::get($data, 'close_ticket'),
385 1403 ];
386 - } else if ($action == 'assign_tags') {
387 1404
388 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
389 - if (!$tags) {
390 - $this->sendError([
391 - 'message' => __('tag_ids param is required', 'fluent-support')
392 - ]);
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();
393 1413 }
394 1414
395 - $tickets = $query->get();
1415 + $responseService = new ResponseService();
396 1416
397 1417 foreach ($tickets as $ticket) {
398 - $ticket->applyTags($tags);
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);
399 1441 }
400 1442
401 1443 return [
402 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1444 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
403 1445 ];
404 -
1446 + } catch (\Exception $e) {
1447 + return $this->sendError([
1448 + 'message' => Helper::getSafeErrorMessage($e)
1449 + ]);
405 1450 }
406 -
407 - $this->sendError([
408 - 'message' => __('Sorry no action found as available', 'fluent-support')
409 - ]);
410 1451 }
411 1452
412 - public function doBulkReplies(Request $request)
1453 + /**
1454 + * deleteResponse method will remove a response from ticket by ticket id and response id
1455 + * @param Request $request
1456 + * @param Conversation $conversation
1457 + * @param $ticket_id
1458 + * @param $response_id
1459 + * @return array
1460 + */
1461 + public function deleteResponse($ticket_id, $response_id)
413 1462 {
414 - $data = $request->all();
415 - $this->validate($data, [
416 - 'content' => 'required',
417 - 'ticket_ids' => 'required|array'
418 - ]);
1463 + try {
1464 + $ticket = Ticket::findOrFail($ticket_id);
419 1465
420 - $ticketIds = $request->get('ticket_ids');
421 - $ticketIds = array_filter($ticketIds, 'absint');
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 + }
422 1469
423 - $agent = Helper::getAgentByUserId();
1470 + // The caller must have access to this specific ticket (visibility +
1471 + // ownership + mailbox), not merely a global manage capability.
1472 + $this->ensureCanAccessTicket($ticket);
424 1473
425 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
426 1481
427 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
428 1485
429 - if (!$hasAllPermission) {
430 - $query->where('agent_id', $agent->id);
1486 + $response->delete();
1487 + $response->ccinfo()->delete();
1488 +
1489 + return [
1490 + 'message' => __('Selected response has been deleted', 'fluent-support')
1491 + ];
1492 + } catch (\Exception $e) {
1493 + return $this->sendError([
1494 + 'message' => Helper::getSafeErrorMessage($e)
1495 + ]);
431 1496 }
1497 + }
432 1498
433 - $tickets = $query->get();
1499 + /**
1500 + * updateResponse method will update ticket response using ticket and response id
1501 + * @param Request $request
1502 + * @param int $ticket_id
1503 + * @param int $response_id
1504 + * @return array
1505 + * @throws \Exception
1506 + */
1507 + public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1508 + {
1509 + try {
1510 + $ticket = Ticket::findOrFail($ticket_id);
434 1511
435 - if ($tickets->isEmpty()) {
436 - $this->sendError([
437 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
438 - ]);
439 - }
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 + }
440 1515
441 - $responseData = [
442 - 'content' => $request->get('content'),
443 - 'conversation_type' => $request->get('conversation_type', 'response'),
444 - 'close_ticket' => $request->get('close_ticket', 'no')
445 - ];
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
446 1519
447 - $attachments = $request->get('attachments', []);
1520 + $response = Conversation::where('id', $response_id)
1521 + ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1523 + ->firstOrFail();
1524 + $agent = Helper::getAgentByUserId();
448 1525
449 - if ($attachments) {
450 - $attachments = Attachment::whereNull('ticket_id')
451 - ->orderBy('id', 'asc')
452 - ->whereIn('file_hash', $attachments)
453 - ->get();
454 - }
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 + }
455 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 + }
456 1542
457 - $responseService = new ResponseService();
1543 + $isDraft = $response->conversation_type == 'draft_response';
1544 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
458 1546
459 - foreach ($tickets as $ticket) {
460 - if ($attachments) {
461 - $responseData['attachments'] = [];
462 - foreach ($attachments as $attachment) {
463 - $attachedFile = $attachment->replicate();
464 - $attachedFile->ticket_id = $ticket->id;
465 - $attachedFile->save();
466 - $responseData['attachments'][] = $attachedFile->file_hash;
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 + );
467 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 + );
468 1559 }
469 1560
470 - $responseService->createResponse($responseData, $agent, $ticket);
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 + ]);
471 1580 }
1581 + }
472 1582
1583 + public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1584 + {
1585 + try {
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 + }
473 1591
474 - return [
475 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
476 - ];
1592 + $ticket = Ticket::findOrFail($ticket_id);
477 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 + ];
1614 + } catch (\Exception $e) {
1615 + return $this->sendError([
1616 + 'message' => Helper::getSafeErrorMessage($e)
1617 + ]);
1618 + }
478 1619 }
479 1620
480 - public function deleteResponse(Request $request, $ticketId, $responseId)
1621 + protected function approveDraftConversation($ticket, $response, $person, $content)
481 1622 {
482 - $ticket = Ticket::findOrFail($ticketId);
483 - $response = Conversation::findOrFail($responseId);
484 - $agent = Helper::getAgentByUserId();
1623 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
485 1624
486 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1625 + $response->content = $content;
1626 + $response->conversation_type = 'response';
1627 + $response->created_at = current_time('mysql');
1628 + $response->save();
487 1629
488 - if (!$hasAllPermission) {
489 - if ($ticket->agent_id != $agent->id) {
490 - return $this->sendError([
491 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
492 - ]);
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;
493 1636 }
494 1637 }
495 1638
496 - Conversation::where('id', $response->id)->delete();
1639 + if ($resetWaitingSince) {
1640 + $ticket->last_agent_response = current_time('mysql');
1641 + $ticket->waiting_since = current_time('mysql');
1642 + }
497 1643
498 - return [
499 - 'message' => __('Selected response has been deleted', 'fluent-support')
500 - ];
1644 + $ticket->response_count += 1;
1645 + $ticket->save();
501 1646
1647 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 +
1649 + return $response;
502 1650 }
503 1651
504 - public function updateResponse(Request $request, $ticketId, $responseId)
1652 + /**
1653 + * getLiveActivity method will return the activity in a ticket by agents
1654 + * @param Request $request
1655 + * @param $ticket_id
1656 + * @return array
1657 + */
1658 + public function getLiveActivity(Request $request, $ticket_id)
505 1659 {
506 - $data = $request->all();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
507 1662
508 - $this->validate($data, [
509 - 'content' => 'required'
510 - ]);
1663 + $this->ensureCanAccessTicket($ticket);
511 1664
512 - $ticket = Ticket::findOrFail($ticketId);
513 - $response = Conversation::findOrFail($responseId);
514 - $agent = Helper::getAgentByUserId();
1665 + $agent = Helper::getAgentByUserId();
515 1666
516 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
1675 + }
517 1676
518 - if (!$hasAllPermission) {
519 - if ($ticket->agent_id != $agent->id) {
520 - return $this->sendError([
521 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
522 - ]);
523 - }
524 - }
1677 + /**
1678 + * removeLiveActivity method will remove activities that
1679 + * @param Request $request
1680 + * @param $ticket_id
1681 + * @return array
1682 + */
1683 + public function removeLiveActivity(Request $request, $ticket_id)
1684 + {
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
525 1687
526 - $response->content = wp_unslash(wp_kses_post($data['content']));
527 - $response->save();
1688 + $this->ensureCanAccessTicket($ticket);
528 1689
529 - return [
530 - 'message' => __('Selected response has been updated', 'fluent-support'),
531 - 'response' => $response
532 - ];
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 + }
533 1701 }
534 1702
535 - public function getLiveActivity(Request $request, $ticketId)
1703 + /**
1704 + * addTag method will add tag in ticket by ticket id
1705 + * @param Request $request
1706 + * @param $ticket_id
1707 + * @return array
1708 + */
1709 + public function addTag(Request $request, $ticket_id)
536 1710 {
537 - $agent = Helper::getAgentByUserId();
1711 + try {
1712 + $ticket = Ticket::findOrFail($ticket_id);
538 1713
539 - return [
540 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
541 - ];
1714 + $this->ensureCanAccessTicket($ticket);
1715 +
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 + }
542 1727 }
543 1728
544 - public function removeLiveActivity(Request $request, $ticketId)
1729 + /**
1730 + * detachTag method will remove all tags from tickets
1731 + * @param $ticket_id
1732 + * @param $tag_id
1733 + * @return array
1734 + */
1735 + public function detachTag($ticket_id, $tag_id)
545 1736 {
546 - $agent = Helper::getAgentByUserId();
1737 + try {
1738 + $ticket = Ticket::findOrFail($ticket_id);
547 1739
548 - return [
549 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
550 - 'agent_id' => $agent->id
551 - ];
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 + }
552 1753 }
553 1754
554 - public function addTag(Request $request, $ticketId)
1755 + /**
1756 + * changeTicketCustomer method will update customer in a ticket
1757 + * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
1758 + * @param Request $request
1759 + * @return array
1760 + */
1761 + public function changeTicketCustomer(Request $request, $ticket_id)
555 1762 {
556 - $ticket = Ticket::findOrFail($ticketId);
1763 + $ticketId = (int) $ticket_id;
1764 + $newCustomerId = $request->getSafe('customer', 'intval');
557 1765
558 - $tagId = intval($request->get('tag_id'));
1766 + if (!$newCustomerId) {
1767 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 + }
559 1769
560 - if (!$ticket->hasTag($tagId)) {
561 - $ticket->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
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'));
562 1775 }
563 1776
564 - return [
565 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
566 - 'tags' => $ticket->tags
567 - ];
568 - }
1777 + try {
1778 + $ticket = Ticket::findOrFail($ticketId);
569 1779
570 - public function detachTag($ticketId, $tagId)
571 - {
572 - $ticket = Ticket::findOrFail($ticketId);
573 - $ticket->tags()->detach($tagId);
1780 + $this->ensureCanAccessTicket($ticket);
574 1781
575 - return [
576 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
577 - 'tags' => $ticket->tags
578 - ];
579 - }
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
580 1785
581 - public function changeTicketCustomer(Request $request)
582 - {
583 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
584 - ->update(['customer_id' => $request->get('customer')]);
585 - return [
586 - 'message' => __('Customer has been updated', 'fluent-support'),
587 - 'updatedCustomer' => $updateCustomer
588 - ];
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 +
1799 + } catch (\Exception $e) {
1800 + return $this->sendError([
1801 + 'message' => Helper::getSafeErrorMessage($e)
1802 + ]);
1803 + }
589 1804 }
590 1805
591 - public function getTicketCustomData(Request $request, $ticketId)
1806 + /**
1807 + * getTicketCustomData method will return the custom data by ticket id
1808 + * @param Request $request
1809 + * @param $ticket_id
1810 + * @return array|array[]
1811 + */
1812 + public function getTicketCustomData(Request $request, $ticket_id)
592 1813 {
593 1814 if (!defined('FLUENTSUPPORTPRO')) {
594 1815 return [
595 1816 'custom_data' => [],
@@ -596,65 +1817,138 @@
596 1817 'rendered_fields' => []
597 1818 ];
598 1819 }
599 1820
600 - $ticket = Ticket::findOrFail($ticketId);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
601 1823
602 - return [
603 - 'custom_data' => (object)$ticket->customData(),
604 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
605 - ];
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 + }
606 1835 }
607 1836
608 - public function syncFluentCrmTags(Request $request)
1837 + /**
1838 + * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
1839 + *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
1840 + * @param Request $request
1841 + * @param FluentCRMServices $fluentCRMServices
1842 + * @return array
1843 + */
1844 + public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
609 1845 {
1846 + $data = [
1847 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 + 'tags' => $request->get('tags', null)
1849 + ];
610 1850
611 - if (!defined('FLUENTCRM')) {
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 +
1856 + try {
1857 + return $fluentCRMServices->syncCrmTags($data);
1858 + } catch (\Exception $e) {
612 1859 return $this->sendError([
613 - 'message' => 'FluentCRM is not installed'
1860 + 'message' => Helper::getSafeErrorMessage($e)
614 1861 ]);
615 1862 }
1863 + }
616 1864
617 - $contactId = absint($request->get('contact_id'));
1865 + /**
1866 + * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
1867 + * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
1868 + * @param Request $request
1869 + * @param FluentCRMServices $fluentCRMServices
1870 + * @return array
1871 + */
618 1872
619 - if (!$contactId) {
1873 + public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
1874 + {
1875 + $data = [
1876 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1877 + 'lists' => $request->get('lists', null, [])
1878 + ];
1879 +
1880 + // Sanitize lists array if it's an array
1881 + if (is_array($data['lists'])) {
1882 + $data['lists'] = array_map('intval', $data['lists']);
1883 + }
1884 +
1885 + try {
1886 + return $fluentCRMServices->syncCrmLists($data);
1887 + } catch (\Exception $e) {
620 1888 return $this->sendError([
621 - 'message' => 'Contact could not be found'
1889 + 'message' => Helper::getSafeErrorMessage($e)
622 1890 ]);
623 1891 }
1892 + }
624 1893
625 - $tagIds = array_filter($request->get('tags', []), 'absint');
626 - $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
627 - $canAddTags = apply_filters('fluent_support/can_user_add_tags_to_customer', $canAddTags);
1894 + /**
1895 + * Get ticket essentials data based on the provided types.
1896 + *
1897 + * @param \Illuminate\Http\Request $request
1898 + * @return array The ticket essentials data.
1899 + */
1900 + public function getTicketEssentials(Request $request)
1901 + {
1902 + $type = $request->getSafe('type', 'sanitize_text_field');
628 1903
629 - if (!$canAddTags) {
1904 + return TicketHelper::getTicketEssentials($type);
1905 + }
1906 +
1907 + public function fetchLabelSearch()
1908 + {
1909 + try {
1910 + $agent_id = get_current_user_id();
1911 + return TicketHelper::getLabelSearch($agent_id);
1912 + } catch (\Exception $e) {
630 1913 return $this->sendError([
631 - 'message' => 'Sorry you do not have permission to add contact tags'
1914 + 'message' => Helper::getSafeErrorMessage($e)
632 1915 ]);
633 1916 }
1917 + }
634 1918
635 - $contact = \FluentCrm\App\Models\Subscriber::findOrFail($contactId);
1919 + public function storeOrUpdateLabelSearch(Request $request)
1920 + {
1921 + try {
1922 + $agent_id = get_current_user_id();
1923 + $searchData = $request->get('query', null, []);
1924 + if (is_array($searchData)) {
1925 + $searchData = map_deep($searchData, 'sanitize_text_field');
1926 + }
1927 + $filterType = Arr::get($searchData, 'filter_type', '');
1928 + if ($filterType == 'advanced') {
1929 + return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
1930 + }
636 1931
637 - $existingTags = $contact->tags;
638 - $existingTagIds = [];
639 - foreach ($existingTags as $tag) {
640 - $existingTagIds[] = $tag->id;
641 - }
642 - $newTagIds = array_diff($tagIds, $existingTagIds);
643 - $removedTagIds = array_diff($existingTagIds, $tagIds);
1932 + return [
1933 + 'message' => __('Invalid filter type.', 'fluent-support'),
1934 + ];
644 1935
645 - if ($newTagIds) {
646 - $contact->attachTags($newTagIds);
1936 + } catch (\Exception $e) {
1937 + return $this->sendError([
1938 + 'message' => Helper::getSafeErrorMessage($e)
1939 + ]);
647 1940 }
1941 + }
648 1942
649 - if ($removedTagIds) {
650 - $contact->detachTags($removedTagIds);
1943 + public function deleteLabelSearch(Request $request, $search_id)
1944 + {
1945 + try {
1946 + $agent_id = get_current_user_id();
1947 + return TicketHelper::deleteSavedSearch($search_id);
1948 + } catch (\Exception $e) {
1949 + return $this->sendError([
1950 + 'message' => Helper::getSafeErrorMessage($e)
1951 + ]);
651 1952 }
652 -
653 -
654 - return [
655 - 'tags' => $contact->tags,
656 - 'message' => 'FluentCRM contact tags has been updated'
657 - ];
658 -
659 1953 }
660 1954 }