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 +1659 -390 1.5.32.4.0 View file →
@@ -3,618 +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;
10 15 use FluentSupport\App\Models\Product;
11 16 use FluentSupport\App\Models\Ticket;
12 -use FluentSupport\App\Modules\PermissionManager;
13 -use FluentSupport\App\Services\EmailNotification\Settings;
17 +use FluentSupport\App\Services\FluentCRMServices;
14 18 use FluentSupport\App\Services\Helper;
15 19 use FluentSupport\App\Services\ProfileInfoService;
16 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;
17 24 use FluentSupport\App\Services\Tickets\ResponseService;
25 +use FluentSupport\App\Models\AgentGroup;
18 26 use FluentSupport\App\Services\Tickets\TicketService;
19 -use FluentSupport\Framework\Request\Request;
20 -use FluentSupport\Framework\Support\Arr;
27 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
21 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 + */
22 36 class TicketController extends Controller
23 37 {
38 + /**
39 + * This `me` method will return the current user profile info
40 + * @param Request $request
41 + * @return array
42 + */
24 43 public function me(Request $request)
25 44 {
26 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 + }
27 55
28 56 $settings = [
29 - 'user_id' => $user->id,
57 + 'user_id' => $user->ID,
30 58 'email' => $user->user_email,
31 59 'person' => Helper::getAgentByUserId($user->ID),
32 60 'permissions' => PermissionManager::currentUserPermissions(),
33 - 'request' => $request->all()
61 + 'request' => $sanitizedRequest
34 62 ];
35 63
36 - if ($request->get('with_portal_settings')) {
37 -
64 + if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
38 65 $mimeHeadings = Helper::getAcceptedMimeHeadings();
39 - $businessSettings = (new Settings())->globalBusinessSettings();
66 + $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
40 67 $maxFileSize = absint($businessSettings['max_file_size']);
41 68
42 69 $portalSettings = [
43 - 'support_products' => Product::select(['id', 'title'])->get(),
70 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
44 71 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
45 72 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
46 73 'has_rich_text_editor' => true,
47 - 'max_file_size' => $maxFileSize,
48 - 'mime_headings' => $mimeHeadings
74 + 'max_file_size' => $maxFileSize,
75 + 'mime_headings' => $mimeHeadings
49 76 ];
50 77
51 78 $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
52 -
53 79 $settings['portal_settings'] = $portalSettings;
54 80 }
55 81
56 -
57 82 return $settings;
58 83 }
59 84
85 + /**
86 + * index method will return the list of ticket based on the selected filter
87 + * @param Request $request
88 + * @return array
89 + */
60 90 public function index(Request $request)
61 91 {
62 - $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([
63 122 'customer' => function ($query) {
64 - $query->select(['first_name', 'last_name', 'email', 'id']);
123 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
65 124 }, 'agent' => function ($query) {
66 - $query->select(['first_name', 'last_name', 'id']);
125 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
67 126 },
127 + 'mailbox',
68 128 'product',
69 129 'tags',
70 130 'preview_response' => function ($query) {
71 - $query->orderBy('id', 'desc');
131 + $query->latest('id');
72 132 }
73 133 ]);
74 134
75 135 // apply filters by access level
76 - 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]);
77 137
78 - if ($customerId = $request->get('customer_id')) {
79 - $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);
80 145 }
81 146
82 - if ($filters = $request->get('filters', [])) {
83 - $ticketsQuery->applyFilters($filters);
84 - }
147 + return [
148 + 'tickets' => $tickets
149 + ];
150 + }
85 151
86 - if ($search = $request->get('search')) {
87 - $ticketsQuery->searchBy($search);
88 - }
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', []);
89 164
90 - $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 + }
91 169
92 - $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;
93 176
94 - $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 + }
95 186
96 - foreach ($tickets as $ticket) {
97 - if ($perPage < 15) {
98 - if ($ticket->status != 'closed') {
99 - $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;
100 199 } else {
101 - $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;
102 216 }
103 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 + ]);
104 233 }
105 -
106 - return [
107 - 'tickets' => $tickets
108 - ];
109 234 }
110 235
111 - 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)
112 243 {
113 - $ticketData = $request->get('ticket', []);
114 - $this->validate($ticketData, [
115 - 'customer_id' => 'required',
116 - 'title' => 'required',
117 - 'content' => 'required'
118 - ]);
244 + try {
245 + //Get logged in agent information
246 + $agent = Helper::getAgentByUserId();
119 247
120 - $customer = Customer::findOrFail($ticketData['customer_id']);
248 + $ticketWith = $request->get('with');
249 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
121 250
122 - if (empty($ticketData['mailbox_id'])) {
123 - $mailbox = Helper::getDefaultMailBox();
124 - $ticketData['mailbox_id'] = $mailbox->id;
125 - } else {
126 - $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); // just for validation
127 - }
251 + if (!$ticketWith) {
252 + $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
253 + $q->where('status', 'active');
254 + }];
255 + }
128 256
129 - if (!empty($ticketData['product_id'])) {
130 - $data['product_source'] = 'local';
131 - }
257 + //Get ticket by id
258 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
132 259
133 - $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 + }]);
134 270
135 - $ticketData['content'] = wp_unslash(wp_kses_post($ticketData['content']));
271 + //Check if ticket is in a restricted mailbox
272 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
136 273
137 - if (!empty($ticketData['priority'])) {
138 - $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
139 - }
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 + }
140 277
141 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
278 + $this->ensureCanAccessTicket($ticket);
142 279
143 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
144 - 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;
145 284
146 - $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 + }
147 296
148 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
149 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
150 - $createdTicket->custom_fields = $createdTicket->customData();
151 - }
297 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 + }
152 299
153 - 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 + }
154 304
155 - return [
156 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
157 - 'ticket' => $createdTicket
158 - ];
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');
159 313
160 - }
314 + foreach ($ticket->responses as $response) {
315 + if ($feedbacks->has($response->id)) {
316 + $response->agent_feedback = $feedbacks->get($response->id)->value;
317 + }
318 + }
319 + }
161 320
162 - public function getTicket(Request $request, $ticketId)
163 - {
164 - $agent = Helper::getAgentByUserId();
165 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
166 - $q->where('status', 'active');
167 - }]);
168 - $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 + }
169 325
170 - $ticket = Ticket::with($ticketWith)
171 - ->findOrFail($ticketId);
326 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 + $ticket->content = $contents['ticket'];
172 328
173 - if ($ticket->customer) {
174 - $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
175 - }
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 + }
176 335
177 - 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) {
178 426 return $this->sendError([
179 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
427 + 'message' => Helper::getSafeErrorMessage($e)
180 428 ]);
181 429 }
430 + }
182 431
432 + public function getMentionableAgents(Request $request, $ticket_id)
433 + {
434 + try {
435 + $ticket = Ticket::findOrFail($ticket_id);
183 436
184 - if ($ticket->status == 'closed') {
185 - $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 + ]);
186 453 }
454 + }
187 455
188 - $responses = Conversation::where('ticket_id', $ticketId)
189 - ->with($responseWith)
190 - ->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')
191 462 ->get();
192 463
193 - foreach ($responses as $response) {
194 - $response->content = make_clickable(wpautop($response->content, false));
464 + if ($allAgents->isEmpty()) {
465 + return [];
195 466 }
196 467
197 - $ticket->content = make_clickable(wpautop($ticket->content, false));
468 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 + $ticketAccess = new AgentTicketAccess();
470 + $results = [];
198 471
199 - $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 + }
200 476
201 - if (defined('FLUENTSUPPORTPRO')) {
202 - $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 + }
203 487 }
204 488
205 - $data = [
206 - 'ticket' => $ticket,
207 - 'responses' => $responses,
208 - 'agent_id' => $agent->id
209 - ];
489 + return $results;
490 + }
210 491
211 - if (in_array('fluentcrm_profile', $request->get('with_data', [])) && defined('FLUENTCRM')) {
212 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
492 + protected function getAgentRestrictionsMap(array $agentIds)
493 + {
494 + if (!$agentIds) {
495 + return [];
213 496 }
214 497
215 - return $data;
498 + $metas = Meta::where('object_type', 'person_meta')
499 + ->where('key', 'agent_restrictions')
500 + ->whereIn('object_id', $agentIds)
501 + ->get();
216 502
503 + $restrictions = [];
504 + foreach ($metas as $meta) {
505 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
506 + }
507 +
508 + return $restrictions;
217 509 }
218 510
219 - 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)
220 520 {
221 - $data = $request->all();
521 + $data = $request->sanitize();
222 522
223 - $this->validate($data, [
224 - 'content' => 'required'
225 - ]);
523 + try {
524 + $convoType = Arr::get($data, 'conversation_type', 'response');
525 + $isDraft = $convoType === 'draft_response';
226 526
227 - $agent = Helper::getAgentByUserId(get_current_user_id());
527 + if (!$isDraft) {
528 + $this->ensureCanManageTickets();
529 + }
228 530
229 - 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) {
230 561 return $this->sendError([
231 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
562 + 'message' => Helper::getSafeErrorMessage($e)
232 563 ]);
233 564 }
565 + }
234 566
235 - $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();
236 572
237 - 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) {
238 581 return $this->sendError([
239 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
582 + 'message' => Helper::getSafeErrorMessage($e)
240 583 ]);
241 584 }
585 + }
242 586
243 - $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();
244 592
245 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
593 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
246 594
247 - return [
248 - 'message' => __('Response has been added'),
249 - 'response' => $responseData['response'],
250 - 'ticket' => $responseData['ticket'],
251 - 'update_data' => $responseData['update_data']
252 - ];
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 + }
253 616 }
254 617
255 - public function getTicketWidgets(Request $request, $ticketId)
618 + public function getFluentBookingAvailability(Request $request, $ticket_id)
256 619 {
257 - $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();
258 623
259 - 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) {
260 647 return $this->sendError([
261 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
648 + 'message' => Helper::getSafeErrorMessage($e)
262 649 ]);
263 650 }
651 + }
264 652
265 - $otherTickets = Ticket::where('id', '!=', $ticketId)
266 - ->select(['id', 'title', 'status', 'created_at'])
267 - ->where('customer_id', $ticket->customer_id)
268 - ->orderBy('id', 'DESC')
269 - ->limit(10)
270 - ->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();
271 658
272 - return [
273 - 'other_tickets' => $otherTickets,
274 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
275 - ];
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 + }
276 672 }
277 673
278 - 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)
279 683 {
280 - $assigner = Helper::getAgentByUserId(get_current_user_id());
281 - $ticket = Ticket::findOrFail($ticketId);
282 - $propName = $request->get('prop_name');
283 - $propValue = $request->get('prop_value');
684 + $data = $request->sanitize();
284 685
285 - 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) {
286 726 return $this->sendError([
287 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
727 + 'message' => Helper::getSafeErrorMessage($e)
288 728 ]);
289 729 }
730 + }
290 731
291 - $prevValue = $ticket->{$propName};
292 - if ($propName && $propValue && $prevValue != $propValue) {
293 - $ticket->{$propName} = $propValue;
294 - $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 + ]);
295 766 }
767 + }
296 768
297 - $updateData = [];
769 + public function deleteDraft($draft_id)
770 + {
771 + $draft_id = intval($draft_id);
298 772
299 - if ($propName == 'product_id') {
300 - $ticket->load('product');
301 - $updateData['product'] = $ticket->product;
302 - } else if ($propName == 'agent_id') {
303 - $ticket->load('agent');
304 - $updateData['agent'] = $ticket->agent;
305 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
306 - if ($prevValue != $ticket->{$propName}) {
307 - 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 + ]);
308 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 + ]);
309 816 }
817 + }
310 818
311 - return [
312 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
313 - 'update_data' => $updateData
314 - ];
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 + }
315 867 }
316 868
317 - 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)
318 877 {
319 - $agent = Helper::getAgentByUserId(get_current_user_id());
878 + try {
879 + $assigner = Helper::getAgentByUserId();
880 + $ticket = Ticket::findOrFail($ticket_id);
320 881
321 - $ticket = Ticket::findOrFail($ticketId);
882 + $this->ensureCanAccessTicket($ticket);
322 883
323 - 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) {
324 955 return $this->sendError([
325 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
956 + 'message' => Helper::getSafeErrorMessage($e)
326 957 ]);
327 958 }
959 + }
328 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 + {
329 972 return [
330 - 'message' => __('Ticket has been closed', 'fluent_support'),
331 - 'ticket' => (new TicketService())->close($ticket, $agent)
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
332 980 ];
333 981 }
334 982
335 - 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)
336 996 {
337 - $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;
338 1004
339 - $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 + }
340 1019
341 - 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) {
342 1124 return $this->sendError([
343 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1125 + 'message' => Helper::getSafeErrorMessage($e)
344 1126 ]);
345 1127 }
1128 + }
346 1129
347 - return [
348 - 'message' => __('Ticket has been opened again', 'fluent_support'),
349 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
350 - ];
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 + }
351 1153 }
352 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 + */
353 1163 public function doBulkActions(Request $request)
354 1164 {
355 - $ticketIds = $request->get('ticket_ids', []);
356 - $action = $request->get('bulk_action');
357 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
358 - $agent = Helper::getAgentByUserId();
359 - $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, []));
360 1168
361 - if (!$hasAllPermission) {
362 - $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 + ]);
363 1324 }
1325 + }
364 1326
365 - if ($action == 'close_tickets') {
366 - $query->where('status', '!=', 'closed');
367 - $tickets = $query->get();
368 - foreach ($tickets as $ticket) {
369 - (new TicketService())->close($ticket, $agent);
370 - }
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);
371 1336
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1339 + (new TicketService())->deleteTicket($ticket);
1340 +
372 1341 return [
373 - 'message' => sprintf(__('%d tickets have been closed', 'fluent-support'), count($tickets))
1342 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
374 1343 ];
375 - } else if ($action == 'delete_tickets') {
376 - $tickets = $query->get();
1344 + } catch (\Exception $e) {
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
1348 + }
1349 + }
377 1350
378 - foreach ($tickets as $ticket) {
379 - $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 + }
380 1377 }
381 1378
382 - return [
383 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
384 - ];
385 - } else if ($action == 'assign_agent') {
386 - $agentId = absint($request->get('agent_id'));
387 - if (!$agentId) {
388 - $this->sendError([
389 - 'message' => __('agent_id param is required', 'fluent-support')
390 - ]);
391 - }
1379 + $this->validate($data, [
1380 + 'content' => 'required',
1381 + 'ticket_ids' => 'required|array'
1382 + ]);
392 1383
393 - $agent = Agent::findOrFail($agentId);
1384 + //Get logged in agent information
1385 + $agent = Helper::getAgentByUserId();
1386 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
394 1387
395 - $query->where(function ($q) use ($agent) {
396 - $q->where('agent_id', '!=', $agent->id)
397 - ->orWhereNull('agent_id');
398 - });
1388 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
399 1389
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392 +
400 1393 $tickets = $query->get();
401 1394
402 - foreach ($tickets as $ticket) {
403 - $ticket->agent_id = $agent->id;
404 - $ticket->save();
405 - 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'));
406 1397 }
407 1398
408 - return [
409 - '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'),
410 1403 ];
411 - } else if ($action == 'assign_tags') {
412 1404
413 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
414 - if (!$tags) {
415 - $this->sendError([
416 - 'message' => __('tag_ids param is required', 'fluent-support')
417 - ]);
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();
418 1413 }
419 1414
420 - $tickets = $query->get();
1415 + $responseService = new ResponseService();
421 1416
422 1417 foreach ($tickets as $ticket) {
423 - $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);
424 1441 }
425 1442
426 1443 return [
427 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1444 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
428 1445 ];
429 -
1446 + } catch (\Exception $e) {
1447 + return $this->sendError([
1448 + 'message' => Helper::getSafeErrorMessage($e)
1449 + ]);
430 1450 }
431 -
432 - $this->sendError([
433 - 'message' => __('Sorry no action found as available', 'fluent-support')
434 - ]);
435 1451 }
436 1452
437 - 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)
438 1462 {
439 - $data = $request->all();
440 - $this->validate($data, [
441 - 'content' => 'required',
442 - 'ticket_ids' => 'required|array'
443 - ]);
1463 + try {
1464 + $ticket = Ticket::findOrFail($ticket_id);
444 1465
445 - $ticketIds = $request->get('ticket_ids');
446 - $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 + }
447 1469
448 - $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);
449 1473
450 - $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 + }
451 1481
452 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
453 1485
454 - if (!$hasAllPermission) {
455 - $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 + ]);
456 1496 }
1497 + }
457 1498
458 - $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);
459 1511
460 - if ($tickets->isEmpty()) {
461 - $this->sendError([
462 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
463 - ]);
464 - }
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 + }
465 1515
466 - $responseData = [
467 - 'content' => $request->get('content'),
468 - 'conversation_type' => $request->get('conversation_type', 'response'),
469 - 'close_ticket' => $request->get('close_ticket', 'no')
470 - ];
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
471 1519
472 - $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();
473 1525
474 - if ($attachments) {
475 - $attachments = Attachment::whereNull('ticket_id')
476 - ->orderBy('id', 'asc')
477 - ->whereIn('file_hash', $attachments)
478 - ->get();
479 - }
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 + }
480 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 + }
481 1542
482 - $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');
483 1546
484 - foreach ($tickets as $ticket) {
485 - if ($attachments) {
486 - $responseData['attachments'] = [];
487 - foreach ($attachments as $attachment) {
488 - $attachedFile = $attachment->replicate();
489 - $attachedFile->ticket_id = $ticket->id;
490 - $attachedFile->save();
491 - $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 + );
492 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 + );
493 1559 }
494 1560
495 - $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 + ]);
496 1580 }
1581 + }
497 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 + }
498 1591
499 - return [
500 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
501 - ];
1592 + $ticket = Ticket::findOrFail($ticket_id);
502 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 + }
503 1619 }
504 1620
505 - public function deleteResponse(Request $request, $ticketId, $responseId)
1621 + protected function approveDraftConversation($ticket, $response, $person, $content)
506 1622 {
507 - $ticket = Ticket::findOrFail($ticketId);
508 - $response = Conversation::findOrFail($responseId);
509 - $agent = Helper::getAgentByUserId();
1623 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
510 1624
511 - $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();
512 1629
513 - if (!$hasAllPermission) {
514 - if ($ticket->agent_id != $agent->id) {
515 - return $this->sendError([
516 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
517 - ]);
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;
518 1636 }
519 1637 }
520 1638
521 - 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 + }
522 1643
523 - return [
524 - 'message' => __('Selected response has been deleted', 'fluent-support')
525 - ];
1644 + $ticket->response_count += 1;
1645 + $ticket->save();
526 1646
1647 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 +
1649 + return $response;
527 1650 }
528 1651
529 - 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)
530 1659 {
531 - $data = $request->all();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
532 1662
533 - $this->validate($data, [
534 - 'content' => 'required'
535 - ]);
1663 + $this->ensureCanAccessTicket($ticket);
536 1664
537 - $ticket = Ticket::findOrFail($ticketId);
538 - $response = Conversation::findOrFail($responseId);
539 - $agent = Helper::getAgentByUserId();
1665 + $agent = Helper::getAgentByUserId();
540 1666
541 - $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 + }
542 1676
543 - if (!$hasAllPermission) {
544 - if ($ticket->agent_id != $agent->id) {
545 - return $this->sendError([
546 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
547 - ]);
548 - }
549 - }
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);
550 1687
551 - $response->content = wp_unslash(wp_kses_post($data['content']));
552 - $response->save();
1688 + $this->ensureCanAccessTicket($ticket);
553 1689
554 - return [
555 - 'message' => __('Selected response has been updated', 'fluent-support'),
556 - 'response' => $response
557 - ];
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 + }
558 1701 }
559 1702
560 - 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)
561 1710 {
562 - $agent = Helper::getAgentByUserId();
1711 + try {
1712 + $ticket = Ticket::findOrFail($ticket_id);
563 1713
564 - return [
565 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
566 - ];
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 + }
567 1727 }
568 1728
569 - 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)
570 1736 {
571 - $agent = Helper::getAgentByUserId();
1737 + try {
1738 + $ticket = Ticket::findOrFail($ticket_id);
572 1739
573 - return [
574 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
575 - 'agent_id' => $agent->id
576 - ];
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 + }
577 1753 }
578 1754
579 - 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)
580 1762 {
581 - $ticket = Ticket::findOrFail($ticketId);
1763 + $ticketId = (int) $ticket_id;
1764 + $newCustomerId = $request->getSafe('customer', 'intval');
582 1765
583 - $tagId = intval($request->get('tag_id'));
1766 + if (!$newCustomerId) {
1767 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 + }
584 1769
585 - if (!$ticket->hasTag($tagId)) {
586 - $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'));
587 1775 }
588 1776
589 - return [
590 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
591 - 'tags' => $ticket->tags
592 - ];
593 - }
1777 + try {
1778 + $ticket = Ticket::findOrFail($ticketId);
594 1779
595 - public function detachTag($ticketId, $tagId)
596 - {
597 - $ticket = Ticket::findOrFail($ticketId);
598 - $ticket->tags()->detach($tagId);
1780 + $this->ensureCanAccessTicket($ticket);
599 1781
600 - return [
601 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
602 - 'tags' => $ticket->tags
603 - ];
604 - }
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
605 1785
606 - public function changeTicketCustomer(Request $request)
607 - {
608 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
609 - ->update(['customer_id' => $request->get('customer')]);
610 - return [
611 - 'message' => __('Customer has been updated', 'fluent-support'),
612 - 'updatedCustomer' => $updateCustomer
613 - ];
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 + }
614 1804 }
615 1805
616 - 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)
617 1813 {
618 1814 if (!defined('FLUENTSUPPORTPRO')) {
619 1815 return [
620 1816 'custom_data' => [],
@@ -621,65 +1817,138 @@
621 1817 'rendered_fields' => []
622 1818 ];
623 1819 }
624 1820
625 - $ticket = Ticket::findOrFail($ticketId);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
626 1823
627 - return [
628 - 'custom_data' => (object)$ticket->customData(),
629 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
630 - ];
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 + }
631 1835 }
632 1836
633 - 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)
634 1845 {
1846 + $data = [
1847 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 + 'tags' => $request->get('tags', null)
1849 + ];
635 1850
636 - 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) {
637 1859 return $this->sendError([
638 - 'message' => 'FluentCRM is not installed'
1860 + 'message' => Helper::getSafeErrorMessage($e)
639 1861 ]);
640 1862 }
1863 + }
641 1864
642 - $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 + */
643 1872
644 - 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) {
645 1888 return $this->sendError([
646 - 'message' => 'Contact could not be found'
1889 + 'message' => Helper::getSafeErrorMessage($e)
647 1890 ]);
648 1891 }
1892 + }
649 1893
650 - $tagIds = array_filter($request->get('tags', []), 'absint');
651 - $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
652 - $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');
653 1903
654 - 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) {
655 1913 return $this->sendError([
656 - 'message' => 'Sorry you do not have permission to add contact tags'
1914 + 'message' => Helper::getSafeErrorMessage($e)
657 1915 ]);
658 1916 }
1917 + }
659 1918
660 - $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 + }
661 1931
662 - $existingTags = $contact->tags;
663 - $existingTagIds = [];
664 - foreach ($existingTags as $tag) {
665 - $existingTagIds[] = $tag->id;
666 - }
667 - $newTagIds = array_diff($tagIds, $existingTagIds);
668 - $removedTagIds = array_diff($existingTagIds, $tagIds);
1932 + return [
1933 + 'message' => __('Invalid filter type.', 'fluent-support'),
1934 + ];
669 1935
670 - if ($newTagIds) {
671 - $contact->attachTags($newTagIds);
1936 + } catch (\Exception $e) {
1937 + return $this->sendError([
1938 + 'message' => Helper::getSafeErrorMessage($e)
1939 + ]);
672 1940 }
1941 + }
673 1942
674 - if ($removedTagIds) {
675 - $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 + ]);
676 1952 }
677 -
678 -
679 - return [
680 - 'tags' => $contact->tags,
681 - 'message' => 'FluentCRM contact tags has been updated'
682 - ];
683 -
684 1953 }
685 1954 }