PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
← All changes | app/Http/Controllers/TicketController.php +1600 -300 1.10.5 → 2.4.5 View file →
@@ -1,433 +1,1769 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 +use FluentSupport\App\Models\Agent;
6 +use FluentSupport\App\Models\Attachment;
5 7 use FluentSupport\App\Models\Meta;
8 +use FluentSupport\App\Models\Customer;
9 +use FluentSupport\Framework\Http\Request\Request;
6 10 use FluentSupport\Framework\Support\Arr;
7 11 use FluentSupport\App\Http\Requests\TicketRequest;
8 12 use FluentSupport\App\Http\Requests\TicketResponseRequest;
9 13 use FluentSupport\App\Models\Conversation;
14 +use FluentSupport\App\Models\Product;
10 15 use FluentSupport\App\Models\Ticket;
11 -use FluentSupport\App\Services\FluentBoardsService;
12 16 use FluentSupport\App\Services\FluentCRMServices;
13 17 use FluentSupport\App\Services\Helper;
14 18 use FluentSupport\App\Services\ProfileInfoService;
15 19 use FluentSupport\App\Services\TicketHelper;
16 -use FluentSupport\Framework\Request\Request;
20 +use FluentSupport\App\Services\TicketQueryService;
17 21 use FluentSupport\App\Modules\PermissionManager;
22 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
23 +use FluentSupport\App\Services\Tickets\ResponseService;
24 +use FluentSupport\App\Models\AgentGroup;
18 25 use FluentSupport\App\Services\Tickets\TicketService;
26 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
19 27
20 28 /**
21 - * TicketController class for REST API related to ticket
22 - * This class is responsible for getting / inserting/ modifying data for all request related to ticket
23 - * @package FluentSupport\App\Http\Controllers
24 - *
25 - * @version 1.0.0
29 + * Agent-side REST endpoints for tickets.
26 30 */
27 31 class TicketController extends Controller
28 32 {
29 33 /**
30 - * This `me` method will return the current user profile info
34 + * Most tickets one bulk request may change. Above the largest list page
35 + * size (150), so a full page always fits.
36 + */
37 + const MAX_BULK_TICKETS = 200;
38 +
39 + /**
40 + * Current user's profile, permissions and optionally the portal settings.
41 + *
31 42 * @param Request $request
32 - * @param ProfileInfoService $profileInfoService
33 43 * @return array
34 44 */
35 - public function me(Request $request, ProfileInfoService $profileInfoService)
45 + public function me(Request $request)
36 46 {
37 47 $user = wp_get_current_user();
48 + $requestData = $request->all();
49 + $sanitizedRequest = [];
50 + foreach ($requestData as $key => $value) {
51 + if (is_array($value)) {
52 + $sanitizedRequest[$key] = map_deep($value, 'sanitize_text_field');
53 + } else {
54 + $sanitizedRequest[$key] = sanitize_text_field($value);
55 + }
56 + }
57 +
38 58 $settings = [
39 - 'user_id' => $user->ID,
40 - 'email' => $user->user_email,
41 - 'person' => Helper::getAgentByUserId($user->ID),
59 + 'user_id' => $user->ID,
60 + 'email' => $user->user_email,
61 + 'person' => Helper::getAgentByUserId($user->ID),
42 62 'permissions' => PermissionManager::currentUserPermissions(),
43 - 'request' => $request->all()
63 + 'request' => $sanitizedRequest
44 64 ];
45 65
46 - $withPortalSettings = $request->getSafe('with_portal_settings');
66 + if ($request->getSafe('with_portal_settings', 'sanitize_text_field')) {
67 + $mimeHeadings = Helper::getAcceptedMimeHeadings();
68 + $businessSettings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
69 + $maxFileSize = absint($businessSettings['max_file_size']);
47 70
48 - return $profileInfoService->me($settings, $withPortalSettings);
71 + $portalSettings = [
72 + 'support_products' => \FluentSupport\App\Models\Product::select(['id', 'title'])->orderedByTitle()->get(),
73 + 'customer_ticket_priorities' => Helper::customerTicketPriorities(),
74 + 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(),
75 + 'has_rich_text_editor' => true,
76 + 'max_file_size' => $maxFileSize,
77 + 'mime_headings' => $mimeHeadings
78 + ];
79 +
80 + $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings);
81 + $settings['portal_settings'] = $portalSettings;
82 + }
83 +
84 + return $settings;
49 85 }
50 86
51 87 /**
52 - * index method will return the list of ticket based on the selected filter
53 88 * @param Request $request
54 89 * @return array
55 90 */
56 - public function index(Request $request, TicketService $ticketService)
91 + public function index(Request $request)
57 92 {
58 - //Selected filter type, either simple or Advanced
59 - $filterType = $request->getText('filter_type', 'simple');
60 - $data = $request->all();
61 - return $ticketService->getTickets($data, $filterType);
93 + $filterType = $request->getSafe('filter_type', 'sanitize_text_field', 'simple');
94 +
95 + $queryArgs = [
96 + 'with' => [],
97 + 'filter_type' => $filterType,
98 + 'sort_by' => sanitize_sql_orderby($request->getSafe('order_by', 'sanitize_text_field', 'id')),
99 + 'sort_type' => $request->getSafe('order_type', 'sanitize_text_field', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
100 + ];
101 +
102 + if ($filterType == 'advanced') {
103 + $advanced_filters = map_deep($request->get('advanced_filters', []), 'sanitize_text_field');
104 + $queryArgs['filters_groups_raw'] = json_decode($advanced_filters, true);
105 + } else {
106 + $queryArgs['simple_filters'] = map_deep($request->get('filters', []), 'sanitize_text_field');
107 + $queryArgs['search'] = trim($request->getSafe('search', 'sanitize_text_field', ''));
108 +
109 + if ($customerId = $request->getSafe('customer_id', 'intval')) {
110 + $queryArgs['customer_id'] = $customerId;
111 + }
112 + }
113 +
114 + $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
115 +
116 + $ticketsModel = $ticketsModel->with([
117 + 'customer' => function ($query) {
118 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
119 + }, 'agent' => function ($query) {
120 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
121 + },
122 + 'mailbox',
123 + 'product',
124 + 'tags',
125 + 'preview_response' => function ($query) {
126 + $query->latest('id');
127 + }
128 + ]);
129 +
130 + do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
131 +
132 + $tickets = $ticketsModel->paginate();
133 +
134 + $perPage = $request->getSafe('per_page', 'intval', 15);
135 +
136 + // Small pages come from the board view, which shows live activity.
137 + if ($perPage < 15) {
138 + TicketHelper::loadBatchLiveActivities($tickets);
139 + }
140 +
141 + return [
142 + 'tickets' => $tickets
143 + ];
62 144 }
63 145
64 146 /**
65 - * createTicket method will create new ticket as well as customer or WP user
66 - * @param Request $request
67 - * @param Ticket $ticket
147 + * Create a ticket, creating the customer and optionally a WP user first.
148 + *
149 + * @param TicketRequest $request
68 150 * @return array
69 - * @throws \Exception
70 151 */
71 - public function createTicket(TicketRequest $request, Ticket $ticket)
152 + public function createTicket(TicketRequest $request)
72 153 {
73 - $data = $request->sanitize();
154 + try {
155 + $data = $request->sanitize();
156 + $ticketData = $data['ticket'];
157 + $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
74 158
75 - $ticketData = $data['ticket'];
159 + if (!empty($data['attachments'])) {
160 + $ticketData['attachments'] = $data['attachments'];
161 + }
76 162
77 - if (!empty($data['attachments'])) {
78 - $ticketData['attachments'] = $data['attachments'];
79 - }
163 + if (empty($ticketData['customer_id'])) {
164 + $createdUserId = false;
80 165
81 - $maybeNewCustomer = $data['newCustomer'];
166 + if (Arr::get($ticketData, 'create_wp_user') == 'yes' && !empty($maybeNewCustomer['username'])) {
167 + if (!username_exists($maybeNewCustomer['username'])) {
168 + $authController = new AuthController();
169 + $createdUserId = $authController->createUser($maybeNewCustomer);
170 + $authController->maybeUpdateUser($createdUserId, $maybeNewCustomer);
171 + }
172 + }
82 173
83 - $createdTicket = $ticket->createTicket($ticketData, $maybeNewCustomer);
174 + $email = Arr::get($maybeNewCustomer, 'email');
175 + if (!$email || !is_email($email)) {
176 + return $this->sendError([
177 + 'message' => __('A valid email is required to create a ticket', 'fluent-support')
178 + ]);
179 + }
84 180
85 - if (is_wp_error($createdTicket)) {
181 + $existingCustomer = Customer::where('email', $email)->first();
182 +
183 + if ($existingCustomer) {
184 + $ticketData['customer_id'] = $existingCustomer->id;
185 + } else {
186 + $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
187 + $customerData['user_id'] = $createdUserId;
188 + $customerData = array_filter($customerData);
189 +
190 + $createCustomer = Customer::create($customerData);
191 +
192 + if (!$createCustomer || !$createCustomer->exists) {
193 + return $this->sendError([
194 + 'message' => __('Customer could not be created', 'fluent-support')
195 + ]);
196 + }
197 +
198 + do_action('fluent_support/customer_created', $createCustomer);
199 +
200 + $ticketData['customer_id'] = $createCustomer->id;
201 + }
202 + }
203 +
204 + $customer = Customer::findOrFail($ticketData['customer_id']);
205 +
206 + $createdTicket = (new TicketService())->storeTicket($ticketData, $customer);
207 +
208 + return [
209 + 'message' => __('Ticket has been created successfully', 'fluent-support'),
210 + 'ticket' => $createdTicket
211 + ];
212 + } catch (\Exception $e) {
86 213 return $this->sendError([
87 - 'message' => $createdTicket->get_error_message()
214 + 'message' => Helper::getSafeErrorMessage($e)
88 215 ]);
89 216 }
90 -
91 - return [
92 - 'message' => 'Ticket has been successfully created',
93 - 'ticket' => $createdTicket
94 - ];
95 217 }
96 218
97 219 /**
98 - * getTicket method will return ticket information by ticket id
99 220 * @param Request $request
100 - * @param $ticket_id
221 + * @param int $ticket_id
101 222 * @return array
102 223 */
103 - public function getTicket(Request $request, Ticket $ticket, $ticket_id)
224 + public function getTicket(Request $request, $ticket_id)
104 225 {
105 226 try {
106 - $ticketWith = $request->getSafe('with', 'sanitize_text_field');
227 + $agent = Helper::getAgentByUserId();
228 +
229 + $ticketWith = $request->get('with');
230 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
231 +
107 232 if (!$ticketWith) {
108 233 $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
109 - $q->whereIn('status', ['active', 'inline']);
234 + $q->where('status', 'active');
110 235 }];
111 236 }
112 - $withCrmData = in_array('fluentcrm_profile', $request->query('with_data', []));
113 237
114 - return $ticket->getTicket($ticketWith, $withCrmData, $ticket_id);
238 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
239 +
240 + $ticket->load(['responses' => function ($q) {
241 + $q->with([
242 + 'person',
243 + 'ccinfo',
244 + 'attachments' => function ($q) {
245 + $q->where('status', 'active');
246 + }
247 + ]);
248 + }]);
249 +
250 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
251 +
252 + if (in_array($ticket->mailbox_id, $restrictedBusinessBoxes)) {
253 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
254 + }
255 +
256 + $this->ensureCanAccessTicket($ticket);
257 +
258 + if ($ticket->customer) {
259 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
260 + $ticket->customer->custom_field_keys = $customFieldsKey;
261 +
262 + if ($ticket->customer->user_id) {
263 + $customFieldKeysUsingHook = apply_filters('fluent_support/custom_registration_form_fields_key', []);
264 + if (!empty($customFieldKeysUsingHook)) {
265 + $allUserMeta = get_user_meta($ticket->customer->user_id);
266 + foreach ($customFieldKeysUsingHook as $key) {
267 + if (isset($allUserMeta[$key][0]) && $allUserMeta[$key][0]) {
268 + $ticket->customer->$key = $allUserMeta[$key][0];
269 + }
270 + }
271 + }
272 + }
273 +
274 + $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
275 + }
276 +
277 + if ($ticket->status == 'closed') {
278 + $ticket->load('closed_by_person');
279 + }
280 +
281 + if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
282 + $responseIds = $ticket->responses->pluck('id')->toArray();
283 + $feedbacks = Meta::where('object_type', 'conversation_meta')
284 + ->where('key', 'agent_feedback_ratings')
285 + ->whereIn('object_id', $responseIds)
286 + ->get()
287 + ->keyBy('object_id');
288 +
289 + foreach ($ticket->responses as $response) {
290 + if ($feedbacks->has($response->id)) {
291 + $response->agent_feedback = $feedbacks->get($response->id)->value;
292 + }
293 + }
294 + }
295 +
296 + $contents = ['ticket' => $ticket->content];
297 + foreach ($ticket->responses as $response) {
298 + $contents['response_' . $response->id] = $response->content;
299 + }
300 +
301 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticket->id);
302 + $ticket->content = $contents['ticket'];
303 +
304 + foreach ($ticket->responses as $response) {
305 + $responseKey = 'response_' . $response->id;
306 + if (isset($contents[$responseKey])) {
307 + $response->content = $contents[$responseKey];
308 + }
309 +
310 + $responseContent = apply_filters(
311 + 'fluent_support/response_content_before_render',
312 + $response->content,
313 + $response,
314 + $ticket
315 + );
316 +
317 + if ($response->conversation_type === 'note') {
318 + $responseContent = wpautop($responseContent, false);
319 + } else {
320 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
321 + }
322 +
323 +
324 + $response->content = apply_filters(
325 + 'fluent_support/response_content_after_render',
326 + $responseContent,
327 + $response,
328 + $ticket
329 + );
330 +
331 + if (!empty($response->ccinfo)) {
332 + $val = Helper::safeUnserialize($response->ccinfo->value);
333 + if (isset($val['cc_email']) && !empty($val['cc_email'])) {
334 + $response->cc_info = $val['cc_email'];
335 + } else {
336 + $response->cc_info = '';
337 + }
338 + } else {
339 + $response->cc_info = '';
340 + }
341 + }
342 +
343 + $ticketContent = apply_filters(
344 + 'fluent_support/ticket_content_before_render',
345 + $ticket->content,
346 + $ticket
347 + );
348 +
349 + $ticketContent = links_add_target(make_clickable(wpautop($ticketContent, false)));
350 +
351 + $ticket->content = apply_filters(
352 + 'fluent_support/ticket_content_after_render',
353 + $ticketContent,
354 + $ticket
355 + );
356 +
357 + $ticket->live_activity = TicketHelper::getActivity($ticket->id, $agent->id);
358 +
359 + $ccInfo = $ticket->getSettingsValue('cc_email', []);
360 + $ticket->carbon_copy = !empty($ccInfo) ? implode(', ', $ccInfo) : '';
361 +
362 + if (defined('FLUENTSUPPORTPRO')) {
363 + $ticket->custom_fields = $ticket->customData('admin', true);
364 + }
365 +
366 + // Set when an agent opened the ticket on the customer's behalf.
367 + if ($ticket->created_by) {
368 + $ticket->load('created_by_person');
369 + if ($ticket->created_by_person) {
370 + $isAgentInitiated = $ticket instanceof Ticket && $ticket->isAgentInitiated();
371 + $ticket->created_by_agent = [
372 + 'id' => $ticket->created_by_person->id,
373 + 'full_name' => $ticket->created_by_person->full_name,
374 + 'agent_initiated' => $isAgentInitiated,
375 + ];
376 + }
377 + }
378 +
379 + $data = [
380 + 'ticket' => $ticket,
381 + 'responses' => $ticket->responses,
382 + 'agent_id' => $agent->id
383 + ];
384 +
385 + if (defined('FLUENTSUPPORTPRO') && $ticket->watchers) {
386 + $data['watchers'] = TicketHelper::getWatchers($ticket->watchers);
387 + }
388 +
389 + $withData = $request->get('with_data', null);
390 + $withDataArray = is_array($withData) ? map_deep($withData, 'sanitize_text_field') : [];
391 +
392 + if (defined('FLUENTCRM') && in_array('fluentcrm_profile', $withDataArray)) {
393 + $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
394 + }
395 +
396 + return $data;
115 397 } catch (\Exception $e) {
116 - return $this->sendError($e->getMessage());
398 + return $this->sendError([
399 + 'message' => Helper::getSafeErrorMessage($e)
400 + ]);
117 401 }
118 402 }
119 403
404 + public function getMentionableAgents(Request $request, $ticket_id)
405 + {
406 + try {
407 + $ticket = Ticket::findOrFail($ticket_id);
408 +
409 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
410 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
411 + }
412 +
413 + $this->ensureCanAccessTicket($ticket);
414 +
415 + $search = trim($request->getSafe('search', 'sanitize_text_field', ''));
416 + $limit = min(max(absint($request->getSafe('limit', 'intval', 20)), 1), 50);
417 +
418 + return [
419 + 'agents' => $this->getMentionableAgentList($ticket, $search, $limit)
420 + ];
421 + } catch (\Exception $e) {
422 + return $this->sendError([
423 + 'message' => Helper::getSafeErrorMessage($e)
424 + ]);
425 + }
426 + }
427 +
428 + protected function getMentionableAgentList($ticket, $search, $limit)
429 + {
430 + $allAgents = Agent::select(['id', 'first_name', 'last_name', 'email', 'user_id'])
431 + ->mentionBy($search)
432 + ->orderBy('first_name')
433 + ->orderBy('last_name')
434 + ->get();
435 +
436 + if ($allAgents->isEmpty()) {
437 + return [];
438 + }
439 +
440 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
441 + $ticketAccess = new AgentTicketAccess();
442 + $results = [];
443 +
444 + foreach ($allAgents as $agent) {
445 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
446 + continue;
447 + }
448 +
449 + $results[] = [
450 + 'id' => strval($agent->id),
451 + 'first_name' => $agent->first_name,
452 + 'last_name' => $agent->last_name,
453 + 'email' => $agent->email,
454 + ];
455 +
456 + if (count($results) >= $limit) {
457 + break;
458 + }
459 + }
460 +
461 + return $results;
462 + }
463 +
464 + protected function getAgentRestrictionsMap(array $agentIds)
465 + {
466 + if (!$agentIds) {
467 + return [];
468 + }
469 +
470 + $metas = Meta::where('object_type', 'person_meta')
471 + ->where('key', 'agent_restrictions')
472 + ->whereIn('object_id', $agentIds)
473 + ->get();
474 +
475 + $restrictions = [];
476 + foreach ($metas as $meta) {
477 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
478 + }
479 +
480 + return $restrictions;
481 + }
482 +
120 483 /**
121 - * createResponse method will create response by agent for the ticket
122 - * @param Request $request
123 - * @param Ticket $ticket
484 + * @param TicketResponseRequest $request
124 485 * @param int $ticket_id
125 486 * @return array
126 487 * @throws \FluentSupport\Framework\Validator\ValidationException
127 488 */
128 - public function createResponse(TicketResponseRequest $request, Ticket $ticket, $ticket_id)
489 + public function createResponse(TicketResponseRequest $request, $ticket_id)
129 490 {
491 + $data = $request->sanitize();
130 492
131 - $data = $request->sanitize();
493 + try {
494 + $convoType = Arr::get($data, 'conversation_type', 'response');
495 + $isDraft = $convoType === 'draft_response';
132 496
497 + // close_ticket always needs manage rights, even on an otherwise-permitted draft save.
498 + if (!$isDraft || Arr::get($data, 'close_ticket') == 'yes') {
499 + $this->ensureCanManageTickets();
500 + }
501 +
502 + $agent = Helper::getAgentByUserId();
503 +
504 + if (!$agent) {
505 + return $this->sendError([
506 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
507 + ]);
508 + }
509 +
510 + $ticket = Ticket::findOrFail($ticket_id);
511 +
512 + $this->ensureCanAccessTicket($ticket);
513 +
514 + if ($convoType === 'response' && $request->getSafe('force_send', 'sanitize_text_field') !== 'yes') {
515 + $lastSeenId = $request->get('last_conversation_id');
516 + $newerResponses = $lastSeenId === null || $lastSeenId === '' ? [] : $this->responsesAddedSince($ticket, (int) $lastSeenId, $agent);
517 + if ($newerResponses) {
518 + return $this->sendError([
519 + 'code' => 'fs_newer_responses',
520 + 'message' => __('Someone replied to this ticket while you were writing.', 'fluent-support'),
521 + 'new_responses' => $newerResponses
522 + ], 409);
523 + }
524 + }
525 +
526 + $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
527 +
528 + $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
529 + $responseData['response']->load([
530 + 'attachments' => function ($q) {
531 + $q->where('status', 'active');
532 + }
533 + ]);
534 + $responseData['response']->content = wpautop($responseData['response']->content, false);
535 +
536 + return [
537 + 'message' => __('Response has been added', 'fluent-support'),
538 + 'response' => $responseData['response'],
539 + 'ticket' => $responseData['ticket'],
540 + 'update_data' => $responseData['update_data']
541 + ];
542 + } catch (\Exception $e) {
543 + return $this->sendError([
544 + 'message' => Helper::getSafeErrorMessage($e)
545 + ]);
546 + }
547 + }
548 +
549 + public function getFluentBookingEventTypes()
550 + {
133 551 try {
134 - return $ticket->createResponse($data, $ticket_id);
552 + // All FluentBooking endpoints need manage permission. View-only agents can't book meetings.
553 + $this->ensureCanManageTickets();
554 +
555 + $service = new FluentBookingService();
556 + $eventTypes = $service->getEventTypes();
557 +
558 + return [
559 + 'status' => $service->getStatus($eventTypes),
560 + 'event_types' => $eventTypes
561 + ];
135 562 } catch (\Exception $e) {
136 - return $this->sendError($e->getMessage());
563 + return $this->sendError([
564 + 'message' => Helper::getSafeErrorMessage($e)
565 + ]);
137 566 }
138 567 }
139 568
569 + public function createFluentBookingLink(Request $request, $ticket_id)
570 + {
571 + try {
572 + $this->ensureCanManageTickets();
573 +
574 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
575 +
576 + $this->ensureCanAccessTicket($ticket);
577 +
578 + $eventId = $request->getSafe('event_type_id', 'intval');
579 +
580 + if (!$eventId) {
581 + throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
582 + }
583 +
584 + return (new FluentBookingService())->createBookingLink(
585 + $ticket,
586 + $eventId,
587 + $request->getSafe('message', 'wp_kses_post'),
588 + $request->get('selected_slots', []),
589 + $request->getSafe('timezone', 'sanitize_text_field', '')
590 + );
591 + } catch (\Exception $e) {
592 + return $this->sendError([
593 + 'message' => Helper::getSafeErrorMessage($e)
594 + ]);
595 + }
596 + }
597 +
598 + public function getFluentBookingAvailability(Request $request, $ticket_id)
599 + {
600 + try {
601 + $this->ensureCanManageTickets();
602 +
603 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
604 +
605 + $this->ensureCanAccessTicket($ticket);
606 +
607 + $eventId = $request->getSafe('event_type_id', 'intval');
608 +
609 + if (!$eventId) {
610 + throw new \Exception(esc_html__('Please select a FluentBooking event type.', 'fluent-support'));
611 + }
612 +
613 + return [
614 + 'availability' => (new FluentBookingService())->getAvailabilitySlots(
615 + $eventId,
616 + $request->getSafe('range', 'sanitize_key', 'next_3_days'),
617 + $request->getSafe('timezone', 'sanitize_text_field'),
618 + $request->getSafe('duration', 'intval'),
619 + $ticket,
620 + $request->get('selected_dates', []),
621 + $request->getSafe('calendar_month', 'sanitize_text_field', '')
622 + )
623 + ];
624 + } catch (\Exception $e) {
625 + return $this->sendError([
626 + 'message' => Helper::getSafeErrorMessage($e)
627 + ]);
628 + }
629 + }
630 +
631 + public function getFluentBookingMeetings($ticket_id)
632 + {
633 + try {
634 + $this->ensureCanManageTickets();
635 +
636 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
637 +
638 + $this->ensureCanAccessTicket($ticket);
639 +
640 + return [
641 + 'meetings' => (new FluentBookingService())->getTicketMeetings($ticket)
642 + ];
643 + } catch (\Exception $e) {
644 + return $this->sendError([
645 + 'message' => Helper::getSafeErrorMessage($e)
646 + ]);
647 + }
648 + }
649 +
140 650 /**
141 - * createDraft method will create draft by agent for the ticket
142 - * @param Request $request
143 - * @param Ticket $ticket
651 + * @param TicketResponseRequest $request
144 652 * @param int $ticket_id
145 653 * @return array
146 654 * @throws \FluentSupport\Framework\Validator\ValidationException
147 655 */
148 - public function createOrUpdatDraft(TicketResponseRequest $request, Ticket $ticket, $ticket_id)
656 + public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
149 657 {
150 -
151 658 $data = $request->sanitize();
152 659
153 660 try {
154 - return $ticket->addOrUpdatDraft($data, $ticket_id);
661 + $agent = Helper::getAgentByUserId();
662 +
663 + if (!$agent) {
664 + return $this->sendError([
665 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
666 + ]);
667 + }
668 +
669 + $ticket = Ticket::findOrFail($ticket_id);
670 +
671 + $this->ensureCanAccessTicket($ticket);
672 +
673 + $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
674 + $previousDraft = Meta::where('key', $key)->first();
675 +
676 + if ($data['draftID'] || $previousDraft) {
677 + Meta::where('key', $key)->update([
678 + 'value' => maybe_serialize($data)
679 + ]);
680 +
681 + return [
682 + 'message' => __('Draft has been updated', 'fluent-support'),
683 + 'draftID' => $data['draftID']
684 + ];
685 + }
686 +
687 + $draftID = Meta::insertGetId([
688 + 'object_type' => '_fs_auto_draft',
689 + 'object_id' => $ticket_id,
690 + 'key' => $key,
691 + 'value' => maybe_serialize($data)
692 + ]);
693 +
694 + return [
695 + 'message' => __('Draft has been added', 'fluent-support'),
696 + 'draftID' => $draftID
697 + ];
155 698 } catch (\Exception $e) {
156 - return $this->sendError($e->getMessage());
699 + return $this->sendError([
700 + 'message' => Helper::getSafeErrorMessage($e)
701 + ]);
157 702 }
158 703 }
159 704
160 - public function getDraft(Ticket $ticket, $ticket_id)
705 + public function getDraft($ticket_id)
161 706 {
162 707 try {
163 - return $ticket->fetchDraft($ticket_id);
708 + $agent = Helper::getAgentByUserId();
709 +
710 + if (!$agent) {
711 + return $this->sendError([
712 + 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
713 + ]);
714 + }
715 +
716 + $ticket = Ticket::findOrFail($ticket_id);
717 +
718 + $this->ensureCanAccessTicket($ticket);
719 +
720 + $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
721 +
722 + $draft = Meta::where([
723 + 'object_type' => '_fs_auto_draft',
724 + 'key' => $key,
725 + ])->first();
726 +
727 + if ($draft) {
728 + $draft->value = Helper::safeUnserialize($draft->value);
729 + }
730 +
731 + return [
732 + 'draft' => $draft
733 + ];
164 734 } catch (\Exception $e) {
165 - return $this->sendError($e->getMessage());
735 + return $this->sendError([
736 + 'message' => Helper::getSafeErrorMessage($e)
737 + ]);
166 738 }
167 739 }
168 740
169 - public function deleteDraft(Ticket $ticket, $draft_id)
741 + public function deleteDraft($draft_id)
170 742 {
171 743 $draft_id = intval($draft_id);
172 744
173 745 try {
174 - return $ticket->removeDraft($draft_id);
746 + $agent = Helper::getAgentByUserId();
747 +
748 + if (!$agent) {
749 + return $this->sendError([
750 + 'message' => __('You do not have permission to perform this action', 'fluent-support'),
751 + ]);
752 + }
753 +
754 + $draft = Meta::where('id', $draft_id)
755 + ->where('object_type', '_fs_auto_draft')
756 + ->first();
757 +
758 + if (!$draft) {
759 + return $this->sendError([
760 + 'message' => __('Draft not found', 'fluent-support'),
761 + ]);
762 + }
763 +
764 + // Managers can delete other agents' drafts, but only on tickets they can access.
765 + $ticket = Ticket::findOrFail($draft->object_id);
766 +
767 + $this->ensureCanAccessTicket($ticket);
768 +
769 + // The draft key embeds the owning agent's id.
770 + $isOwnDraft = strpos($draft->key, '_agent_id_' . $agent->id . '_') !== false;
771 +
772 + if (!$isOwnDraft && !PermissionManager::canManageTickets()) {
773 + return $this->sendError([
774 + 'message' => __('You do not have permission to delete this draft', 'fluent-support'),
775 + ]);
776 + }
777 +
778 + $draft->delete();
779 +
780 + return [
781 + 'message' => __('Discard draft successfully', 'fluent-support'),
782 + ];
175 783 } catch (\Exception $e) {
176 - return $this->sendError($e->getMessage());
784 + return $this->sendError([
785 + 'message' => Helper::getSafeErrorMessage($e)
786 + ]);
177 787 }
178 788 }
179 789
180 790 /**
181 - * getTicketWidgets method generate additional information for a ticket by customer
182 - * @param Ticket $ticket
183 - * @param $ticket_id
791 + * The customer's other tickets, plus extra profile widgets on request.
792 + *
793 + * @param Request $request
794 + * @param int $ticket_id
184 795 * @return array
185 796 */
186 - public function getTicketWidgets(Ticket $ticket, $ticket_id)
797 + public function getTicketWidgets(Request $request, $ticket_id)
187 798 {
188 799 try {
189 - return $ticket->getTicketWidgets($ticket_id);
800 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
801 +
802 + $this->ensureCanAccessTicket($ticket);
803 +
804 + $perPage = max(1, absint(apply_filters('fluent_support/previous_ticket_widgets_limit', 5)));
805 + $page = max(1, absint($request->get('page', 1)));
806 + $offset = ($page - 1) * $perPage;
807 +
808 + $baseQuery = Ticket::where('id', '!=', $ticket_id)
809 + ->where('customer_id', $ticket->customer_id);
810 +
811 + (new AgentTicketAccess())->applyAccessScope($baseQuery);
812 +
813 + $total = $baseQuery->count();
814 +
815 + $otherTickets = (clone $baseQuery)
816 + ->select(['id', 'title', 'status', 'created_at'])
817 + ->latest('id')
818 + ->limit($perPage)
819 + ->offset($offset)
820 + ->get();
821 +
822 + $response = [
823 + 'other_tickets' => $otherTickets,
824 + 'other_tickets_total' => $total,
825 + 'other_tickets_more' => ($offset + $perPage) < $total,
826 + ];
827 +
828 + if (in_array('extra_widgets', $request->get('with', []))) {
829 + $response['extra_widgets'] = ProfileInfoService::getProfileExtraWidgets($ticket->customer);
830 + }
831 +
832 + return $response;
190 833 } catch (\Exception $e) {
191 - return $this->sendError($e->getMessage());
834 + return $this->sendError([
835 + 'message' => Helper::getSafeErrorMessage($e)
836 + ]);
192 837 }
193 838 }
194 839
195 840 /**
196 - * updateTicketProperty method will update ticket property
197 841 * @param Request $request
198 - * @param Ticket $ticket
199 - * @param $ticket_id
842 + * @param int $ticket_id
200 843 * @return array
201 844 */
202 - public function updateTicketProperty(Request $request, Ticket $ticket, $ticket_id)
845 + public function updateTicketProperty(Request $request, $ticket_id)
203 846 {
204 - $propName = $request->getSafe('prop_name', 'sanitize_text_field');
205 - $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
847 + try {
848 + $assigner = Helper::getAgentByUserId();
849 + $ticket = Ticket::findOrFail($ticket_id);
206 850
207 - try {
208 - return $ticket->updateTicketProperty($propName, $propValue, $ticket_id);
851 + $this->ensureCanAccessTicket($ticket);
852 +
853 + $propName = $request->getSafe('prop_name', 'sanitize_text_field');
854 + $propValue = $request->getSafe('prop_value', 'sanitize_text_field');
855 +
856 + // prop_name is assigned straight onto the model, so it must be allowlisted.
857 + if (!in_array($propName, $this->updatableTicketProperties(), true)) {
858 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
859 + }
860 +
861 + $propValue = $this->sanitizeTicketProperty($ticket, $propName, $propValue);
862 +
863 + $prevValue = $ticket->{$propName};
864 +
865 + if ($propName && $propValue !== null && $prevValue != $propValue) {
866 + $ticket->{$propName} = $propValue;
867 + $ticket->save();
868 +
869 + // Logged like the close/reopen flows do.
870 + if ($propName === 'status') {
871 + $statuses = Helper::ticketStatuses();
872 + $fromLabel = isset($statuses[$prevValue]) ? $statuses[$prevValue] : $prevValue;
873 + $toLabel = isset($statuses[$propValue]) ? $statuses[$propValue] : $propValue;
874 +
875 + $internalNote = sprintf(
876 + /* translators: 1: previous status, 2: new status */
877 + __('Ticket status changed from %1$s to %2$s', 'fluent-support'),
878 + esc_html($fromLabel),
879 + esc_html($toLabel)
880 + );
881 +
882 + Conversation::create([
883 + 'ticket_id' => $ticket->id,
884 + 'person_id' => $assigner->id,
885 + 'conversation_type' => 'internal_info',
886 + 'content' => $internalNote
887 + ]);
888 + }
889 + }
890 +
891 + $updateData = [];
892 +
893 + if ($propName == 'product_id') {
894 + $ticket->load('product');
895 + $updateData['product'] = $ticket->product;
896 + } else if ($propName == 'agent_id') {
897 + $previousAgentId = (int) $prevValue;
898 + $ticket->load('agent');
899 + $updateData['agent'] = $ticket->agent;
900 + $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
901 + if ($prevValue != $ticket->{$propName}) {
902 + do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket, $assigner, $previousAgentId);
903 + }
904 + }
905 +
906 + $message = sprintf(
907 + /* translators: %s: The name of the property that was updated */
908 + __('%s has been updated', 'fluent-support'),
909 + esc_html(str_replace('_', ' ', ucwords((string) $propName)))
910 + );
911 +
912 + return [
913 + 'message' => $message,
914 + 'update_data' => $updateData
915 + ];
209 916 } catch (\Exception $e) {
210 - return $this->sendError($e->getMessage());
917 + return $this->sendError([
918 + 'message' => Helper::getSafeErrorMessage($e)
919 + ]);
211 920 }
212 921 }
213 922
214 923 /**
215 - * closeTicket method close the ticket by id
924 + * Replies by anyone else (the customer or another agent) added after the
925 + * conversation the agent last saw. The admin reply box sends that id so a
926 + * reply written against an outdated thread is held back; callers that send
927 + * no id (MCP, API, bulk reply) are never checked.
928 + *
216 929 * @param Ticket $ticket
930 + * @param int $lastSeenId
931 + * @param \FluentSupport\App\Models\Agent $agent
932 + * @return array
933 + */
934 + protected function responsesAddedSince(Ticket $ticket, $lastSeenId, $agent)
935 + {
936 + if ($lastSeenId < 0) {
937 + return [];
938 + }
939 +
940 + $newer = Conversation::where('ticket_id', $ticket->id)
941 + ->where('id', '>', $lastSeenId)
942 + ->where('conversation_type', 'response')
943 + ->where('person_id', '!=', $agent->id)
944 + ->with(['person' => function ($query) {
945 + $query->select(['id', 'first_name', 'last_name', 'person_type']);
946 + }])
947 + ->orderBy('id', 'ASC')
948 + ->limit(10)
949 + ->get();
950 +
951 + return $newer->map(function ($conversation) {
952 + $person = $conversation->person;
953 + return [
954 + 'id' => (int) $conversation->id,
955 + 'person_name' => $person ? trim($person->first_name . ' ' . $person->last_name) : '',
956 + 'person_type' => $person ? $person->person_type : '',
957 + 'created_at' => (string) $conversation->created_at,
958 + ];
959 + })->all();
960 + }
961 +
962 + /**
963 + * The only ticket columns that may be changed through updateTicketProperty.
964 + * This mirrors exactly what the admin UI edits here (agent, title, product,
965 + * status and the two priority fields). Ownership, audit, public-identifier
966 + * and other sensitive columns are intentionally absent and must go through
967 + * their dedicated workflows.
968 + *
969 + * mailbox_id is deliberately NOT listed: moving a ticket between mailboxes
970 + * has to fire fluent_support/before_move_tickets and
971 + * fluent_support/tickets_moved, which only MailBoxService::moveTickets()
972 + * does. Assigning the column here would move the ticket silently, so the
973 + * mailbox switcher posts to mailboxes/{id}/move_tickets instead.
974 + *
975 + * @return array
976 + */
977 + protected function updatableTicketProperties()
978 + {
979 + return [
980 + 'agent_id',
981 + 'title',
982 + 'product_id',
983 + 'status',
984 + 'priority',
985 + 'client_priority',
986 + ];
987 + }
988 +
989 + /**
990 + * Validate a property update against its allowed values and the
991 + * capability the UI requires for it.
992 + *
993 + * @param Ticket $ticket
994 + * @param string $propName Already confirmed to be in the allowlist.
995 + * @param string $propValue Raw (text-sanitized) value from the request.
996 + * @return mixed Normalized value ready to assign to the model.
997 + * @throws \Exception When the value is invalid or the caller lacks permission.
998 + */
999 + protected function sanitizeTicketProperty(Ticket $ticket, $propName, $propValue)
1000 + {
1001 + switch ($propName) {
1002 + case 'title':
1003 + $propValue = trim(sanitize_text_field($propValue));
1004 + if ($propValue === '') {
1005 + throw new \Exception(esc_html__('Ticket title cannot be empty.', 'fluent-support'), 422);
1006 + }
1007 + return $propValue;
1008 +
1009 + case 'status':
1010 + // The status dropdown in ViewTicket.vue submits the group key from
1011 + // changeable_ticket_statuses and hides empty groups. Match that.
1012 + $allowedStatuses = [];
1013 + foreach (Helper::changeableTicketStatuses() as $statusKey => $statusGroup) {
1014 + if (!empty($statusGroup)) {
1015 + $allowedStatuses[] = $statusKey;
1016 + }
1017 + }
1018 +
1019 + if (!in_array($propValue, $allowedStatuses, true)) {
1020 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1021 + }
1022 +
1023 + // Closing and reopening need TicketService's side effects and hooks.
1024 + // Use closeTicket() / reOpenTicket().
1025 + if ($propValue === 'closed' || $ticket->status === 'closed') {
1026 + throw new \Exception(esc_html__('Closing or reopening a ticket must use the dedicated close and re-open actions.', 'fluent-support'), 422);
1027 + }
1028 +
1029 + return $propValue;
1030 +
1031 + case 'priority':
1032 + if (!array_key_exists($propValue, Helper::adminTicketPriorities())) {
1033 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1034 + }
1035 + return $propValue;
1036 +
1037 + case 'client_priority':
1038 + if (!array_key_exists($propValue, Helper::customerTicketPriorities())) {
1039 + throw new \Exception(esc_html__('Invalid client priority.', 'fluent-support'), 422);
1040 + }
1041 + return $propValue;
1042 +
1043 + case 'product_id':
1044 + $productId = (int) $propValue;
1045 + if (!$productId || !Product::where('id', $productId)->exists()) {
1046 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1047 + }
1048 + return $productId;
1049 +
1050 + case 'agent_id':
1051 + if (!PermissionManager::currentUserCan('fst_assign_agents')) {
1052 + throw new \Exception(esc_html__('Permission denied to assign agent', 'fluent-support'), 403);
1053 + }
1054 +
1055 + $agentId = (int) $propValue;
1056 + $agent = Agent::findOrFail($agentId);
1057 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($agent);
1058 +
1059 + if (in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1060 + throw new \Exception(esc_html__('Agent is restricted for this mailbox ticket', 'fluent-support'), 403);
1061 + }
1062 + return $agentId;
1063 + }
1064 +
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1066 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
1067 + }
1068 +
1069 + /**
1070 + * @param Request $request
217 1071 * @param int $ticket_id
218 1072 * @return array
219 1073 */
220 - public function closeTicket(Ticket $ticket, $ticket_id)
1074 + public function closeTicket(Request $request, $ticket_id)
221 1075 {
222 1076 try {
223 - return $ticket->closeTicket($ticket_id, $this->request->getSafe('close_ticket_silently'));
1077 + $agent = Helper::getAgentByUserId();
1078 + $ticket = Ticket::findOrFail($ticket_id);
1079 +
1080 + $this->ensureCanAccessTicket($ticket);
1081 +
1082 + $closeSilently = $request->getSafe('close_ticket_silently', 'sanitize_text_field');
1083 +
1084 + return [
1085 + 'message' => __('Ticket has been closed', 'fluent-support'),
1086 + 'ticket' => (new TicketService())->close($ticket, $agent, '', $closeSilently)
1087 + ];
224 1088 } catch (\Exception $e) {
225 - return $this->sendError($e->getMessage());
1089 + return $this->sendError([
1090 + 'message' => Helper::getSafeErrorMessage($e)
1091 + ]);
226 1092 }
227 1093 }
228 1094
229 1095 /**
230 - * reOpenTicket method will reopen a closed ticket
231 - * @param Request $request
232 - * @param $ticket_id
1096 + * @param int $ticket_id
233 1097 * @return array
234 1098 */
235 - public function reOpenTicket(Ticket $ticket, $ticket_id)
1099 + public function reOpenTicket($ticket_id)
236 1100 {
237 1101 try {
238 - return $ticket->reOpenTicket($ticket_id);
1102 + $agent = Helper::getAgentByUserId();
1103 + $ticket = Ticket::findOrFail($ticket_id);
1104 +
1105 + $this->ensureCanAccessTicket($ticket);
1106 +
1107 + return [
1108 + 'message' => __('Ticket has been opened again', 'fluent-support'),
1109 + 'ticket' => (new TicketService())->reopen($ticket, $agent)
1110 + ];
239 1111 } catch (\Exception $e) {
240 - return $this->sendError($e->getMessage());
1112 + return $this->sendError([
1113 + 'message' => Helper::getSafeErrorMessage($e)
1114 + ]);
241 1115 }
242 1116 }
243 1117
244 1118 /**
245 - * doBulkActions method is responsible for bulk action
246 - * This function will get ticket ids and action as parameter and perform action based on the selection
247 1119 * @param Request $request
248 - * @param Ticket $ticket
249 1120 * @return array|string[]|void
250 1121 * @throws \Exception
251 1122 */
252 - public function doBulkActions(Request $request, Ticket $ticket)
1123 + public function doBulkActions(Request $request)
253 1124 {
254 - $action = $request->getSafe('bulk_action', 'sanitize_text_field'); //get action
255 - $ticket_ids = $request->get('ticket_ids', []);
256 - $sanitizedTicketIds = array_map('intval', $ticket_ids);
1125 + try {
1126 + $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
257 1128
258 - try {
259 - return $ticket->handleBulkActions($action, $sanitizedTicketIds);
1129 + if (count($ticketIds) > self::MAX_BULK_TICKETS) {
1130 + throw new \Exception(sprintf(
1131 + /* translators: %d is the most tickets one bulk action can change */
1132 + esc_html__('You can change at most %d tickets at a time.', 'fluent-support'),
1133 + (int) self::MAX_BULK_TICKETS
1134 + ), 422);
1135 + }
1136 +
1137 + $agent = Helper::getAgentByUserId();
1138 + $query = Ticket::whereIn('id', $ticketIds);
1139 +
1140 + // Same access rules as ensureCanAccessTicket() on single-ticket routes.
1141 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1142 +
1143 + if ($action == 'close_tickets') {
1144 + $tickets = $query->get();
1145 + $tickets->each(function ($ticket) use ($agent) {
1146 + (new TicketService())->close($ticket, $agent);
1147 + });
1148 +
1149 + return [
1150 + 'message' => sprintf(
1151 + /* translators: %d represents the number of closed tickets. */
1152 + __('%d tickets have been closed.', 'fluent-support'),
1153 + count($tickets)
1154 + )
1155 + ];
1156 + } else if ($action == 'delete_tickets') {
1157 + $tickets = $query->get();
1158 + $ticketService = new TicketService();
1159 +
1160 + foreach ($tickets as $ticket) {
1161 + $ticketService->deleteTicket($ticket, $agent);
1162 + }
1163 +
1164 + return [
1165 + 'message' => sprintf(
1166 + /* translators: %d is the number of tickets that were deleted */
1167 + __('%d tickets have been deleted', 'fluent-support'),
1168 + count($tickets)
1169 + )
1170 + ];
1171 + } else if ($action == 'assign_agent') {
1172 + if (!$request->has('agent_id')) {
1173 + throw new \Exception(esc_html__('agent_id param is required', 'fluent-support'));
1174 + }
1175 +
1176 + $assignAgent = Agent::findOrFail($request->getSafe('agent_id', 'intval'));
1177 +
1178 + $query->where(function ($q) use ($assignAgent) {
1179 + $q->where('agent_id', '!=', $assignAgent->id)
1180 + ->orWhereNull('agent_id');
1181 + });
1182 +
1183 + $tickets = $query->get();
1184 + $assignedCount = 0;
1185 + $skippedCount = 0;
1186 +
1187 + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds($assignAgent);
1188 +
1189 + $tickets->each(function ($ticket) use ($assignAgent, $agent, $restrictedBoxes, &$assignedCount, &$skippedCount) {
1190 + $previousAgentId = (int) $ticket->agent_id;
1191 +
1192 + if (!empty($ticket->mailbox_id) && in_array((int) $ticket->mailbox_id, $restrictedBoxes, true)) {
1193 + $skippedCount++;
1194 + return;
1195 + }
1196 +
1197 + $ticket->agent_id = $assignAgent->id;
1198 + $ticket->save();
1199 + $assignedCount++;
1200 +
1201 + do_action('fluent_support/agent_assigned_to_ticket', $assignAgent, $ticket, $agent, $previousAgentId);
1202 + });
1203 +
1204 + $assignedMessage = sprintf(
1205 + /* translators: %1$d is the number of tickets assigned, %2$s is the agent's name. */
1206 + __('%1$d tickets have been assigned to %2$s.', 'fluent-support'),
1207 + $assignedCount,
1208 + $assignAgent->full_name
1209 + );
1210 +
1211 + $skippedMessage = $skippedCount > 0
1212 + ? sprintf(
1213 + /* translators: %1$d is the number of skipped tickets due to mailbox restrictions. */
1214 + __('%1$d tickets were skipped due to mailbox restrictions or already being assigned.', 'fluent-support'),
1215 + $skippedCount
1216 + )
1217 + : '';
1218 +
1219 + return [
1220 + 'message' => trim($assignedMessage . ' ' . $skippedMessage)
1221 + ];
1222 + } else if ($action == 'assign_agent_group') {
1223 + if (!$request->has('agent_group_id')) {
1224 + throw new \Exception(esc_html__('agent_group_id param is required', 'fluent-support'));
1225 + }
1226 +
1227 + $groupId = $request->getSafe('agent_group_id', 'intval');
1228 + $group = AgentGroup::findOrFail($groupId);
1229 +
1230 + if ($group->agents()->count() === 0) {
1231 + throw new \Exception(esc_html__('No agents found in this group', 'fluent-support'));
1232 + }
1233 +
1234 + $tickets = $query->get();
1235 + $assignedCount = 0;
1236 + $skippedCount = 0;
1237 + $currentCounts = [];
1238 +
1239 + foreach ($tickets as $ticket) {
1240 + $previousAgentId = (int) $ticket->agent_id;
1241 + $selectedAgent = $group->getLeastLoadedAgent(
1242 + $ticket->mailbox_id, $currentCounts
1243 + );
1244 +
1245 + if (!$selectedAgent) {
1246 + $skippedCount++;
1247 + continue;
1248 + }
1249 +
1250 + $ticket->agent_id = $selectedAgent->id;
1251 + $ticket->save();
1252 + $assignedCount++;
1253 + $currentCounts[$selectedAgent->id] = ($currentCounts[$selectedAgent->id] ?? 0) + 1;
1254 +
1255 + as_enqueue_async_action('fluent_support/async_agent_assigned_to_ticket', [
1256 + $selectedAgent->id, $ticket->id, $agent->id, $previousAgentId
1257 + ], 'fluent-support');
1258 + }
1259 +
1260 + return [
1261 + 'message' => sprintf(
1262 + /* translators: %1$d is tickets assigned, %2$d is tickets skipped. */
1263 + __('%1$d tickets assigned via agent group. %2$d skipped.', 'fluent-support'),
1264 + $assignedCount,
1265 + $skippedCount
1266 + )
1267 + ];
1268 + } else if ($action == 'assign_tags') {
1269 + $tagIds = $request->get('tag_ids', null);
1270 + if (!is_array($tagIds)) {
1271 + $tagIds = [];
1272 + }
1273 + $tags = array_filter(array_map('absint', $tagIds));
1274 +
1275 + $query->get()->each(function ($ticket) use ($tags) {
1276 + $ticket->applyTags($tags);
1277 + });
1278 +
1279 + return [
1280 + 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1281 + ];
1282 + } else if ($action == 'remove_tags') {
1283 + $tagIds = $request->get('tag_ids', null);
1284 + $tags = array_filter(array_map('absint', is_array($tagIds) ? $tagIds : []));
1285 +
1286 + if (!$tags) {
1287 + throw new \Exception(esc_html__('Please select tags to remove', 'fluent-support'), 422);
1288 + }
1289 +
1290 + $query->get()->each(function ($ticket) use ($tags) {
1291 + $ticket->detachTags($tags);
1292 + });
1293 +
1294 + return [
1295 + 'message' => __('Selected tags have been removed from the tickets', 'fluent-support')
1296 + ];
1297 + } else if ($action == 'change_priority') {
1298 + $priority = $request->getSafe('priority', 'sanitize_text_field');
1299 +
1300 + if (!array_key_exists($priority, Helper::adminTicketPriorities())) {
1301 + throw new \Exception(esc_html__('Invalid ticket priority.', 'fluent-support'), 422);
1302 + }
1303 +
1304 + return [
1305 + 'message' => sprintf(
1306 + /* translators: %d is the number of tickets whose priority changed */
1307 + __('Priority changed on %d tickets.', 'fluent-support'),
1308 + $this->bulkSetProperty($query, 'priority', $priority)
1309 + )
1310 + ];
1311 + } else if ($action == 'change_product') {
1312 + $productId = $request->getSafe('product_id', 'intval');
1313 +
1314 + if (!$productId || !Product::where('id', $productId)->exists()) {
1315 + throw new \Exception(esc_html__('Invalid product.', 'fluent-support'), 422);
1316 + }
1317 +
1318 + return [
1319 + 'message' => sprintf(
1320 + /* translators: %d is the number of tickets whose product changed */
1321 + __('Product changed on %d tickets.', 'fluent-support'),
1322 + $this->bulkSetProperty($query, 'product_id', $productId)
1323 + )
1324 + ];
1325 + } else if ($action == 'reopen_tickets') {
1326 + // Only closed tickets reopen; the rest are left as they are.
1327 + $tickets = $query->where('status', 'closed')->get();
1328 + $ticketService = new TicketService();
1329 +
1330 + foreach ($tickets as $ticket) {
1331 + $ticketService->reopen($ticket, $agent);
1332 + }
1333 +
1334 + return [
1335 + 'message' => sprintf(
1336 + /* translators: %d is the number of tickets reopened */
1337 + __('%d tickets have been reopened.', 'fluent-support'),
1338 + count($tickets)
1339 + )
1340 + ];
1341 + }
1342 +
1343 + throw new \Exception(esc_html__('Sorry no action found as available', 'fluent-support'));
260 1344 } catch (\Exception $e) {
261 - return $this->sendError($e->getMessage());
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
262 1348 }
263 1349 }
264 1350
265 1351 /**
266 - * deleteTicket method will delete a ticket
267 - * @param Request $request
268 - * @param TicketService $ticketService
1352 + * Set one column on every ticket the bulk query matches, skipping tickets
1353 + * that already have the value.
1354 + *
1355 + * @param \FluentSupport\Framework\Database\Orm\Builder $query
1356 + * @param string $column
1357 + * @param mixed $value
1358 + * @return int Number of tickets changed.
1359 + */
1360 + protected function bulkSetProperty($query, $column, $value)
1361 + {
1362 + $changed = 0;
1363 +
1364 + /** @var Ticket $ticket */
1365 + foreach ($query->get() as $ticket) {
1366 + if ($ticket->{$column} == $value) {
1367 + continue;
1368 + }
1369 + $ticket->{$column} = $value;
1370 + $ticket->save();
1371 + $changed++;
1372 + }
1373 +
1374 + return $changed;
1375 + }
1376 +
1377 + /**
1378 + * @param int $ticket_id
269 1379 * @return array
270 1380 */
271 - public function deleteTicket(TicketService $ticketService, $ticket_id)
1381 + public function deleteTicket($ticket_id)
272 1382 {
273 - $ticket = Ticket::findOrFail($ticket_id);
274 1383 try {
275 - return $ticketService->delete($ticket);
1384 + $ticket = Ticket::findOrFail($ticket_id);
1385 +
1386 + $this->ensureCanAccessTicket($ticket);
1387 +
1388 + (new TicketService())->deleteTicket($ticket);
1389 +
1390 + return [
1391 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
1392 + ];
276 1393 } catch (\Exception $e) {
277 - return $this->sendError($e->getMessage());
1394 + return $this->sendError([
1395 + 'message' => Helper::getSafeErrorMessage($e)
1396 + ]);
278 1397 }
279 1398 }
280 1399
281 1400 /**
282 - * doBulkReplies method will create response for bulk tickets
283 - * This function will get ticket ids, content, attachment etc and create response for tickets
284 1401 * @param Request $request
285 - * @param Conversation $conversation
286 1402 * @return array
287 1403 * @throws \Exception
288 1404 */
289 - public function doBulkReplies(Request $request, Conversation $conversation)
1405 + public function doBulkReplies(Request $request)
290 1406 {
291 - $data = $request->get();
292 - $this->validate($data, [
293 - 'content' => 'required',
294 - 'ticket_ids' => 'required|array'
295 - ]);
1407 + try {
1408 + $requestData = $request->all();
1409 + $data = [];
1410 + foreach ($requestData as $key => $value) {
1411 + if (is_array($value)) {
1412 + if ($key === 'ticket_ids') {
1413 + $data[$key] = array_map('intval', $value);
1414 + } elseif ($key === 'content') {
1415 + $data[$key] = wp_kses_post($value);
1416 + } else {
1417 + $data[$key] = map_deep($value, 'sanitize_text_field');
1418 + }
1419 + } else {
1420 + $data[$key] = sanitize_text_field($value);
1421 + }
1422 + }
296 1423
297 - try {
298 - return $conversation->doBulkReplies($data);
1424 + $this->validate($data, [
1425 + 'content' => 'required',
1426 + 'ticket_ids' => 'required|array'
1427 + ]);
1428 +
1429 + $agent = Helper::getAgentByUserId();
1430 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
1431 +
1432 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1433 +
1434 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1435 +
1436 + $tickets = $query->get();
1437 +
1438 + if ($tickets->isEmpty()) {
1439 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
1440 + }
1441 +
1442 + $responseData = [
1443 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1444 + 'conversation_type' => 'response',
1445 + 'close_ticket' => Arr::get($data, 'close_ticket'),
1446 + ];
1447 +
1448 + $attachmentHashes = Arr::get($data, 'attachments', []);
1449 + $attachments = false;
1450 + if ($attachmentHashes) {
1451 + $attachments = Attachment::whereNull('ticket_id')
1452 + ->orderBy('id', 'asc')
1453 + ->whereIn('file_hash', $attachmentHashes)
1454 + ->get();
1455 + }
1456 +
1457 + $responseService = new ResponseService();
1458 +
1459 + foreach ($tickets as $ticket) {
1460 + if ($attachments) {
1461 + $responseData['attachments'] = [];
1462 + $attachmentRecords = [];
1463 + foreach ($attachments as $attachment) {
1464 + $fileHash = bin2hex(random_bytes(16));
1465 + $attachmentRecords[] = [
1466 + 'ticket_id' => $ticket->id,
1467 + 'file_path' => $attachment->file_path,
1468 + 'full_url' => $attachment->full_url,
1469 + 'title' => $attachment->title,
1470 + 'driver' => $attachment->driver,
1471 + 'file_size' => $attachment->file_size,
1472 + 'status' => $attachment->status,
1473 + 'file_hash' => $fileHash,
1474 + ];
1475 + $responseData['attachments'][] = $fileHash;
1476 + }
1477 + if ($attachmentRecords) {
1478 + Attachment::insert($attachmentRecords);
1479 + }
1480 + }
1481 +
1482 + $responseService->createResponse($responseData, $agent, $ticket);
1483 + }
1484 +
1485 + return [
1486 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
1487 + ];
299 1488 } catch (\Exception $e) {
300 - return $this->sendError($e->getMessage());
1489 + return $this->sendError([
1490 + 'message' => Helper::getSafeErrorMessage($e)
1491 + ]);
301 1492 }
302 1493 }
303 1494
304 1495 /**
305 - * deleteResponse method will remove a response from ticket by ticket id and response id
306 - * @param Request $request
307 - * @param Conversation $conversation
308 - * @param $ticket_id
309 - * @param $response_id
1496 + * @param int $ticket_id
1497 + * @param int $response_id
310 1498 * @return array
311 1499 */
312 - public function deleteResponse(Conversation $conversation, $ticket_id, $response_id)
1500 + public function deleteResponse($ticket_id, $response_id)
313 1501 {
314 1502 try {
315 - return $conversation->deleteResponse($ticket_id, $response_id);
1503 + $ticket = Ticket::findOrFail($ticket_id);
1504 +
1505 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1506 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1507 + }
1508 +
1509 + $this->ensureCanAccessTicket($ticket);
1510 +
1511 + // Being assigned to the ticket isn't enough to delete a response.
1512 + if (!PermissionManager::currentUserCan('fst_delete_tickets')) {
1513 + throw new \Exception(
1514 + esc_html__('Sorry, you do not have permission to delete this response.', 'fluent-support')
1515 + );
1516 + }
1517 +
1518 + $response = Conversation::where('id', $response_id)
1519 + ->where('ticket_id', $ticket_id)
1520 + ->firstOrFail();
1521 +
1522 + $response->delete();
1523 + $response->ccinfo()->delete();
1524 +
1525 + return [
1526 + 'message' => __('Selected response has been deleted', 'fluent-support')
1527 + ];
316 1528 } catch (\Exception $e) {
317 - return $this->sendError($e->getMessage());
1529 + return $this->sendError([
1530 + 'message' => Helper::getSafeErrorMessage($e)
1531 + ]);
318 1532 }
319 1533 }
320 1534
321 1535 /**
322 - * updateResponse method will update ticket response using ticket and response id
323 - * @param Request $request
324 - * @param Conversation $conversation
1536 + * @param TicketResponseRequest $request
325 1537 * @param int $ticket_id
326 1538 * @param int $response_id
327 1539 * @return array
328 1540 * @throws \Exception
329 1541 */
330 - public function updateResponse(TicketResponseRequest $request, Conversation $conversation, $ticket_id, $response_id)
1542 + public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
331 1543 {
332 - $data = $request->getSafe(['content', 'ticket_id', 'response_id']);
1544 + try {
1545 + $ticket = Ticket::findOrFail($ticket_id);
333 1546
334 - try {
335 - return $conversation->updateResponse($data, $ticket_id, $response_id);
1547 + if (in_array($ticket->mailbox_id, PermissionManager::getRestrictedMailboxIds())) {
1548 + throw new \Exception(esc_html__('Ticket cannot be fetched due to restricted mailbox', 'fluent-support'), 403);
1549 + }
1550 +
1551 + $this->ensureCanAccessTicket($ticket);
1552 +
1553 + $response = Conversation::where('id', $response_id)
1554 + ->where('ticket_id', $ticket_id)
1555 + ->with('person')
1556 + ->firstOrFail();
1557 + $agent = Helper::getAgentByUserId();
1558 +
1559 + $editableTypes = ['response', 'draft_response', 'note', 'internal_info'];
1560 + if (!in_array($response->conversation_type, $editableTypes, true)) {
1561 + throw new \Exception(
1562 + esc_html__('This response type cannot be edited.', 'fluent-support')
1563 + );
1564 + }
1565 +
1566 + // Customer replies also use the 'response' type.
1567 + if ($response->person && $response->person->person_type !== 'agent') {
1568 + throw new \Exception(
1569 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1570 + );
1571 + }
1572 +
1573 + $isDraft = $response->conversation_type == 'draft_response';
1574 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1575 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
1576 +
1577 + if ($isDraft && !$isAuthor) {
1578 + if (!$canApproveDraft) {
1579 + throw new \Exception(
1580 + esc_html__('Sorry, You do not have permission to approve this draft response', 'fluent-support')
1581 + );
1582 + }
1583 + } elseif (!$isAuthor && !PermissionManager::currentUserCan('fst_manage_other_tickets')) {
1584 + throw new \Exception(
1585 + esc_html__('Sorry, you do not have permission to update this response.', 'fluent-support')
1586 + );
1587 + }
1588 +
1589 + // Input is already unslashed. Unslashing again strips real backslashes.
1590 + $content = wp_kses_post($request->getSafe('content', 'wp_kses_post'));
1591 + $response->content = $content;
1592 +
1593 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1594 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1595 + } else {
1596 + $response->save();
1597 + }
1598 +
1599 + return [
1600 + 'message' => __('Selected response has been updated', 'fluent-support'),
1601 + 'response' => $response
1602 + ];
336 1603 } catch (\Exception $e) {
337 - return $this->sendError($e->getMessage());
1604 + return $this->sendError([
1605 + 'message' => Helper::getSafeErrorMessage($e)
1606 + ]);
338 1607 }
339 1608 }
340 1609
341 - public function approveDraftResponse(TicketResponseRequest $request, Conversation $conversation, $ticket_id, $response_id)
1610 + public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
342 1611 {
343 - $data = [
344 - 'content' => $request->getSafe('content', 'sanitize_text_field')
345 - ];
346 - $conversationType = 'response';
1612 + try {
1613 + if (!PermissionManager::currentUserCan('fst_approve_draft_reply')) {
1614 + throw new \Exception(
1615 + esc_html__('You do not have permission to approve draft responses.', 'fluent-support')
1616 + );
1617 + }
347 1618
348 - try {
349 - return $conversation->publishDraftResponse($data, $ticket_id, $response_id, $conversationType);
1619 + $ticket = Ticket::findOrFail($ticket_id);
1620 +
1621 + $this->ensureCanAccessTicket($ticket);
1622 +
1623 + $response = Conversation::where('id', $response_id)
1624 + ->where('ticket_id', $ticket_id)
1625 + ->where('conversation_type', 'draft_response')
1626 + ->firstOrFail();
1627 +
1628 + $person = Helper::getAgentByUserId();
1629 +
1630 + $response = $this->approveDraftConversation(
1631 + $ticket,
1632 + $response,
1633 + $person,
1634 + wp_kses_post($request->getSafe('content', 'wp_kses_post'))
1635 + );
1636 +
1637 + return [
1638 + 'message' => __('Draft response has been successfully approved.', 'fluent-support'),
1639 + 'response' => $response,
1640 + ];
350 1641 } catch (\Exception $e) {
351 - return $this->sendError($e->getMessage());
1642 + return $this->sendError([
1643 + 'message' => Helper::getSafeErrorMessage($e)
1644 + ]);
352 1645 }
353 1646 }
354 1647
1648 + protected function approveDraftConversation($ticket, $response, $person, $content)
1649 + {
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1651 + }
1652 +
355 1653 /**
356 - * getLiveActivity method will return the activity in a ticket by agents
1654 + * Agents currently viewing the ticket.
1655 + *
357 1656 * @param Request $request
358 - * @param $ticket_id
1657 + * @param int $ticket_id
359 1658 * @return array
360 1659 */
361 1660 public function getLiveActivity(Request $request, $ticket_id)
362 1661 {
363 - $agent = Helper::getAgentByUserId();
1662 + try {
1663 + $ticket = Ticket::findOrFail($ticket_id);
364 1664
365 - return [
366 - 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
367 - ];
1665 + $this->ensureCanAccessTicket($ticket);
1666 +
1667 + $agent = Helper::getAgentByUserId();
1668 +
1669 + return [
1670 + 'live_activity' => TicketHelper::getActivity($ticket_id, $agent->id)
1671 + ];
1672 + } catch (\Exception $e) {
1673 + return $this->sendError([
1674 + 'message' => Helper::getSafeErrorMessage($e)
1675 + ]);
1676 + }
368 1677 }
369 1678
370 1679 /**
371 - * removeLiveActivity method will remove activities that
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
372 1682 * @param Request $request
373 - * @param $ticket_id
1683 + * @param int $ticket_id
374 1684 * @return array
375 1685 */
376 1686 public function removeLiveActivity(Request $request, $ticket_id)
377 1687 {
378 - $agent = Helper::getAgentByUserId();
1688 + try {
1689 + $ticket = Ticket::findOrFail($ticket_id);
379 1690
380 - return [
381 - 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
382 - 'agent_id' => $agent->id
383 - ];
1691 + $this->ensureCanAccessTicket($ticket);
1692 +
1693 + $agent = Helper::getAgentByUserId();
1694 +
1695 + return [
1696 + 'result' => TicketHelper::removeFromActivities($ticket_id, $agent->id),
1697 + 'agent_id' => $agent->id
1698 + ];
1699 + } catch (\Exception $e) {
1700 + return $this->sendError([
1701 + 'message' => Helper::getSafeErrorMessage($e)
1702 + ]);
1703 + }
384 1704 }
385 1705
386 1706 /**
387 - * addTag method will add tag in ticket by ticket id
388 1707 * @param Request $request
389 - * @param $ticket_id
1708 + * @param int $ticket_id
390 1709 * @return array
391 1710 */
392 1711 public function addTag(Request $request, $ticket_id)
393 1712 {
394 - $ticket = Ticket::findOrFail($ticket_id);
1713 + try {
1714 + $ticket = Ticket::findOrFail($ticket_id);
395 1715
396 - $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1716 + $this->ensureCanAccessTicket($ticket);
397 1717
398 - return [
399 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
400 - 'tags' => $ticket->tags
401 - ];
1718 + $ticket->applyTags($request->getSafe('tag_id', 'intval'));
1719 +
1720 + return [
1721 + 'message' => __('Tag has been added to this ticket', 'fluent-support'),
1722 + 'tags' => $ticket->tags
1723 + ];
1724 + } catch (\Exception $e) {
1725 + return $this->sendError([
1726 + 'message' => Helper::getSafeErrorMessage($e)
1727 + ]);
1728 + }
402 1729 }
403 1730
404 1731 /**
405 - * detachTag method will remove all tags from tickets
406 - * @param $ticket_id
407 - * @param $tag_id
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
408 1736 * @return array
409 1737 */
410 1738 public function detachTag($ticket_id, $tag_id)
411 1739 {
412 - $ticket = Ticket::findOrFail($ticket_id);
413 - $ticket->detachTags($tag_id);
1740 + try {
1741 + $ticket = Ticket::findOrFail($ticket_id);
414 1742
415 - return [
416 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
417 - 'tags' => $ticket->tags
418 - ];
1743 + $this->ensureCanAccessTicket($ticket);
1744 +
1745 + $ticket->detachTags($tag_id);
1746 +
1747 + return [
1748 + 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
1749 + 'tags' => $ticket->tags
1750 + ];
1751 + } catch (\Exception $e) {
1752 + return $this->sendError([
1753 + 'message' => Helper::getSafeErrorMessage($e)
1754 + ]);
1755 + }
419 1756 }
420 1757
421 1758 /**
422 - * changeTicketCustomer method will update customer in a ticket
423 - * This method will get ticket id and customer id as parameter, it will replace existing customer id with new
424 1759 * @param Request $request
1760 + * @param int $ticket_id
425 1761 * @return array
426 1762 */
427 - public function changeTicketCustomer(Request $request)
1763 + public function changeTicketCustomer(Request $request, $ticket_id)
428 1764 {
429 - $ticketId = $request->getSafe('ticket_id', 'intval');
1765 + $ticketId = (int) $ticket_id;
430 1766 $newCustomerId = $request->getSafe('customer', 'intval');
431 1767
432 1768 if (!$newCustomerId) {
433 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
@@ -432,26 +1768,46 @@
432 1768 if (!$newCustomerId) {
433 1769 return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
434 1770 }
435 1771
1772 + // The ticket would expose the new customer's profile data, so this
1773 + // needs the same capability as the customer routes.
1774 + if (!PermissionManager::currentUserCan('fst_sensitive_data')) {
1775 + return $this->sendError(__('You do not have permission to change the ticket customer.', 'fluent-support'));
1776 + }
1777 +
436 1778 try {
437 - $updated = Ticket::where('id', $ticketId)
438 - ->where('customer_id', '!=', $newCustomerId)
439 - ->update(['customer_id' => $newCustomerId]);
1779 + $ticket = Ticket::findOrFail($ticketId);
440 1780
441 - return $updated
442 - ? ['message' => __('Customer has been updated', 'fluent-support')]
443 - : $this->sendError(__('Ticket not found or customer already assigned.', 'fluent-support'));
1781 + $this->ensureCanAccessTicket($ticket);
444 1782
1783 + $targetCustomer = Customer::where('id', $newCustomerId)
1784 + ->where('person_type', 'customer')
1785 + ->first();
1786 +
1787 + if (!$targetCustomer) {
1788 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1789 + }
1790 +
1791 + if ($ticket->customer_id == $newCustomerId) {
1792 + return $this->sendError(__('Customer already assigned to this ticket.', 'fluent-support'));
1793 + }
1794 +
1795 + $ticket->customer_id = $newCustomerId;
1796 + $ticket->save();
1797 +
1798 + return ['message' => __('Customer has been updated', 'fluent-support')];
1799 +
445 1800 } catch (\Exception $e) {
446 - return $this->sendError($e->getMessage());
1801 + return $this->sendError([
1802 + 'message' => Helper::getSafeErrorMessage($e)
1803 + ]);
447 1804 }
448 1805 }
449 1806
450 1807 /**
451 - * getTicketCustomData method will return the custom data by ticket id
452 1808 * @param Request $request
453 - * @param $ticket_id
1809 + * @param int $ticket_id
454 1810 * @return array|array[]
455 1811 */
456 1812 public function getTicketCustomData(Request $request, $ticket_id)
457 1813 {
@@ -456,24 +1812,30 @@
456 1812 public function getTicketCustomData(Request $request, $ticket_id)
457 1813 {
458 1814 if (!defined('FLUENTSUPPORTPRO')) {
459 1815 return [
460 - 'custom_data' => [],
1816 + 'custom_data' => [],
461 1817 'rendered_fields' => []
462 1818 ];
463 1819 }
464 1820
465 - $ticket = Ticket::findOrFail($ticket_id);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
466 1823
467 - return [
468 - 'custom_data' => (object)$ticket->customData(),
469 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer, 'admin')
470 - ];
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 + }
471 1835 }
472 1836
473 1837 /**
474 - * syncFluentCrmTags method will synchronize the tags with Fluent CRM by contact id
475 - *This function will get contact id and tags as parameter, get existing tags from crm and updated added/removed tags
476 1838 * @param Request $request
477 1839 * @param FluentCRMServices $fluentCRMServices
478 1840 * @return array
479 1841 */
@@ -478,19 +1840,27 @@
478 1840 * @return array
479 1841 */
480 1842 public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
481 1843 {
482 - $data = $request->only(['contact_id', 'tags']);
1844 + $data = [
1845 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1846 + 'tags' => $request->get('tags', null)
1847 + ];
1848 +
1849 + if (is_array($data['tags'])) {
1850 + $data['tags'] = array_map('intval', $data['tags']);
1851 + }
1852 +
483 1853 try {
484 1854 return $fluentCRMServices->syncCrmTags($data);
485 1855 } catch (\Exception $e) {
486 - return $this->sendError($e->getMessage());
1856 + return $this->sendError([
1857 + 'message' => Helper::getSafeErrorMessage($e)
1858 + ]);
487 1859 }
488 1860 }
489 1861
490 1862 /**
491 - * This `syncFluentCrmLists` method will synchronize the lists with Fluent CRM by contact id
492 - * This method will get contact id and lists as parameter, get existing lists from crm and updated added/removed lists
493 1863 * @param Request $request
494 1864 * @param FluentCRMServices $fluentCRMServices
495 1865 * @return array
496 1866 */
@@ -496,122 +1866,46 @@
496 1866 */
497 1867
498 1868 public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
499 1869 {
500 - $data = $request->only(['contact_id', 'lists']);
501 - try {
502 - return $fluentCRMServices->syncCrmLists($data);
503 - } catch (\Exception $e) {
504 - return $this->sendError($e->getMessage());
505 - }
506 - }
1870 + $data = [
1871 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1872 + 'lists' => $request->get('lists', null, [])
1873 + ];
507 1874
508 - /**
509 - * Retrieve boards from Fluent Boards API.
510 - *
511 - * @return array Formatted array of boards.
512 - */
513 - public function getBoards()
514 - {
515 - $boards = FluentBoardsApi('boards')->getBoards();
516 - $formattedBoards = [];
517 -
518 - foreach ($boards as $board) {
519 - $formattedBoard = [
520 - 'id' => $board->id,
521 - 'title' => $board->title,
522 - 'tasks' => [],
523 - ];
524 -
525 - $formattedBoards[] = $formattedBoard;
1875 + if (is_array($data['lists'])) {
1876 + $data['lists'] = array_map('intval', $data['lists']);
526 1877 }
527 1878
528 - return ['boards' => $formattedBoards];
529 - }
530 -
531 - /**
532 - * Retrieve stages for a specific board from Fluent Boards API.
533 - *
534 - * @param Request $request Request object containing 'board_id'.
535 - * @return array Formatted array of stages.
536 - */
537 - public function getStages(Request $request)
538 - {
539 - $boardId = $request->input('board_id');
540 - $boardStages = FluentBoardsApi('boards')->getStagesByBoard($boardId);
541 -
542 - $formattedStages = [];
543 - if (!empty($boardStages)) {
544 - foreach ($boardStages[0]->stages as $stage) {
545 - $formattedStages[] = [
546 - 'id' => $stage->id,
547 - 'title' => $stage->title,
548 - ];
549 - }
550 - }
551 -
552 - return ['stages' => $formattedStages];
553 - }
554 -
555 - /**
556 - * Create a task using data provided in the request.
557 - *
558 - * @param Request $request Request object containing task data.
559 - * @return array Response containing message and task data.
560 - */
561 - public function createTask(Request $request, FluentBoardsService $fluentBoardsService)
562 - {
563 1879 try {
564 - $taskData = [
565 - 'source_id' => $request->getSafe('source_id', 'intval'),
566 - 'board_id' => $request->getSafe('board_id', 'intval'),
567 - 'stage_id' => $request->getSafe('stage_id', 'intval'),
568 - 'crm_contact_id' => $request->getSafe('crm_contact_id', 'intval') ?: null,
569 - 'title' => $request->getSafe('title', 'sanitize_text_field'),
570 - 'description' => $request->getSafe('description', 'wp_kses_post'),
571 - 'source' => $request->getSafe('source', 'sanitize_text_field'),
572 - 'started_at' => $request->getSafe('started_at', 'sanitize_text_field'),
573 - 'due_at' => $request->getSafe('due_at', 'sanitize_text_field'),
574 - ];
575 -
576 - $task = FluentBoardsApi('tasks')->create($taskData);
577 -
578 - if (!$task) {
579 - return $this->sendError(__('Failed to create task.', 'fluent-support'));
580 - }
581 -
582 - $fluentBoardsService->addInternalNote($task);
583 - $fluentBoardsService->addComment($task);
584 -
585 - return [
586 - 'message' => __('Task successfully added to Fluent Boards', 'fluent-support'),
587 - 'task' => $task
588 - ];
1880 + return $fluentCRMServices->syncCrmLists($data);
589 1881 } catch (\Exception $e) {
590 - return $this->sendError($e->getMessage());
1882 + return $this->sendError([
1883 + 'message' => Helper::getSafeErrorMessage($e)
1884 + ]);
591 1885 }
592 1886 }
593 1887
594 1888 /**
595 - * Get ticket essentials data based on the provided types.
596 - *
597 - * @param \Illuminate\Http\Request $request
598 - * @return array The ticket essentials data.
1889 + * @param Request $request
1890 + * @return array
599 1891 */
600 1892 public function getTicketEssentials(Request $request)
601 1893 {
602 - $type = $request->get('type');
1894 + $type = $request->getSafe('type', 'sanitize_text_field');
603 1895
604 1896 return TicketHelper::getTicketEssentials($type);
605 1897 }
606 1898
607 - public function fetchLabelSearch(Ticket $ticket)
1899 + public function fetchLabelSearch()
608 1900 {
609 1901 try {
610 1902 $agent_id = get_current_user_id();
611 1903 return TicketHelper::getLabelSearch($agent_id);
612 1904 } catch (\Exception $e) {
613 - return $this->sendError($e->getMessage());
1905 + return $this->sendError([
1906 + 'message' => Helper::getSafeErrorMessage($e)
1907 + ]);
614 1908 }
615 1909 }
616 1910
617 1911 public function storeOrUpdateLabelSearch(Request $request)
@@ -617,12 +1911,15 @@
617 1911 public function storeOrUpdateLabelSearch(Request $request)
618 1912 {
619 1913 try {
620 1914 $agent_id = get_current_user_id();
621 - $searchData = $request->get('query');
1915 + $searchData = $request->get('query', null, []);
1916 + if (is_array($searchData)) {
1917 + $searchData = map_deep($searchData, 'sanitize_text_field');
1918 + }
622 1919 $filterType = Arr::get($searchData, 'filter_type', '');
623 1920 if ($filterType == 'advanced') {
624 - return TicketHelper::saveSearchLabel($agent_id,$searchData,$filterType);
1921 + return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
625 1922 }
626 1923
627 1924 return [
628 1925 'message' => __('Invalid filter type.', 'fluent-support'),
@@ -628,9 +1925,11 @@
628 1925 'message' => __('Invalid filter type.', 'fluent-support'),
629 1926 ];
630 1927
631 1928 } catch (\Exception $e) {
632 - return $this->sendError($e->getMessage());
1929 + return $this->sendError([
1930 + 'message' => Helper::getSafeErrorMessage($e)
1931 + ]);
633 1932 }
634 1933 }
635 1934
636 1935 public function deleteLabelSearch(Request $request, $search_id)
@@ -638,9 +1937,10 @@
638 1937 try {
639 1938 $agent_id = get_current_user_id();
640 1939 return TicketHelper::deleteSavedSearch($search_id);
641 1940 } catch (\Exception $e) {
642 - return $this->sendError($e->getMessage());
1941 + return $this->sendError([
1942 + 'message' => Helper::getSafeErrorMessage($e)
1943 + ]);
643 1944 }
644 1945 }
645 1946 }
646 -