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 +1700 -357 1.4.02.4.0 View file →
@@ -3,609 +3,1952 @@
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;
27 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
18 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 + */
19 36 class TicketController extends Controller
20 37 {
38 + /**
39 + * This `me` method will return the current user profile info
40 + * @param Request $request
41 + * @return array
42 + */
21 43 public function me(Request $request)
22 44 {
23 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 + }
24 55
25 - return [
26 - 'user_id' => $user->id,
56 + $settings = [
57 + 'user_id' => $user->ID,
27 58 'email' => $user->user_email,
28 59 'person' => Helper::getAgentByUserId($user->ID),
29 60 'permissions' => PermissionManager::currentUserPermissions(),
30 - 'request' => $request->all()
61 + 'request' => $sanitizedRequest
31 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;
32 83 }
33 84
85 + /**
86 + * index method will return the list of ticket based on the selected filter
87 + * @param Request $request
88 + * @return array
89 + */
34 90 public function index(Request $request)
35 91 {
36 - $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([
37 122 'customer' => function ($query) {
38 - $query->select(['first_name', 'last_name', 'email', 'id']);
123 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
39 124 }, 'agent' => function ($query) {
40 - $query->select(['first_name', 'last_name', 'id']);
125 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
41 126 },
127 + 'mailbox',
42 128 'product',
43 129 'tags',
44 130 'preview_response' => function ($query) {
45 - $query->orderBy('id', 'desc');
131 + $query->latest('id');
46 132 }
47 133 ]);
48 134
49 135 // apply filters by access level
50 - $permissionLevel = PermissionManager::currentUserTicketsPermissionLevel();
51 - if ($permissionLevel != 'all') {
52 - $agent = Helper::getAgentByUserId();
53 - if ($permissionLevel == 'own') {
54 - $ticketsQuery->where('agent_id', $agent->id);
55 - } else {
56 - $ticketsQuery->where(function ($q) use ($agent) {
57 - $q->where('agent_id', $agent->id);
58 - $q->orWhereNull('agent_id');
59 - });
60 - }
136 + do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
137 +
138 + $tickets = $ticketsModel->paginate();
139 +
140 + $perPage = $request->getSafe('per_page', 'intval', 15);
141 +
142 + // Load live activity for small page sizes (board/kanban view)
143 + if ($perPage < 15) {
144 + TicketHelper::loadBatchLiveActivities($tickets);
61 145 }
62 146
63 - if ($customerId = $request->get('customer_id')) {
64 - $ticketsQuery = $ticketsQuery->where('customer_id', $customerId);
65 - }
147 + return [
148 + 'tickets' => $tickets
149 + ];
150 + }
66 151
67 - if ($filters = $request->get('filters', [])) {
68 - $ticketsQuery->applyFilters($filters);
69 - }
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', []);
70 164
71 - if ($search = $request->get('search')) {
72 - $ticketsQuery->searchBy($search);
73 - }
165 + //Include attachments if provided
166 + if (!empty($data['attachments'])) {
167 + $ticketData['attachments'] = $data['attachments'];
168 + }
74 169
75 - $ticketsQuery->orderBy($request->get('order_by', 'id'), $request->get('order_type', 'ASC'));
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;
76 176
77 - $tickets = $ticketsQuery->paginate();
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 + }
78 186
79 - $perPage = $request->get('per_page');
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 + }
80 193
81 - foreach ($tickets as $ticket) {
82 - if ($perPage < 15) {
83 - if ($ticket->status != 'closed') {
84 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
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;
85 199 } else {
86 - $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;
87 216 }
88 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 + ]);
89 233 }
90 -
91 - return [
92 - 'tickets' => $tickets
93 - ];
94 234 }
95 235
96 - 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)
97 243 {
98 - $ticketData = $request->get('ticket', []);
99 - $this->validate($ticketData, [
100 - 'customer_id' => 'required',
101 - 'title' => 'required',
102 - 'content' => 'required'
103 - ]);
244 + try {
245 + //Get logged in agent information
246 + $agent = Helper::getAgentByUserId();
104 247
105 - $customer = Customer::findOrFail($ticketData['customer_id']);
248 + $ticketWith = $request->get('with');
249 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
106 250
107 - if (empty($ticketData['mailbox_id'])) {
108 - $mailbox = Helper::getDefaultMailBox();
109 - $ticketData['mailbox_id'] = $mailbox->id;
110 - } else {
111 - $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); // just for validation
112 - }
251 + if (!$ticketWith) {
252 + $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
253 + $q->where('status', 'active');
254 + }];
255 + }
113 256
114 - if (!empty($ticketData['product_id'])) {
115 - $data['product_source'] = 'local';
116 - }
257 + //Get ticket by id
258 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
117 259
118 - $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 + }]);
119 270
120 - $ticketData['content'] = wp_unslash(wp_kses_post($ticketData['content']));
271 + //Check if ticket is in a restricted mailbox
272 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
121 273
122 - if (!empty($ticketData['priority'])) {
123 - $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
124 - }
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 + }
125 277
126 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
278 + $this->ensureCanAccessTicket($ticket);
127 279
128 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
129 - 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;
130 284
131 - $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 + }
132 296
133 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
134 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
135 - $createdTicket->custom_fields = $createdTicket->customData();
136 - }
297 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
298 + }
137 299
138 - 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 + }
139 304
140 - return [
141 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
142 - 'ticket' => $createdTicket
143 - ];
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');
144 313
314 + foreach ($ticket->responses as $response) {
315 + if ($feedbacks->has($response->id)) {
316 + $response->agent_feedback = $feedbacks->get($response->id)->value;
317 + }
318 + }
319 + }
320 +
321 + $contents = ['ticket' => $ticket->content];
322 + foreach ($ticket->responses as $response) {
323 + $contents['response_' . $response->id] = $response->content;
324 + }
325 +
326 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 + $ticket->content = $contents['ticket'];
328 +
329 + //Format response content
330 + foreach ($ticket->responses as $response) {
331 + $responseKey = 'response_' . $response->id;
332 + if (isset($contents[$responseKey])) {
333 + $response->content = $contents[$responseKey];
334 + }
335 +
336 + $responseContent = apply_filters(
337 + 'fluent_support/response_content_before_render',
338 + $response->content,
339 + $response,
340 + $ticket
341 + );
342 +
343 + if ($response->conversation_type === 'note') {
344 + $responseContent = wpautop($responseContent, false);
345 + } else {
346 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
347 + }
348 +
349 +
350 + $response->content = apply_filters(
351 + 'fluent_support/response_content_after_render',
352 + $responseContent,
353 + $response,
354 + $ticket
355 + );
356 +
357 + if (!empty($response->ccinfo)) {
358 + $val = Helper::safeUnserialize($response->ccinfo->value);
359 + if (isset($val['cc_email']) && !empty($val['cc_email'])) {
360 + $response->cc_info = $val['cc_email'];
361 + } else {
362 + $response->cc_info = '';
363 + }
364 + } else {
365 + $response->cc_info = '';
366 + }
367 + }
368 +
369 + $ticketContent = apply_filters(
370 + 'fluent_support/ticket_content_before_render',
371 + $ticket->content,
372 + $ticket
373 + );
374 +
375 + $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
376 +
377 + $ticket->content = apply_filters(
378 + 'fluent_support/ticket_content_after_render',
379 + $ticketContent,
380 + $ticket
381 + );
382 +
383 + //Get last activity by agent
384 + $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385 +
386 + //Get all carbon copy customer
387 + $ccInfo = $ticket->getSettingsValue('cc_email', []);
388 + $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
389 +
390 + if (defined('FLUENTSUPPORTPRO')) {
391 + $ticket->custom_fields = $ticket->customData('admin', true);
392 + }
393 +
394 + // Load agent info if ticket was created on behalf of customer
395 + if ($ticket->created_by) {
396 + $ticket->load('created_by_person');
397 + if ($ticket->created_by_person) {
398 + $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
399 + $ticket->created_by_agent = [
400 + 'id' => $ticket->created_by_person->id,
401 + 'full_name' => $ticket->created_by_person->full_name,
402 + 'agent_initiated' => $isAgentInitiated,
403 + ];
404 + }
405 + }
406 +
407 + $data = [
408 + 'ticket' => $ticket,
409 + 'responses' => $ticket->responses,
410 + 'agent_id' => $agent->id
411 + ];
412 +
413 + if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
414 + $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
415 + }
416 +
417 + $withData = $request->get('with_data', null);
418 + $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
419 +
420 + if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
421 + $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
422 + }
423 +
424 + return $data;
425 + } catch (\Exception $e) {
426 + return $this->sendError([
427 + 'message' => Helper::getSafeErrorMessage($e)
428 + ]);
429 + }
145 430 }
146 431
147 - public function getTicket(Request $request, $ticketId)
432 + public function getMentionableAgents(Request $request, $ticket_id)
148 433 {
149 - $agent = Helper::getAgentByUserId();
150 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
151 - $q->where('status', 'active');
152 - }]);
153 - $responseWith = $request->get('response_with', ['person', 'attachments']);
434 + try {
435 + $ticket = Ticket::findOrFail($ticket_id);
154 436
155 - $ticket = Ticket::with($ticketWith)
156 - ->findOrFail($ticketId);
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 + }
157 440
158 - if ($ticket->customer) {
159 - $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
160 - }
441 + $this->ensureCanAccessTicket($ticket);
161 442
162 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
163 450 return $this->sendError([
164 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
451 + 'message' => Helper::getSafeErrorMessage($e)
165 452 ]);
166 453 }
454 + }
167 455
456 + protected function getMentionableAgentList($ticket, $search, $limit)
457 + {
458 + $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
459 + ->mentionBy($search)
460 + ->orderBy('first_name')
461 + ->orderBy('last_name')
462 + ->get();
168 463
169 - if ($ticket->status == 'closed') {
170 - $ticket->load('closed_by_person');
464 + if ($allAgents->isEmpty()) {
465 + return [];
171 466 }
172 467
173 - $responses = Conversation::where('ticket_id', $ticketId)
174 - ->with($responseWith)
175 - ->orderBy('id', 'DESC')
176 - ->get();
468 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 + $ticketAccess = new AgentTicketAccess();
470 + $results = [];
177 471
178 - foreach ($responses as $response) {
179 - $response->content = make_clickable(wpautop($response->content, false));
472 + foreach ($allAgents as $agent) {
473 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
474 + continue;
475 + }
476 +
477 + $results[] = [
478 + 'id' => strval($agent->id),
479 + 'first_name' => $agent->first_name,
480 + 'last_name' => $agent->last_name,
481 + 'email' => $agent->email,
482 + ];
483 +
484 + if (count($results) >= $limit) {
485 + break;
486 + }
180 487 }
181 488
182 - $ticket->content = make_clickable(wpautop($ticket->content, false));
489 + return $results;
490 + }
183 491
184 - $ticket->live_activity = TicketHelper::getActivity($ticketId, $agent->id);
492 + protected function getAgentRestrictionsMap(array $agentIds)
493 + {
494 + if (!$agentIds) {
495 + return [];
496 + }
185 497
186 - if (defined('FLUENTSUPPORTPRO')) {
187 - $ticket->custom_fields = $ticket->customData('admin', true);
498 + $metas = Meta::where('object_type', 'person_meta')
499 + ->where('key', 'agent_restrictions')
500 + ->whereIn('object_id', $agentIds)
501 + ->get();
502 +
503 + $restrictions = [];
504 + foreach ($metas as $meta) {
505 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
188 506 }
189 507
190 - return [
191 - 'ticket' => $ticket,
192 - 'responses' => $responses,
193 - 'agent_id' => $agent->id
194 - ];
508 + return $restrictions;
195 509 }
196 510
197 - 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)
198 520 {
199 - $data = $request->all();
521 + $data = $request->sanitize();
200 522
201 - $this->validate($data, [
202 - 'content' => 'required'
203 - ]);
523 + try {
524 + $convoType = Arr::get($data, 'conversation_type', 'response');
525 + $isDraft = $convoType === 'draft_response';
204 526
205 - $agent = Helper::getAgentByUserId(get_current_user_id());
527 + if (!$isDraft) {
528 + $this->ensureCanManageTickets();
529 + }
206 530
207 - 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) {
208 561 return $this->sendError([
209 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
562 + 'message' => Helper::getSafeErrorMessage($e)
210 563 ]);
211 564 }
565 + }
212 566
213 - $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();
214 572
215 - 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) {
216 581 return $this->sendError([
217 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
582 + 'message' => Helper::getSafeErrorMessage($e)
218 583 ]);
219 584 }
585 + }
220 586
221 - $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();
222 592
223 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
593 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
224 594
225 - return [
226 - 'message' => __('Response has been added'),
227 - 'response' => $responseData['response'],
228 - 'ticket' => $responseData['ticket'],
229 - 'update_data' => $responseData['update_data']
230 - ];
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 + }
231 616 }
232 617
233 - public function getTicketWidgets(Request $request, $ticketId)
618 + public function getFluentBookingAvailability(Request $request, $ticket_id)
234 619 {
235 - $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();
236 623
237 - 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) {
238 647 return $this->sendError([
239 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
648 + 'message' => Helper::getSafeErrorMessage($e)
240 649 ]);
241 650 }
651 + }
242 652
243 - $otherTickets = Ticket::where('id', '!=', $ticketId)
244 - ->select(['id', 'title', 'status', 'created_at'])
245 - ->where('customer_id', $ticket->customer_id)
246 - ->orderBy('id', 'DESC')
247 - ->limit(10)
248 - ->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();
249 658
250 - return [
251 - 'other_tickets' => $otherTickets,
252 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
253 - ];
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 + }
254 672 }
255 673
256 - 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)
257 683 {
258 - $assigner = Helper::getAgentByUserId(get_current_user_id());
259 - $ticket = Ticket::findOrFail($ticketId);
260 - $propName = $request->get('prop_name');
261 - $propValue = $request->get('prop_value');
684 + $data = $request->sanitize();
262 685
263 - 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) {
264 726 return $this->sendError([
265 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
727 + 'message' => Helper::getSafeErrorMessage($e)
266 728 ]);
267 729 }
730 + }
268 731
269 - $prevValue = $ticket->{$propName};
270 - if ($propName && $propValue && $prevValue != $propValue) {
271 - $ticket->{$propName} = $propValue;
272 - $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 + ]);
273 766 }
767 + }
274 768
275 - $updateData = [];
769 + public function deleteDraft($draft_id)
770 + {
771 + $draft_id = intval($draft_id);
276 772
277 - if ($propName == 'product_id') {
278 - $ticket->load('product');
279 - $updateData['product'] = $ticket->product;
280 - } else if ($propName == 'agent_id') {
281 - $ticket->load('agent');
282 - $updateData['agent'] = $ticket->agent;
283 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
284 - if ($prevValue != $ticket->{$propName}) {
285 - 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 + ]);
286 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 + ]);
287 816 }
817 + }
288 818
289 - return [
290 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
291 - 'update_data' => $updateData
292 - ];
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 + }
293 867 }
294 868
295 - 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)
296 877 {
297 - $agent = Helper::getAgentByUserId(get_current_user_id());
878 + try {
879 + $assigner = Helper::getAgentByUserId();
880 + $ticket = Ticket::findOrFail($ticket_id);
298 881
299 - $ticket = Ticket::findOrFail($ticketId);
882 + $this->ensureCanAccessTicket($ticket);
300 883
301 - 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) {
302 955 return $this->sendError([
303 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
956 + 'message' => Helper::getSafeErrorMessage($e)
304 957 ]);
305 958 }
959 + }
306 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 + {
307 972 return [
308 - 'message' => __('Ticket has been closed', 'fluent_support'),
309 - 'ticket' => (new TicketService())->close($ticket, $agent)
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
310 980 ];
311 981 }
312 982
313 - 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)
314 996 {
315 - $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;
316 1004
317 - $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 + }
318 1019
319 - 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) {
320 1124 return $this->sendError([
321 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1125 + 'message' => Helper::getSafeErrorMessage($e)
322 1126 ]);
323 1127 }
1128 + }
324 1129
325 - return [
326 - 'message' => __('Ticket has been opened again', 'fluent_support'),
327 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
328 - ];
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 + }
329 1153 }
330 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 + */
331 1163 public function doBulkActions(Request $request)
332 1164 {
333 - $ticketIds = $request->get('ticket_ids', []);
334 - $action = $request->get('bulk_action');
335 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
336 - $agent = Helper::getAgentByUserId();
337 - $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, []));
338 1168
339 - if (!$hasAllPermission) {
340 - $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 + ]);
341 1324 }
1325 + }
342 1326
343 - if ($action == 'close_tickets') {
344 - $query->where('status', '!=', 'closed');
345 - $tickets = $query->get();
346 - foreach ($tickets as $ticket) {
347 - (new TicketService())->close($ticket, $agent);
348 - }
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);
349 1336
1337 + $this->ensureCanAccessTicket($ticket);
1338 +
1339 + (new TicketService())->deleteTicket($ticket);
1340 +
350 1341 return [
351 - 'message' => sprintf( __('%d tickets have been closed', 'fluent-support'), count($tickets))
1342 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
352 1343 ];
353 - } else if ($action == 'delete_tickets') {
1344 + } catch (\Exception $e) {
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
1348 + }
1349 + }
1350 +
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 + }
1377 + }
1378 +
1379 + $this->validate($data, [
1380 + 'content' => 'required',
1381 + 'ticket_ids' => 'required|array'
1382 + ]);
1383 +
1384 + //Get logged in agent information
1385 + $agent = Helper::getAgentByUserId();
1386 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
1387 +
1388 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1389 +
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1392 +
354 1393 $tickets = $query->get();
355 1394
356 - foreach ($tickets as $ticket) {
357 - $ticket->deleteTicket();
1395 + if ($tickets->isEmpty()) {
1396 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
358 1397 }
359 1398
360 - return [
361 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
1399 + $responseData = [
1400 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1401 + 'conversation_type' => 'response',
1402 + 'close_ticket' => Arr::get($data, 'close_ticket'),
362 1403 ];
363 - } else if ($action == 'assign_agent') {
364 - $agentId = absint($request->get('agent_id'));
365 - if (!$agentId) {
366 - $this->sendError([
367 - 'message' => __('agent_id param is required', 'fluent-support')
368 - ]);
1404 +
1405 + //If request with file attachments
1406 + $attachmentHashes = Arr::get($data, 'attachments', []);
1407 + $attachments = false;
1408 + if ($attachmentHashes) {
1409 + $attachments = Attachment::whereNull('ticket_id')
1410 + ->orderBy('id', 'asc')
1411 + ->whereIn('file_hash', $attachmentHashes)
1412 + ->get();
369 1413 }
370 1414
371 - $agent = Agent::findOrFail($agentId);
1415 + $responseService = new ResponseService();
372 1416
373 - $query->where(function ($q) use ($agent) {
374 - $q->where('agent_id', '!=', $agent->id)
375 - ->orWhereNull('agent_id');
376 - });
1417 + foreach ($tickets as $ticket) {
1418 + if ($attachments) {
1419 + $responseData['attachments'] = [];
1420 + $attachmentRecords = [];
1421 + foreach ($attachments as $attachment) {
1422 + $fileHash = bin2hex(random_bytes(16));
1423 + $attachmentRecords[] = [
1424 + 'ticket_id' => $ticket->id,
1425 + 'file_path' => $attachment->file_path,
1426 + 'full_url' => $attachment->full_url,
1427 + 'title' => $attachment->title,
1428 + 'driver' => $attachment->driver,
1429 + 'file_size' => $attachment->file_size,
1430 + 'status' => $attachment->status,
1431 + 'file_hash' => $fileHash,
1432 + ];
1433 + $responseData['attachments'][] = $fileHash;
1434 + }
1435 + if ($attachmentRecords) {
1436 + Attachment::insert($attachmentRecords);
1437 + }
1438 + }
377 1439
378 - $tickets = $query->get();
379 -
380 - foreach ($tickets as $ticket) {
381 - $ticket->agent_id = $agent->id;
382 - $ticket->save();
383 - do_action('fluent_support/agent_assigned_to_ticket', $agent, $ticket);
1440 + $responseService->createResponse($responseData, $agent, $ticket);
384 1441 }
385 1442
386 1443 return [
387 - 'message' => __(count($tickets) . ' tickets has been assigned to', 'fluent-support') . ' ' . $agent->full_name
1444 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
388 1445 ];
389 - } else if ($action == 'assign_tags') {
1446 + } catch (\Exception $e) {
1447 + return $this->sendError([
1448 + 'message' => Helper::getSafeErrorMessage($e)
1449 + ]);
1450 + }
1451 + }
390 1452
391 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
392 - if (!$tags) {
393 - $this->sendError([
394 - 'message' => __('tag_ids param is required', 'fluent-support')
395 - ]);
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)
1462 + {
1463 + try {
1464 + $ticket = Ticket::findOrFail($ticket_id);
1465 +
1466 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1467 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'));
396 1468 }
397 1469
398 - $tickets = $query->get();
1470 + // The caller must have access to this specific ticket (visibility +
1471 + // ownership + mailbox), not merely a global manage capability.
1472 + $this->ensureCanAccessTicket($ticket);
399 1473
400 - foreach ($tickets as $ticket) {
401 - $ticket->applyTags($tags);
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 + );
402 1480 }
403 1481
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
1485 +
1486 + $response->delete();
1487 + $response->ccinfo()->delete();
1488 +
404 1489 return [
405 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1490 + 'message' => __('Selected response has been deleted', 'fluent-support')
406 1491 ];
407 -
1492 + } catch (\Exception $e) {
1493 + return $this->sendError([
1494 + 'message' => Helper::getSafeErrorMessage($e)
1495 + ]);
408 1496 }
409 -
410 - $this->sendError([
411 - 'message' => __('Sorry no action found as available', 'fluent-support')
412 - ]);
413 1497 }
414 1498
415 - public function doBulkReplies(Request $request)
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)
416 1508 {
417 - $data = $request->all();
418 - $this->validate($data, [
419 - 'content' => 'required',
420 - 'ticket_ids' => 'required|array'
421 - ]);
1509 + try {
1510 + $ticket = Ticket::findOrFail($ticket_id);
422 1511
423 - $ticketIds = $request->get('ticket_ids');
424 - $ticketIds = array_filter($ticketIds, 'absint');
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 + }
425 1515
426 - $agent = Helper::getAgentByUserId();
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
427 1519
428 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1520 + $response = Conversation::where('id', $response_id)
1521 + ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1523 + ->firstOrFail();
1524 + $agent = Helper::getAgentByUserId();
429 1525
430 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
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 + }
431 1534
432 - if (!$hasAllPermission) {
433 - $query->where('agent_id', $agent->id);
434 - }
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 + }
435 1542
436 - $tickets = $query->get();
1543 + $isDraft = $response->conversation_type == 'draft_response';
1544 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
437 1546
438 - if ($tickets->isEmpty()) {
439 - $this->sendError([
440 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
1547 + if ($isDraft && !$isAuthor) {
1548 + // Another agent's draft can only be edited/approved by an approver.
1549 + if (!$canApproveDraft) {
1550 + throw new \Exception(
1551 + esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 + );
1553 + }
1554 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 + // Editing another agent's response requires manage-others capability.
1556 + throw new \Exception(
1557 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 + );
1559 + }
1560 +
1561 + // Request input is already unslashed at the boundary; unslashing again
1562 + // would strip literal backslashes out of the edited reply.
1563 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 + $response->content = $content;
1565 +
1566 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1568 + } else {
1569 + $response->save();
1570 + }
1571 +
1572 + return [
1573 + 'message' => __('Selected response has been updated', 'fluent-support'),
1574 + 'response' => $response
1575 + ];
1576 + } catch (\Exception $e) {
1577 + return $this->sendError([
1578 + 'message' => Helper::getSafeErrorMessage($e)
441 1579 ]);
442 1580 }
1581 + }
443 1582
444 - $responseData = [
445 - 'content' => $request->get('content'),
446 - 'conversation_type' => $request->get('conversation_type', 'response'),
447 - 'close_ticket' => $request->get('close_ticket', 'no')
448 - ];
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 + }
449 1591
450 - $attachments = $request->get('attachments', []);
1592 + $ticket = Ticket::findOrFail($ticket_id);
451 1593
452 - if ($attachments) {
453 - $attachments = Attachment::whereNull('ticket_id')
454 - ->orderBy('id', 'asc')
455 - ->whereIn('file_hash', $attachments)
456 - ->get();
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 + ]);
457 1618 }
1619 + }
458 1620
1621 + protected function approveDraftConversation($ticket, $response, $person, $content)
1622 + {
1623 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
459 1624
460 - $responseService = new ResponseService();
1625 + $response->content = $content;
1626 + $response->conversation_type = 'response';
1627 + $response->created_at = current_time('mysql');
1628 + $response->save();
461 1629
462 - foreach ($tickets as $ticket) {
463 - if ($attachments) {
464 - $responseData['attachments'] = [];
465 - foreach ($attachments as $attachment) {
466 - $attachedFile = $attachment->replicate();
467 - $attachedFile->ticket_id = $ticket->id;
468 - $attachedFile->save();
469 - $responseData['attachments'][] = $attachedFile->file_hash;
470 - }
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;
471 1636 }
1637 + }
472 1638
473 - $responseService->createResponse($responseData, $agent, $ticket);
1639 + if ($resetWaitingSince) {
1640 + $ticket->last_agent_response = current_time('mysql');
1641 + $ticket->waiting_since = current_time('mysql');
474 1642 }
475 1643
1644 + $ticket->response_count += 1;
1645 + $ticket->save();
476 1646
477 - return [
478 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
479 - ];
1647 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
480 1648
1649 + return $response;
481 1650 }
482 1651
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)
1659 + {
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
483 1662
484 - public function deleteResponse(Request $request, $ticketId, $responseId)
1663 + $this->ensureCanAccessTicket($ticket);
1664 +
1665 + $agent = Helper::getAgentByUserId();
1666 +
1667 + return [
1668 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1669 + ];
1670 + } catch (\Exception $e) {
1671 + return $this->sendError([
1672 + 'message' => Helper::getSafeErrorMessage($e)
1673 + ]);
1674 + }
1675 + }
1676 +
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)
485 1684 {
486 - $ticket = Ticket::findOrFail($ticketId);
487 - $response = Conversation::findOrFail($responseId);
488 - $agent = Helper::getAgentByUserId();
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
489 1687
490 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1688 + $this->ensureCanAccessTicket($ticket);
491 1689
492 - if (!$hasAllPermission) {
493 - if ($ticket->agent_id != $agent->id) {
494 - return $this->sendError([
495 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
496 - ]);
497 - }
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 + ]);
498 1700 }
1701 + }
499 1702
500 - Conversation::where('id', $response->id)->delete();
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)
1710 + {
1711 + try {
1712 + $ticket = Ticket::findOrFail($ticket_id);
501 1713
502 - return [
503 - 'message' => __('Selected response has been deleted', 'fluent-support')
504 - ];
1714 + $this->ensureCanAccessTicket($ticket);
505 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 + }
506 1727 }
507 1728
508 - public function updateResponse(Request $request, $ticketId, $responseId)
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)
509 1736 {
510 - $data = $request->all();
1737 + try {
1738 + $ticket = Ticket::findOrFail($ticket_id);
511 1739
512 - $this->validate($data, [
513 - 'content' => 'required'
514 - ]);
1740 + $this->ensureCanAccessTicket($ticket);
515 1741
516 - $ticket = Ticket::findOrFail($ticketId);
517 - $response = Conversation::findOrFail($responseId);
518 - $agent = Helper::getAgentByUserId();
1742 + $ticket->detachTags($tag_id);
519 1743
520 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
1753 + }
521 1754
522 - if (!$hasAllPermission) {
523 - if ($ticket->agent_id != $agent->id) {
524 - return $this->sendError([
525 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
526 - ]);
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)
1762 + {
1763 + $ticketId = (int) $ticket_id;
1764 + $newCustomerId = $request->getSafe('customer', 'intval');
1765 +
1766 + if (!$newCustomerId) {
1767 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 + }
1769 +
1770 + // Rebinding a ticket to another customer exposes that customer's private
1771 + // data (profile, custom fields) through the ticket, so it requires the same
1772 + // sensitive-data capability that gates the customer routes.
1773 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 + }
1776 +
1777 + try {
1778 + $ticket = Ticket::findOrFail($ticketId);
1779 +
1780 + $this->ensureCanAccessTicket($ticket);
1781 +
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
1785 +
1786 + if (!$targetCustomer) {
1787 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
527 1788 }
528 - }
529 1789
530 - $response->content = wp_unslash(wp_kses_post($data['content']));
531 - $response->save();
1790 + if ($ticket->customer_id == $newCustomerId) {
1791 + return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1792 + }
532 1793
533 - return [
534 - 'message' => __('Selected response has been updated', 'fluent-support'),
535 - 'response' => $response
536 - ];
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 + }
537 1804 }
538 1805
539 - public function getLiveActivity(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)
540 1813 {
541 - $agent = Helper::getAgentByUserId();
1814 + if (!defined('FLUENTSUPPORTPRO')) {
1815 + return [
1816 + 'custom_data' => [],
1817 + 'rendered_fields' => []
1818 + ];
1819 + }
542 1820
543 - return [
544 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
545 - ];
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1823 +
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 + }
546 1835 }
547 1836
548 - public function removeLiveActivity(Request $request, $ticketId)
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)
549 1845 {
550 - $agent = Helper::getAgentByUserId();
1846 + $data = [
1847 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 + 'tags' => $request->get('tags', null)
1849 + ];
551 1850
552 - return [
553 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
554 - 'agent_id' => $agent->id
555 - ];
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) {
1859 + return $this->sendError([
1860 + 'message' => Helper::getSafeErrorMessage($e)
1861 + ]);
1862 + }
556 1863 }
557 1864
558 - public function addTag(Request $request, $ticketId)
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 + */
1872 +
1873 + public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
559 1874 {
560 - $ticket = Ticket::findOrFail($ticketId);
1875 + $data = [
1876 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1877 + 'lists' => $request->get('lists', null, [])
1878 + ];
561 1879
562 - $tagId = intval($request->get('tag_id'));
1880 + // Sanitize lists array if it's an array
1881 + if (is_array($data['lists'])) {
1882 + $data['lists'] = array_map('intval', $data['lists']);
1883 + }
563 1884
564 - if (!$ticket->hasTag($tagId)) {
565 - $ticket->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
1885 + try {
1886 + return $fluentCRMServices->syncCrmLists($data);
1887 + } catch (\Exception $e) {
1888 + return $this->sendError([
1889 + 'message' => Helper::getSafeErrorMessage($e)
1890 + ]);
566 1891 }
567 -
568 - return [
569 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
570 - 'tags' => $ticket->tags
571 - ];
572 1892 }
573 1893
574 - public function detachTag($ticketId, $tagId)
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)
575 1901 {
576 - $ticket = Ticket::findOrFail($ticketId);
577 - $ticket->tags()->detach($tagId);
1902 + $type = $request->getSafe('type', 'sanitize_text_field');
578 1903
579 - return [
580 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
581 - 'tags' => $ticket->tags
582 - ];
1904 + return TicketHelper::getTicketEssentials($type);
583 1905 }
584 1906
585 - public function changeTicketCustomer(Request $request)
1907 + public function fetchLabelSearch()
586 1908 {
587 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
588 - ->update(['customer_id' => $request->get('customer')]);
589 - return [
590 - 'message' => __('Customer has been updated', 'fluent-support'),
591 - 'updatedCustomer' => $updateCustomer
592 - ];
1909 + try {
1910 + $agent_id = get_current_user_id();
1911 + return TicketHelper::getLabelSearch($agent_id);
1912 + } catch (\Exception $e) {
1913 + return $this->sendError([
1914 + 'message' => Helper::getSafeErrorMessage($e)
1915 + ]);
1916 + }
593 1917 }
594 1918
595 - public function getTicketCustomData(Request $request, $ticketId)
1919 + public function storeOrUpdateLabelSearch(Request $request)
596 1920 {
597 - if (!defined('FLUENTSUPPORTPRO')) {
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 + }
1931 +
598 1932 return [
599 - 'custom_data' => [],
600 - 'rendered_fields' => []
1933 + 'message' => __('Invalid filter type.', 'fluent-support'),
601 1934 ];
1935 +
1936 + } catch (\Exception $e) {
1937 + return $this->sendError([
1938 + 'message' => Helper::getSafeErrorMessage($e)
1939 + ]);
602 1940 }
1941 + }
603 1942
604 - $ticket = Ticket::findOrFail($ticketId);
605 -
606 - return [
607 - 'custom_data' => (object) $ticket->customData(),
608 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
609 - ];
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 + ]);
1952 + }
610 1953 }
611 1954 }