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