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