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 +1548 -509 1.5.52.4.0 View file →
@@ -3,22 +3,29 @@
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
22 29 /**
23 30 * TicketController class for REST API related to ticket
24 31 * This class is responsible for getting / inserting/ modifying data for all request related to ticket
@@ -25,14 +32,27 @@
25 32 * @package FluentSupport\App\Http\Controllers
26 33 *
27 34 * @version 1.0.0
28 35 */
29 -
30 36 class TicketController extends Controller
31 37 {
38 + /**
39 + * This `me` method will return the current user profile info
40 + * @param Request $request
41 + * @return array
42 + */
32 43 public function me(Request $request)
33 44 {
34 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 + }
35 55
36 56 $settings = [
37 57 'user_id' => $user->ID,
38 58 'email' => $user->user_email,
@@ -37,37 +57,29 @@
37 57 'user_id' => $user->ID,
38 58 'email' => $user->user_email,
39 59 'person' => Helper::getAgentByUserId($user->ID),
40 60 'permissions' => PermissionManager::currentUserPermissions(),
41 - 'request' => $request->all()
61 + 'request' => $sanitizedRequest
42 62 ];
43 63
44 - if ($request->get('with_portal_settings')) {
45 -
64 + if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
46 65 $mimeHeadings = Helper::getAcceptedMimeHeadings();
47 - $businessSettings = (new Settings())->globalBusinessSettings();
66 + $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
48 67 $maxFileSize = absint($businessSettings['max_file_size']);
49 68
50 69 $portalSettings = [
51 - 'support_products' => Product::select(['id', 'title'])->get(),
70 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
52 71 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
53 72 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
54 73 'has_rich_text_editor' => true,
55 - 'max_file_size' => $maxFileSize,
56 - 'mime_headings' => $mimeHeadings
74 + 'max_file_size' => $maxFileSize,
75 + 'mime_headings' => $mimeHeadings
57 76 ];
58 - /**
59 - * Filter customer portal settings
60 - *
61 - * @since v1.0.0
62 - * @param array $portalSettings
63 - */
77 +
64 78 $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
65 -
66 79 $settings['portal_settings'] = $portalSettings;
67 80 }
68 81
69 -
70 82 return $settings;
71 83 }
72 84
73 85 /**
@@ -77,32 +89,32 @@
77 89 */
78 90 public function index(Request $request)
79 91 {
80 92 //Selected filter type, either simple or Advanced
81 - $filterType = $request->get('filter_type', 'simple');
93 + $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
82 94
83 95 /*Prepare Query Arguments*/
84 96 $queryArgs = [
85 - 'with' => [],
97 + 'with' => [],
86 98 'filter_type' => $filterType,
87 - 'sort_by' => $request->get('order_by', 'id'),
88 - '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',
89 101 ];
90 102
91 -
92 103 //If the selected filter type is advanced
93 - if($request->get('filter_type')=='advanced'){
104 + if ($filterType == 'advanced') {
105 + $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
94 106 //Get the selected query params for advanced filter
95 - $queryArgs['filters_groups_raw'] = json_decode($this->request->get('advanced_filters'), true);
107 + $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
96 108 } else {
97 109 //Selected filter type is simple
98 - $queryArgs['simple_filters'] = $request->get('filters', []);
99 - $queryArgs['search'] = trim(sanitize_text_field($request->get('search', '')));
100 - if ($customerId = $request->get('customer_id')) {
101 - $queryArgs['customer_id'] = intval($customerId);
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;
102 115 }
103 116 }
104 -
105 117 /*End Prepare Query Arguments*/
106 118
107 119 $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
108 120
@@ -109,34 +121,28 @@
109 121 $ticketsModel = $ticketsModel->with([
110 122 'customer' => function ($query) {
111 123 $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
112 124 }, 'agent' => function ($query) {
113 - $query->select(['first_name', 'last_name', 'id']);
125 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
114 126 },
127 + 'mailbox',
115 128 'product',
116 129 'tags',
117 130 'preview_response' => function ($query) {
118 - $query->orderBy('id', 'desc');
131 + $query->latest('id');
119 132 }
120 133 ]);
121 134
122 -
123 135 // apply filters by access level
124 136 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
125 137
126 -
127 138 $tickets = $ticketsModel->paginate();
128 139
129 - $perPage = $request->get('per_page');
140 + $perPage = $request->getSafe('per_page', 'intval', 15);
130 141
131 - foreach ($tickets as $ticket) {
132 - if ($perPage < 15) {
133 - if ($ticket->status != 'closed') {
134 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
135 - } else {
136 - $ticket->live_activity = [];
137 - }
138 - }
142 + // Load live activity for small page sizes (board/kanban view)
143 + if ($perPage < 15) {
144 + TicketHelper::loadBatchLiveActivities($tickets);
139 145 }
140 146
141 147 return [
142 148 'tickets' => $tickets
@@ -144,356 +150,1007 @@
144 150 }
145 151
146 152 /**
147 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', []);
164 +
165 + //Include attachments if provided
166 + if (!empty($data['attachments'])) {
167 + $ticketData['attachments'] = $data['attachments'];
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 + ]);
233 + }
234 + }
235 +
236 + /**
237 + * getTicket method will return ticket information by ticket id
148 238 * @param Request $request
239 + * @param $ticket_id
149 240 * @return array
150 - * @throws \FluentSupport\Framework\Validator\ValidationException
151 241 */
152 - public function createTicket(Request $request)
242 + public function getTicket(Request $request, $ticket_id)
153 243 {
154 - $ticketData = $request->get('ticket', []);
155 - $maybeNewCustomer = $request->get('newCustomer');
244 + try {
245 + //Get logged in agent information
246 + $agent = Helper::getAgentByUserId();
156 247
157 - //If user select create WP user during ticket creation
158 - if ($ticketData['create_wp_user'] == 'yes'){
159 - //Check if username already in use, if not create new user
160 - if(!username_exists($maybeNewCustomer['username'])){
161 - $authController = new AuthController();
162 - $createdUser = $authController->createUser($maybeNewCustomer);
163 - $authController->maybeUpdateUser($createdUser, $maybeNewCustomer);
164 - }else{
165 - return $this->sendError(__('This username is already exist in WordPress', 'fluent-support'));
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 + }];
166 255 }
167 - }
168 - //If user select create customer during ticket creation
169 - if($ticketData['create_customer'] == 'yes'){
170 - //Check user already exist as customer, if not create new
171 - if (!empty($maybeNewCustomer) && is_null(Customer::where('email', $maybeNewCustomer['email'])->first())){
172 - $createCustomer = Customer::create($maybeNewCustomer);
173 - if ($createCustomer){
174 - $ticketData['customer_id'] = $createCustomer->id;
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 + }
175 295 }
296 +
297 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
176 298 }
177 - else{
178 - 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');
179 303 }
304 +
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');
313 +
314 + foreach ($ticket->responses as $response) {
315 + if ($feedbacks->has($response->id)) {
316 + $response->agent_feedback = $feedbacks->get($response->id)->value;
317 + }
318 + }
319 + }
320 +
321 + $contents = ['ticket' => $ticket->content];
322 + foreach ($ticket->responses as $response) {
323 + $contents['response_' . $response->id] = $response->content;
324 + }
325 +
326 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
327 + $ticket->content = $contents['ticket'];
328 +
329 + //Format response content
330 + foreach ($ticket->responses as $response) {
331 + $responseKey = 'response_' . $response->id;
332 + if (isset($contents[$responseKey])) {
333 + $response->content = $contents[$responseKey];
334 + }
335 +
336 + $responseContent = apply_filters(
337 + 'fluent_support/response_content_before_render',
338 + $response->content,
339 + $response,
340 + $ticket
341 + );
342 +
343 + if ($response->conversation_type === 'note') {
344 + $responseContent = wpautop($responseContent, false);
345 + } else {
346 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
347 + }
348 +
349 +
350 + $response->content = apply_filters(
351 + 'fluent_support/response_content_after_render',
352 + $responseContent,
353 + $response,
354 + $ticket
355 + );
356 +
357 + if (!empty($response->ccinfo)) {
358 + $val = Helper::safeUnserialize($response->ccinfo->value);
359 + if (isset($val['cc_email']) && !empty($val['cc_email'])) {
360 + $response->cc_info = $val['cc_email'];
361 + } else {
362 + $response->cc_info = '';
363 + }
364 + } else {
365 + $response->cc_info = '';
366 + }
367 + }
368 +
369 + $ticketContent = apply_filters(
370 + 'fluent_support/ticket_content_before_render',
371 + $ticket->content,
372 + $ticket
373 + );
374 +
375 + $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
376 +
377 + $ticket->content = apply_filters(
378 + 'fluent_support/ticket_content_after_render',
379 + $ticketContent,
380 + $ticket
381 + );
382 +
383 + //Get last activity by agent
384 + $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
385 +
386 + //Get all carbon copy customer
387 + $ccInfo = $ticket->getSettingsValue('cc_email', []);
388 + $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
389 +
390 + if (defined('FLUENTSUPPORTPRO')) {
391 + $ticket->custom_fields = $ticket->customData('admin', true);
392 + }
393 +
394 + // Load agent info if ticket was created on behalf of customer
395 + if ($ticket->created_by) {
396 + $ticket->load('created_by_person');
397 + if ($ticket->created_by_person) {
398 + $isAgentInitiated = strpos($ticket->content, __(' initialized this ticket', 'fluent-support')) !== false;
399 + $ticket->created_by_agent = [
400 + 'id' => $ticket->created_by_person->id,
401 + 'full_name' => $ticket->created_by_person->full_name,
402 + 'agent_initiated' => $isAgentInitiated,
403 + ];
404 + }
405 + }
406 +
407 + $data = [
408 + 'ticket' => $ticket,
409 + 'responses' => $ticket->responses,
410 + 'agent_id' => $agent->id
411 + ];
412 +
413 + if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
414 + $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
415 + }
416 +
417 + $withData = $request->get('with_data', null);
418 + $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
419 +
420 + if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
421 + $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
422 + }
423 +
424 + return $data;
425 + } catch (\Exception $e) {
426 + return $this->sendError([
427 + 'message' => Helper::getSafeErrorMessage($e)
428 + ]);
180 429 }
430 + }
181 431
182 - $this->validate($ticketData, [
183 - 'customer_id' => 'required',
184 - 'title' => 'required',
185 - 'content' => 'required'
186 - ]);
432 + public function getMentionableAgents(Request $request, $ticket_id)
433 + {
434 + try {
435 + $ticket = Ticket::findOrFail($ticket_id);
187 436
188 - //Get customer information from db
189 - $customer = Customer::findOrFail($ticketData['customer_id']);
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 + }
190 440
191 - if (empty($ticketData['mailbox_id'])) {
192 - $mailbox = Helper::getDefaultMailBox();
193 - $ticketData['mailbox_id'] = $mailbox->id;
194 - } else {
195 - $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); // just for validation
196 - }
441 + $this->ensureCanAccessTicket($ticket);
197 442
198 - if (!empty($ticketData['product_id'])) {
199 - $data['product_source'] = 'local';
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 + ]);
200 453 }
454 + }
201 455
202 - $ticketData['title'] = sanitize_text_field(wp_unslash($ticketData['title']));
456 + protected function getMentionableAgentList($ticket, $search, $limit)
457 + {
458 + $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
459 + ->mentionBy($search)
460 + ->orderBy('first_name')
461 + ->orderBy('last_name')
462 + ->get();
203 463
204 - $ticketData['content'] = wp_unslash(wp_kses_post($ticketData['content']));
464 + if ($allAgents->isEmpty()) {
465 + return [];
466 + }
205 467
206 - if (!empty($ticketData['priority'])) {
207 - $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
208 - }
468 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
469 + $ticketAccess = new AgentTicketAccess();
470 + $results = [];
209 471
210 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
472 + foreach ($allAgents as $agent) {
473 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
474 + continue;
475 + }
211 476
212 - /*
213 - * Filter ticket data
214 - *
215 - * @since v1.0.0
216 - * @param array $ticketData
217 - * @param object $customer
218 - */
219 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
477 + $results[] = [
478 + 'id' => strval($agent->id),
479 + 'first_name' => $agent->first_name,
480 + 'last_name' => $agent->last_name,
481 + 'email' => $agent->email,
482 + ];
220 483
221 - /*
222 - * Action before ticket create
223 - *
224 - * @since v1.0.0
225 - * @param array $ticketData
226 - * @param object $customer
227 - */
228 - do_action('fluent_support/before_ticket_create', $ticketData, $customer);
484 + if (count($results) >= $limit) {
485 + break;
486 + }
487 + }
229 488
230 - $createdTicket = Ticket::create($ticketData);
489 + return $results;
490 + }
231 491
232 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
233 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
492 + protected function getAgentRestrictionsMap(array $agentIds)
493 + {
494 + if (!$agentIds) {
495 + return [];
234 496 }
235 497
236 - /*
237 - * Action on ticket create
238 - *
239 - * @since v1.0.0
240 - * @param object $createdTicket
241 - * @param object $customer
242 - */
243 - do_action('fluent_support/ticket_created', $createdTicket, $customer);
498 + $metas = Meta::where('object_type', 'person_meta')
499 + ->where('key', 'agent_restrictions')
500 + ->whereIn('object_id', $agentIds)
501 + ->get();
244 502
245 - return [
246 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
247 - 'ticket' => $createdTicket
248 - ];
503 + $restrictions = [];
504 + foreach ($metas as $meta) {
505 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
506 + }
249 507
508 + return $restrictions;
250 509 }
251 510
252 511 /**
253 - * getTicket method will return ticket information by ticket id
512 + * createResponse method will create response by agent for the ticket
254 513 * @param Request $request
255 - * @param $ticketId
514 + * @param Ticket $ticket
515 + * @param int $ticket_id
256 516 * @return array
517 + * @throws \FluentSupport\Framework\Validator\ValidationException
257 518 */
258 - public function getTicket(Request $request, $ticketId)
519 + public function createResponse(TicketResponseRequest $request, $ticket_id)
259 520 {
260 - //Get logged in agent information
261 - $agent = Helper::getAgentByUserId();
521 + $data = $request->sanitize();
262 522
263 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
264 - $q->whereIn('status', ['active', 'inline']);
265 - }]);
266 - $responseWith = $request->get('response_with', ['person', 'attachments']);
523 + try {
524 + $convoType = Arr::get($data, 'conversation_type', 'response');
525 + $isDraft = $convoType === 'draft_response';
267 526
268 - //Get ticket by id
269 - $ticket = Ticket::with($ticketWith)
270 - ->findOrFail($ticketId);
527 + if (!$isDraft) {
528 + $this->ensureCanManageTickets();
529 + }
271 530
272 - //If ticket has customer
273 - if ($ticket->customer) {
274 - //Get and set customer profile url
275 - $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
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) {
561 + return $this->sendError([
562 + 'message' => Helper::getSafeErrorMessage($e)
563 + ]);
276 564 }
565 + }
277 566
278 - //If user do not have permission in this ticket
279 - if (!PermissionManager::hasTicketPermission($ticket)) {
567 + public function getFluentBookingEventTypes()
568 + {
569 + try {
570 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
571 + $this->ensureCanManageTickets();
572 +
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) {
280 581 return $this->sendError([
281 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
582 + 'message' => Helper::getSafeErrorMessage($e)
282 583 ]);
283 584 }
585 + }
284 586
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();
285 592
286 - if ($ticket->status == 'closed') {
287 - $ticket->load('closed_by_person');
593 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
594 +
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 + ]);
288 615 }
616 + }
289 617
290 - //Get ticket responses
291 - $responses = Conversation::where('ticket_id', $ticketId)
292 - ->with($responseWith)
293 - ->orderBy('id', 'DESC')
294 - ->get();
618 + public function getFluentBookingAvailability(Request $request, $ticket_id)
619 + {
620 + try {
621 + // All FluentBooking endpoints require manage permission; view-only agents cannot call a meeting.
622 + $this->ensureCanManageTickets();
295 623
296 - foreach ($responses as $response) {
297 - $response->content = make_clickable(wpautop($response->content, false));
298 - }
624 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
299 625
300 - $ticket->content = make_clickable(wpautop($ticket->content, false));
626 + // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
627 + $this->ensureCanAccessTicket($ticket);
301 628
302 - //Get last activity by agent
303 - $ticket->live_activity = TicketHelper::getActivity($ticketId, $agent->id);
629 + $eventId = $request->getSafe('event_type_id', 'intval');
304 630
305 - if (defined('FLUENTSUPPORTPRO')) {
306 - $ticket->custom_fields = $ticket->customData('admin', true);
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) {
647 + return $this->sendError([
648 + 'message' => Helper::getSafeErrorMessage($e)
649 + ]);
307 650 }
651 + }
308 652
309 - $data = [
310 - 'ticket' => $ticket,
311 - 'responses' => $responses,
312 - 'agent_id' => $agent->id
313 - ];
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();
314 658
315 - //Is request come with fluentcrm_profile, get fluent crm contack information
316 - if (in_array('fluentcrm_profile', $request->get('with_data', [])) && defined('FLUENTCRM')) {
317 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
318 - }
659 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
319 660
320 - return $data;
661 + // Enforces per-ticket visibility (e.g. own-tickets-only agents cannot access unassigned tickets).
662 + $this->ensureCanAccessTicket($ticket);
321 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 + }
322 672 }
323 673
324 674 /**
325 - * createResponse method will create response by agent for the ticket
675 + * createDraft method will create draft by agent for the ticket
326 676 * @param Request $request
327 - * @param $ticketId
677 + * @param Ticket $ticket
678 + * @param int $ticket_id
328 679 * @return array
329 680 * @throws \FluentSupport\Framework\Validator\ValidationException
330 681 */
331 - public function createResponse(Request $request, $ticketId)
682 + public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
332 683 {
333 - $data = $request->all();
684 + $data = $request->sanitize();
334 685
335 - $this->validate($data, [
336 - 'content' => 'required'
337 - ]);
686 + try {
687 + //Get logged-in agent information
688 + $agent = Helper::getAgentByUserId();
338 689
339 - //Get logged-in agent information
340 - $agent = Helper::getAgentByUserId(get_current_user_id());
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 + }
341 695
342 - if (!$agent) {
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) {
343 726 return $this->sendError([
344 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
727 + 'message' => Helper::getSafeErrorMessage($e)
345 728 ]);
346 729 }
730 + }
347 731
348 - $ticket = Ticket::findOrFail($ticketId);
732 + public function getDraft($ticket_id)
733 + {
734 + try {
735 + //Get logged-in agent information
736 + $agent = Helper::getAgentByUserId();
349 737
350 - //If the agent has permission to view this ticket
351 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
352 763 return $this->sendError([
353 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
764 + 'message' => Helper::getSafeErrorMessage($e)
354 765 ]);
355 766 }
356 - // Adding support for shortcode in agent response
357 - $data = apply_filters('fluent_support/parse_smartcode_data', $data, [
358 - 'customer' => $ticket->customer,
359 - 'agent' => Helper::getAgentByUserId(get_current_user_id())
360 - ]);
361 - $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
767 + }
362 768
363 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
769 + public function deleteDraft($draft_id)
770 + {
771 + $draft_id = intval($draft_id);
364 772
365 - return [
366 - 'message' => __('Response has been added'),
367 - 'response' => $responseData['response'],
368 - 'ticket' => $responseData['ticket'],
369 - 'update_data' => $responseData['update_data']
370 - ];
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 + ]);
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 + ]);
816 + }
371 817 }
372 818
373 819 /**
374 820 * getTicketWidgets method generate additional information for a ticket by customer
375 - * @param Request $request
376 - * @param $ticketId
821 + * @param Ticket $ticket
822 + * @param $ticket_id
377 823 * @return array
378 824 */
379 - public function getTicketWidgets(Request $request, $ticketId)
825 + public function getTicketWidgets(Request $request, $ticket_id)
380 826 {
381 - //Get ticket with customer by ticket
382 - $ticket = Ticket::with('customer')->findOrFail($ticketId);
827 + try {
828 + //Get ticket with customer by ticket id
829 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
383 830
384 - //If the logged-in user has permission
385 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
386 863 return $this->sendError([
387 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
864 + 'message' => Helper::getSafeErrorMessage($e)
388 865 ]);
389 866 }
390 -
391 - //Get last 10 tickets of this customer except this
392 - $otherTickets = Ticket::where('id', '!=', $ticketId)
393 - ->select(['id', 'title', 'status', 'created_at'])
394 - ->where('customer_id', $ticket->customer_id)
395 - ->orderBy('id', 'DESC')
396 - ->limit(10)
397 - ->get();
398 -
399 - return [
400 - 'other_tickets' => $otherTickets,
401 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
402 - ];
403 867 }
404 868
405 869 /**
406 870 * updateTicketProperty method will update ticket property
407 871 * @param Request $request
408 - * @param $ticketId
872 + * @param Ticket $ticket
873 + * @param $ticket_id
409 874 * @return array
410 875 */
411 - public function updateTicketProperty(Request $request, $ticketId)
876 + public function updateTicketProperty(Request $request, $ticket_id)
412 877 {
413 - $assigner = Helper::getAgentByUserId(get_current_user_id());
414 - $ticket = Ticket::findOrFail($ticketId);
415 - $propName = $request->get('prop_name');
416 - $propValue = $request->get('prop_value');
878 + try {
879 + $assigner = Helper::getAgentByUserId();
880 + $ticket = Ticket::findOrFail($ticket_id);
417 881
418 - if (!PermissionManager::hasTicketPermission($ticket)) {
882 + $this->ensureCanAccessTicket($ticket);
883 +
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) {
419 955 return $this->sendError([
420 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
956 + 'message' => Helper::getSafeErrorMessage($e)
421 957 ]);
422 958 }
959 + }
423 960
424 - $prevValue = $ticket->{$propName};
425 - if ($propName && $propValue && $prevValue != $propValue) {
426 - $ticket->{$propName} = $propValue;
427 - $ticket->save();
428 - }
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 + {
972 + return [
973 + 'agent_id',
974 + 'title',
975 + 'mailbox_id',
976 + 'product_id',
977 + 'status',
978 + 'priority',
979 + 'client_priority',
980 + ];
981 + }
429 982
430 - $updateData = [];
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)
996 + {
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;
431 1004
432 - if ($propName == 'product_id') {
433 - $ticket->load('product');
434 - $updateData['product'] = $ticket->product;
435 - } else if ($propName == 'agent_id') {
436 - $ticket->load('agent');
437 - $updateData['agent'] = $ticket->agent;
438 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
439 - if ($prevValue != $ticket->{$propName}) {
440 - do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner);
441 - }
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 + }
1019 +
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;
442 1096 }
443 1097
444 - return [
445 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
446 - 'update_data' => $updateData
447 - ];
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);
448 1101 }
449 1102
450 1103 /**
451 1104 * closeTicket method close the ticket by id
452 - * @param Request $request
453 - * @param $ticketId
1105 + * @param Ticket $ticket
1106 + * @param int $ticket_id
454 1107 * @return array
455 1108 */
456 - public function closeTicket(Request $request, $ticketId)
1109 + public function closeTicket(Request $request, $ticket_id)
457 1110 {
458 - $agent = Helper::getAgentByUserId(get_current_user_id());
1111 + try {
1112 + $agent = Helper::getAgentByUserId();
1113 + $ticket = Ticket::findOrFail($ticket_id);
459 1114
460 - $ticket = Ticket::findOrFail($ticketId);
1115 + $this->ensureCanAccessTicket($ticket);
461 1116
462 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
463 1124 return $this->sendError([
464 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1125 + 'message' => Helper::getSafeErrorMessage($e)
465 1126 ]);
466 1127 }
467 -
468 - return [
469 - 'message' => __('Ticket has been closed', 'fluent_support'),
470 - 'ticket' => (new TicketService())->close($ticket, $agent)
471 - ];
472 1128 }
473 1129
474 1130 /**
475 1131 * reOpenTicket method will reopen a closed ticket
476 1132 * @param Request $request
477 - * @param $ticketId
1133 + * @param $ticket_id
478 1134 * @return array
479 1135 */
480 - public function reOpenTicket(Request $request, $ticketId)
1136 + public function reOpenTicket($ticket_id)
481 1137 {
482 - $agent = Helper::getAgentByUserId(get_current_user_id());
1138 + try {
1139 + $agent = Helper::getAgentByUserId();
1140 + $ticket = Ticket::findOrFail($ticket_id);
483 1141
484 - $ticket = Ticket::findOrFail($ticketId);
1142 + $this->ensureCanAccessTicket($ticket);
485 1143
486 - if (!PermissionManager::hasTicketPermission($ticket)) {
1144 + return [
1145 + 'message' => __('Ticket has been opened again', 'fluent-support'),
1146 + 'ticket' => (new TicketService())->reopen($ticket, $agent)
1147 + ];
1148 + } catch (\Exception $e) {
487 1149 return $this->sendError([
488 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1150 + 'message' => Helper::getSafeErrorMessage($e)
489 1151 ]);
490 1152 }
491 -
492 - return [
493 - 'message' => __('Ticket has been opened again', 'fluent_support'),
494 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
495 - ];
496 1153 }
497 1154
498 1155 /**
499 1156 * doBulkActions method is responsible for bulk action
@@ -498,100 +1155,198 @@
498 1155 /**
499 1156 * doBulkActions method is responsible for bulk action
500 1157 * This function will get ticket ids and action as parameter and perform action based on the selection
501 1158 * @param Request $request
1159 + * @param Ticket $ticket
502 1160 * @return array|string[]|void
1161 + * @throws \Exception
503 1162 */
504 1163 public function doBulkActions(Request $request)
505 1164 {
506 - //Get all ticket ids
507 - $ticketIds = $request->get('ticket_ids', []);
508 - $action = $request->get('bulk_action');//get action
509 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
510 - $agent = Helper::getAgentByUserId();
511 - $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, []));
512 1168
513 - //If agent do not have permission to manage other tickets
514 - if (!$hasAllPermission) {
515 - //Filter ticket by agent_id
516 - $query->where('agent_id', $agent->id);
517 - }
1169 + $agent = Helper::getAgentByUserId();
1170 + $query = Ticket::whereIn('id', $ticketIds);
518 1171
519 - //If bulk action is close ticket
520 - if ($action == 'close_tickets') {
521 - $query->where('status', '!=', 'closed');
522 - $tickets = $query->get();
523 - foreach ($tickets as $ticket) {
524 - (new TicketService())->close($ticket, $agent);
525 - }
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);
526 1175
527 - return [
528 - 'message' => sprintf(__('%d tickets have been closed', 'fluent-support'), count($tickets))
529 - ];
530 - } else if ($action == 'delete_tickets') {
531 - //If bulk action is delete ticket
532 - $tickets = $query->get();
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 + });
533 1182
534 - foreach ($tickets as $ticket) {
535 - $ticket->deleteTicket();
536 - }
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();
537 1193
538 - return [
539 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
540 - ];
541 - } else if ($action == 'assign_agent') {
542 - //If action is assign agent
543 - $agentId = absint($request->get('agent_id'));
544 - if (!$agentId) {
545 - $this->sendError([
546 - 'message' => __('agent_id param is required', 'fluent-support')
547 - ]);
548 - }
1194 + foreach ($tickets as $ticket) {
1195 + $ticketService->deleteTicket($ticket, $agent);
1196 + }
549 1197
550 - $agent = Agent::findOrFail($agentId);
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 + }
551 1209
552 - //Filter ticket where not assign same agent or none
553 - $query->where(function ($q) use ($agent) {
554 - $q->where('agent_id', '!=', $agent->id)
555 - ->orWhereNull('agent_id');
556 - });
1210 + $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
557 1211
558 - $tickets = $query->get();
1212 + $query->where(function ($q) use ($assignAgent) {
1213 + $q->where('agent_id', '!=', $assignAgent->id)
1214 + ->orWhereNull('agent_id');
1215 + });
559 1216
560 - foreach ($tickets as $ticket) {
561 - $assigner = Helper::getCurrentAgent();
562 - $ticket->agent_id = $agent->id;
563 - $ticket->save();
564 - do_action('fluent_support/agent_assigned_to_ticket', $agent, $ticket, $assigner);
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 + ];
565 1317 }
566 1318
567 - return [
568 - 'message' => __(count($tickets) . ' tickets has been assigned to', 'fluent-support') . ' ' . $agent->full_name
569 - ];
570 - } else if ($action == 'assign_tags') {
571 - //if action is assign tags
572 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
573 - if (!$tags) {
574 - $this->sendError([
575 - 'message' => __('tag_ids param is required', 'fluent-support')
576 - ]);
577 - }
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 + ]);
1324 + }
1325 + }
578 1326
579 - $tickets = $query->get();
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);
580 1336
581 - foreach ($tickets as $ticket) {
582 - $ticket->applyTags($tags);
583 - }
1337 + $this->ensureCanAccessTicket($ticket);
584 1338
1339 + (new TicketService())->deleteTicket($ticket);
1340 +
585 1341 return [
586 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1342 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
587 1343 ];
588 -
1344 + } catch (\Exception $e) {
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
589 1348 }
590 -
591 - $this->sendError([
592 - 'message' => __('Sorry no action found as available', 'fluent-support')
593 - ]);
594 1349 }
595 1350
596 1351 /**
597 1352 * doBulkReplies method will create response for bulk tickets
@@ -596,224 +1351,406 @@
596 1351 /**
597 1352 * doBulkReplies method will create response for bulk tickets
598 1353 * This function will get ticket ids, content, attachment etc and create response for tickets
599 1354 * @param Request $request
1355 + * @param Conversation $conversation
600 1356 * @return array
601 - * @throws \FluentSupport\Framework\Validator\ValidationException
1357 + * @throws \Exception
602 1358 */
603 1359 public function doBulkReplies(Request $request)
604 1360 {
605 - $data = $request->all();
606 - $this->validate($data, [
607 - 'content' => 'required',
608 - 'ticket_ids' => 'required|array'
609 - ]);
1361 + try {
1362 + // Sanitize all request data before validation
1363 + $requestData = $request->all();
1364 + $data = [];
1365 + foreach ($requestData as $key => $value) {
1366 + if (is_array($value)) {
1367 + if ($key === 'ticket_ids') {
1368 + $data[$key] = array_map('intval', $value);
1369 + } elseif ($key === 'content') {
1370 + $data[$key] = wp_kses_post($value);
1371 + } else {
1372 + $data[$key] = map_deep($value, 'sanitize_text_field');
1373 + }
1374 + } else {
1375 + $data[$key] = sanitize_text_field($value);
1376 + }
1377 + }
610 1378
611 - //get all ticket ids
612 - $ticketIds = $request->get('ticket_ids');
613 - $ticketIds = array_filter($ticketIds, 'absint');
1379 + $this->validate($data, [
1380 + 'content' => 'required',
1381 + 'ticket_ids' => 'required|array'
1382 + ]);
614 1383
615 - //Get logged in agent information
616 - $agent = Helper::getAgentByUserId();
1384 + //Get logged in agent information
1385 + $agent = Helper::getAgentByUserId();
1386 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
617 1387
618 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1388 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
619 1389
620 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1390 + // Scope to tickets the agent may access (visibility + mailbox restrictions).
1391 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
621 1392
622 - //If the agent does not have permission
623 - if (!$hasAllPermission) {
624 - //Filter ticket by agent_id
625 - $query->where('agent_id', $agent->id);
626 - }
1393 + $tickets = $query->get();
627 1394
628 - $tickets = $query->get();
1395 + if ($tickets->isEmpty()) {
1396 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1397 + }
629 1398
630 - //if not ticket found
631 - if ($tickets->isEmpty()) {
632 - $this->sendError([
633 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
634 - ]);
635 - }
1399 + $responseData = [
1400 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1401 + 'conversation_type' => 'response',
1402 + 'close_ticket' => Arr::get($data, 'close_ticket'),
1403 + ];
636 1404
637 - //get response data
638 - $responseData = [
639 - 'content' => $request->get('content'),
640 - 'conversation_type' => $request->get('conversation_type', 'response'),
641 - 'close_ticket' => $request->get('close_ticket', 'no')
642 - ];
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();
1413 + }
643 1414
644 - $attachments = $request->get('attachments', []);
1415 + $responseService = new ResponseService();
645 1416
646 - //If request with file
647 - if ($attachments) {
648 - $attachments = Attachment::whereNull('ticket_id')
649 - ->orderBy('id', 'asc')
650 - ->whereIn('file_hash', $attachments)
651 - ->get();
652 - }
1417 + foreach ($tickets as $ticket) {
1418 + if ($attachments) {
1419 + $responseData['attachments'] = [];
1420 + $attachmentRecords = [];
1421 + foreach ($attachments as $attachment) {
1422 + $fileHash = bin2hex(random_bytes(16));
1423 + $attachmentRecords[] = [
1424 + 'ticket_id' => $ticket->id,
1425 + 'file_path' => $attachment->file_path,
1426 + 'full_url' => $attachment->full_url,
1427 + 'title' => $attachment->title,
1428 + 'driver' => $attachment->driver,
1429 + 'file_size' => $attachment->file_size,
1430 + 'status' => $attachment->status,
1431 + 'file_hash' => $fileHash,
1432 + ];
1433 + $responseData['attachments'][] = $fileHash;
1434 + }
1435 + if ($attachmentRecords) {
1436 + Attachment::insert($attachmentRecords);
1437 + }
1438 + }
653 1439
654 -
655 - $responseService = new ResponseService();
656 -
657 - foreach ($tickets as $ticket) {
658 - if ($attachments) {
659 - $responseData['attachments'] = [];
660 - foreach ($attachments as $attachment) {
661 - $attachedFile = $attachment->replicate();
662 - $attachedFile->ticket_id = $ticket->id;
663 - $attachedFile->save();
664 - $responseData['attachments'][] = $attachedFile->file_hash;
665 - }
1440 + $responseService->createResponse($responseData, $agent, $ticket);
666 1441 }
667 1442
668 - $responseService->createResponse($responseData, $agent, $ticket);
1443 + return [
1444 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1445 + ];
1446 + } catch (\Exception $e) {
1447 + return $this->sendError([
1448 + 'message' => Helper::getSafeErrorMessage($e)
1449 + ]);
669 1450 }
670 -
671 -
672 - return [
673 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
674 - ];
675 -
676 1451 }
677 1452
678 1453 /**
679 1454 * deleteResponse method will remove a response from ticket by ticket id and response id
680 1455 * @param Request $request
681 - * @param $ticketId
682 - * @param $responseId
1456 + * @param Conversation $conversation
1457 + * @param $ticket_id
1458 + * @param $response_id
683 1459 * @return array
684 1460 */
685 - public function deleteResponse(Request $request, $ticketId, $responseId)
1461 + public function deleteResponse($ticket_id, $response_id)
686 1462 {
687 - $ticket = Ticket::findOrFail($ticketId);
688 - $response = Conversation::findOrFail($responseId);
689 - $agent = Helper::getAgentByUserId();
1463 + try {
1464 + $ticket = Ticket::findOrFail($ticket_id);
690 1465
691 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
692 1469
693 - if (!$hasAllPermission) {
694 - if ($ticket->agent_id != $agent->id) {
695 - return $this->sendError([
696 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
697 - ]);
1470 + // The caller must have access to this specific ticket (visibility +
1471 + // ownership + mailbox), not merely a global manage capability.
1472 + $this->ensureCanAccessTicket($ticket);
1473 +
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 + );
698 1480 }
699 - }
700 1481
701 - Conversation::where('id', $response->id)->delete();
1482 + $response = Conversation::where('id', $response_id)
1483 + ->where('ticket_id', $ticket_id)
1484 + ->firstOrFail();
702 1485
703 - return [
704 - 'message' => __('Selected response has been deleted', 'fluent-support')
705 - ];
1486 + $response->delete();
1487 + $response->ccinfo()->delete();
706 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 + ]);
1496 + }
707 1497 }
708 1498
709 1499 /**
710 1500 * updateResponse method will update ticket response using ticket and response id
711 1501 * @param Request $request
712 - * @param $ticketId
713 - * @param $responseId
1502 + * @param int $ticket_id
1503 + * @param int $response_id
714 1504 * @return array
715 - * @throws \FluentSupport\Framework\Validator\ValidationException
1505 + * @throws \Exception
716 1506 */
717 - public function updateResponse(Request $request, $ticketId, $responseId)
1507 + public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
718 1508 {
719 - $data = $request->all();
1509 + try {
1510 + $ticket = Ticket::findOrFail($ticket_id);
720 1511
721 - $this->validate($data, [
722 - 'content' => 'required'
723 - ]);
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 + }
724 1515
725 - $ticket = Ticket::findOrFail($ticketId);
726 - $response = Conversation::findOrFail($responseId);
727 - $agent = Helper::getAgentByUserId();
1516 + // The caller must have access to this specific ticket (visibility +
1517 + // ownership + mailbox), not merely a global manage capability.
1518 + $this->ensureCanAccessTicket($ticket);
728 1519
729 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1520 + $response = Conversation::where('id', $response_id)
1521 + ->where('ticket_id', $ticket_id)
1522 + ->with('person')
1523 + ->firstOrFail();
1524 + $agent = Helper::getAgentByUserId();
730 1525
731 - if (!$hasAllPermission) {
732 - if ($ticket->agent_id != $agent->id) {
733 - return $this->sendError([
734 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
735 - ]);
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 + );
736 1533 }
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 + }
1542 +
1543 + $isDraft = $response->conversation_type == 'draft_response';
1544 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1545 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1546 +
1547 + if ($isDraft && !$isAuthor) {
1548 + // Another agent's draft can only be edited/approved by an approver.
1549 + if (!$canApproveDraft) {
1550 + throw new \Exception(
1551 + esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1552 + );
1553 + }
1554 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1555 + // Editing another agent's response requires manage-others capability.
1556 + throw new \Exception(
1557 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1558 + );
1559 + }
1560 +
1561 + // Request input is already unslashed at the boundary; unslashing again
1562 + // would strip literal backslashes out of the edited reply.
1563 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1564 + $response->content = $content;
1565 +
1566 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1567 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1568 + } else {
1569 + $response->save();
1570 + }
1571 +
1572 + return [
1573 + 'message' => __('Selected response has been updated', 'fluent-support'),
1574 + 'response' => $response
1575 + ];
1576 + } catch (\Exception $e) {
1577 + return $this->sendError([
1578 + 'message' => Helper::getSafeErrorMessage($e)
1579 + ]);
737 1580 }
1581 + }
738 1582
739 - $response->content = wp_unslash(wp_kses_post($data['content']));
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 + }
1591 +
1592 + $ticket = Ticket::findOrFail($ticket_id);
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 + }
1619 + }
1620 +
1621 + protected function approveDraftConversation($ticket, $response, $person, $content)
1622 + {
1623 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
1624 +
1625 + $response->content = $content;
1626 + $response->conversation_type = 'response';
1627 + $response->created_at = current_time('mysql');
740 1628 $response->save();
741 1629
742 - return [
743 - 'message' => __('Selected response has been updated', 'fluent-support'),
744 - 'response' => $response
745 - ];
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;
1636 + }
1637 + }
1638 +
1639 + if ($resetWaitingSince) {
1640 + $ticket->last_agent_response = current_time('mysql');
1641 + $ticket->waiting_since = current_time('mysql');
1642 + }
1643 +
1644 + $ticket->response_count += 1;
1645 + $ticket->save();
1646 +
1647 + do_action('fluent_support/response_added_by_' . $person->person_type, $response, $ticket, $person);
1648 +
1649 + return $response;
746 1650 }
747 1651
748 1652 /**
749 1653 * getLiveActivity method will return the activity in a ticket by agents
750 1654 * @param Request $request
751 - * @param $ticketId
1655 + * @param $ticket_id
752 1656 * @return array
753 1657 */
754 - public function getLiveActivity(Request $request, $ticketId)
1658 + public function getLiveActivity(Request $request, $ticket_id)
755 1659 {
756 - $agent = Helper::getAgentByUserId();
1660 + try {
1661 + $ticket = Ticket::findOrFail($ticket_id);
757 1662
758 - return [
759 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
760 - ];
1663 + $this->ensureCanAccessTicket($ticket);
1664 +
1665 + $agent = Helper::getAgentByUserId();
1666 +
1667 + return [
1668 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1669 + ];
1670 + } catch (\Exception $e) {
1671 + return $this->sendError([
1672 + 'message' => Helper::getSafeErrorMessage($e)
1673 + ]);
1674 + }
761 1675 }
762 1676
763 1677 /**
764 1678 * removeLiveActivity method will remove activities that
765 1679 * @param Request $request
766 - * @param $ticketId
1680 + * @param $ticket_id
767 1681 * @return array
768 1682 */
769 - public function removeLiveActivity(Request $request, $ticketId)
1683 + public function removeLiveActivity(Request $request, $ticket_id)
770 1684 {
771 - $agent = Helper::getAgentByUserId();
1685 + try {
1686 + $ticket = Ticket::findOrFail($ticket_id);
772 1687
773 - return [
774 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
775 - 'agent_id' => $agent->id
776 - ];
1688 + $this->ensureCanAccessTicket($ticket);
1689 +
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 + }
777 1701 }
778 1702
779 1703 /**
780 1704 * addTag method will add tag in ticket by ticket id
781 1705 * @param Request $request
782 - * @param $ticketId
1706 + * @param $ticket_id
783 1707 * @return array
784 1708 */
785 - public function addTag(Request $request, $ticketId)
1709 + public function addTag(Request $request, $ticket_id)
786 1710 {
787 - $ticket = Ticket::findOrFail($ticketId);
1711 + try {
1712 + $ticket = Ticket::findOrFail($ticket_id);
788 1713
789 - $tagId = intval($request->get('tag_id'));
1714 + $this->ensureCanAccessTicket($ticket);
790 1715
791 - if (!$ticket->hasTag($tagId)) {
792 - $ticket->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
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 + ]);
793 1726 }
794 -
795 - return [
796 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
797 - 'tags' => $ticket->tags
798 - ];
799 1727 }
800 1728
801 1729 /**
802 1730 * detachTag method will remove all tags from tickets
803 - * @param $ticketId
804 - * @param $tagId
1731 + * @param $ticket_id
1732 + * @param $tag_id
805 1733 * @return array
806 1734 */
807 - public function detachTag($ticketId, $tagId)
1735 + public function detachTag($ticket_id, $tag_id)
808 1736 {
809 - $ticket = Ticket::findOrFail($ticketId);
810 - $ticket->tags()->detach($tagId);
1737 + try {
1738 + $ticket = Ticket::findOrFail($ticket_id);
811 1739
812 - return [
813 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
814 - 'tags' => $ticket->tags
815 - ];
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 + }
816 1753 }
817 1754
818 1755 /**
819 1756 * changeTicketCustomer method will update customer in a ticket
@@ -820,25 +1757,60 @@
820 1757 * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
821 1758 * @param Request $request
822 1759 * @return array
823 1760 */
824 - public function changeTicketCustomer(Request $request)
1761 + public function changeTicketCustomer(Request $request, $ticket_id)
825 1762 {
826 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
827 - ->update(['customer_id' => $request->get('customer')]);
828 - return [
829 - 'message' => __('Customer has been updated', 'fluent-support'),
830 - 'updatedCustomer' => $updateCustomer
831 - ];
1763 + $ticketId = (int) $ticket_id;
1764 + $newCustomerId = $request->getSafe('customer', 'intval');
1765 +
1766 + if (!$newCustomerId) {
1767 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1768 + }
1769 +
1770 + // Rebinding a ticket to another customer exposes that customer's private
1771 + // data (profile, custom fields) through the ticket, so it requires the same
1772 + // sensitive-data capability that gates the customer routes.
1773 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1774 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1775 + }
1776 +
1777 + try {
1778 + $ticket = Ticket::findOrFail($ticketId);
1779 +
1780 + $this->ensureCanAccessTicket($ticket);
1781 +
1782 + $targetCustomer = Customer::where('id', $newCustomerId)
1783 + ->where('person_type', 'customer')
1784 + ->first();
1785 +
1786 + if (!$targetCustomer) {
1787 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
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 + }
832 1804 }
833 1805
834 1806 /**
835 1807 * getTicketCustomData method will return the custom data by ticket id
836 1808 * @param Request $request
837 - * @param $ticketId
1809 + * @param $ticket_id
838 1810 * @return array|array[]
839 1811 */
840 - public function getTicketCustomData(Request $request, $ticketId)
1812 + public function getTicketCustomData(Request $request, $ticket_id)
841 1813 {
842 1814 if (!defined('FLUENTSUPPORTPRO')) {
843 1815 return [
844 1816 'custom_data' => [],
@@ -845,14 +1817,22 @@
845 1817 'rendered_fields' => []
846 1818 ];
847 1819 }
848 1820
849 - $ticket = Ticket::findOrFail($ticketId);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
850 1823
851 - return [
852 - 'custom_data' => (object)$ticket->customData(),
853 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
854 - ];
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 + }
855 1835 }
856 1836
857 1837 /**
858 1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
@@ -857,59 +1837,118 @@
857 1837 /**
858 1838 * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
859 1839 *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
860 1840 * @param Request $request
1841 + * @param FluentCRMServices $fluentCRMServices
861 1842 * @return array
862 1843 */
863 - public function syncFluentCrmTags(Request $request)
1844 + public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
864 1845 {
1846 + $data = [
1847 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1848 + 'tags' => $request->get('tags', null)
1849 + ];
865 1850
866 - 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) {
867 1859 return $this->sendError([
868 - 'message' => __('FluentCRM is not installed', 'fluent-support')
1860 + 'message' => Helper::getSafeErrorMessage($e)
869 1861 ]);
870 1862 }
1863 + }
871 1864
872 - $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 + */
873 1872
874 - 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) {
875 1888 return $this->sendError([
876 - 'message' => __('Contact could not be found', 'fluent-support')
1889 + 'message' => Helper::getSafeErrorMessage($e)
877 1890 ]);
878 1891 }
1892 + }
879 1893
880 - $tagIds = array_filter($request->get('tags', []), 'absint');
881 - $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
882 - $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');
883 1903
884 - 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) {
885 1913 return $this->sendError([
886 - 'message' => __('Sorry you do not have permission to add contact tags', 'fluent-support')
1914 + 'message' => Helper::getSafeErrorMessage($e)
887 1915 ]);
888 1916 }
1917 + }
889 1918
890 - $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 + }
891 1931
892 - $existingTags = $contact->tags;
893 - $existingTagIds = [];
894 - foreach ($existingTags as $tag) {
895 - $existingTagIds[] = $tag->id;
896 - }
897 - $newTagIds = array_diff($tagIds, $existingTagIds);
898 - $removedTagIds = array_diff($existingTagIds, $tagIds);
1932 + return [
1933 + 'message' => __('Invalid filter type.', 'fluent-support'),
1934 + ];
899 1935
900 - if ($newTagIds) {
901 - $contact->attachTags($newTagIds);
1936 + } catch (\Exception $e) {
1937 + return $this->sendError([
1938 + 'message' => Helper::getSafeErrorMessage($e)
1939 + ]);
902 1940 }
1941 + }
903 1942
904 - if ($removedTagIds) {
905 - $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 + ]);
906 1952 }
907 -
908 -
909 - return [
910 - 'tags' => $contact->tags,
911 - 'message' => __('FluentCRM contact tags has been updated', 'fluent-support')
912 - ];
913 -
914 1953 }
915 1954 }