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 +1671 -385 1.5.2 → 2.4.5 View file →
@@ -3,593 +3,1814 @@
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 5 use FluentSupport\App\Models\Agent;
6 6 use FluentSupport\App\Models\Attachment;
7 +use FluentSupport\App\Models\Meta;
7 8 use FluentSupport\App\Models\Customer;
8 -use FluentSupport\App\Models\MailBox;
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;
9 13 use FluentSupport\App\Models\Conversation;
14 +use FluentSupport\App\Models\Product;
10 15 use FluentSupport\App\Models\Ticket;
11 -use FluentSupport\App\Modules\PermissionManager;
16 +use FluentSupport\App\Services\FluentCRMServices;
12 17 use FluentSupport\App\Services\Helper;
13 18 use FluentSupport\App\Services\ProfileInfoService;
14 19 use FluentSupport\App\Services\TicketHelper;
20 +use FluentSupport\App\Services\TicketQueryService;
21 +use FluentSupport\App\Modules\PermissionManager;
22 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
15 23 use FluentSupport\App\Services\Tickets\ResponseService;
24 +use FluentSupport\App\Models\AgentGroup;
16 25 use FluentSupport\App\Services\Tickets\TicketService;
17 -use FluentSupport\Framework\Request\Request;
18 -use FluentSupport\Framework\Support\Arr;
26 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
19 27
28 +/**
29 + * Agent-side REST endpoints for tickets.
30 + */
20 31 class TicketController extends Controller
21 32 {
33 + /**
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 + *
42 + * @param Request $request
43 + * @return array
44 + */
22 45 public function me(Request $request)
23 46 {
24 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 + }
25 57
26 - return [
27 - 'user_id' => $user->id,
58 + $settings = [
59 + 'user_id' => $user->ID,
28 60 'email' => $user->user_email,
29 61 'person' => Helper::getAgentByUserId($user->ID),
30 62 'permissions' => PermissionManager::currentUserPermissions(),
31 - 'request' => $request->all()
63 + 'request' => $sanitizedRequest
32 64 ];
65 +
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']);
70 +
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;
33 85 }
34 86
87 + /**
88 + * @param Request $request
89 + * @return array
90 + */
35 91 public function index(Request $request)
36 92 {
37 - $ticketsQuery = Ticket::with([
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([
38 117 'customer' => function ($query) {
39 - $query->select(['first_name', 'last_name', 'email', 'id']);
118 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
40 119 }, 'agent' => function ($query) {
41 - $query->select(['first_name', 'last_name', 'id']);
120 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
42 121 },
122 + 'mailbox',
43 123 'product',
44 124 'tags',
45 125 'preview_response' => function ($query) {
46 - $query->orderBy('id', 'desc');
126 + $query->latest('id');
47 127 }
48 128 ]);
49 129
50 - // apply filters by access level
51 - do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsQuery, false]);
130 + do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
52 131
53 - if ($customerId = $request->get('customer_id')) {
54 - $ticketsQuery = $ticketsQuery->where('customer_id', $customerId);
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);
55 139 }
56 140
57 - if ($filters = $request->get('filters', [])) {
58 - $ticketsQuery->applyFilters($filters);
141 + return [
142 + 'tickets' => $tickets
143 + ];
144 + }
145 +
146 + /**
147 + * Create a ticket, creating the customer and optionally a WP user first.
148 + *
149 + * @param TicketRequest $request
150 + * @return array
151 + */
152 + public function createTicket(TicketRequest $request)
153 + {
154 + try {
155 + $data = $request->sanitize();
156 + $ticketData = $data['ticket'];
157 + $maybeNewCustomer = Arr::get($data, 'newCustomer', []);
158 +
159 + if (!empty($data['attachments'])) {
160 + $ticketData['attachments'] = $data['attachments'];
161 + }
162 +
163 + if (empty($ticketData['customer_id'])) {
164 + $createdUserId = false;
165 +
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 + }
173 +
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 + }
180 +
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) {
213 + return $this->sendError([
214 + 'message' => Helper::getSafeErrorMessage($e)
215 + ]);
59 216 }
217 + }
60 218
61 - if ($search = $request->get('search')) {
62 - $ticketsQuery->searchBy($search);
63 - }
219 + /**
220 + * @param Request $request
221 + * @param int $ticket_id
222 + * @return array
223 + */
224 + public function getTicket(Request $request, $ticket_id)
225 + {
226 + try {
227 + $agent = Helper::getAgentByUserId();
64 228
65 - $ticketsQuery->orderBy($request->get('order_by', 'id'), $request->get('order_type', 'ASC'));
229 + $ticketWith = $request->get('with');
230 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
66 231
67 - $tickets = $ticketsQuery->paginate();
232 + if (!$ticketWith) {
233 + $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
234 + $q->where('status', 'active');
235 + }];
236 + }
68 237
69 - $perPage = $request->get('per_page');
238 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
70 239
71 - foreach ($tickets as $ticket) {
72 - if ($perPage < 15) {
73 - if ($ticket->status != 'closed') {
74 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
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);
75 319 } else {
76 - $ticket->live_activity = [];
320 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
77 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 + }
78 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;
397 + } catch (\Exception $e) {
398 + return $this->sendError([
399 + 'message' => Helper::getSafeErrorMessage($e)
400 + ]);
79 401 }
80 -
81 - return [
82 - 'tickets' => $tickets
83 - ];
84 402 }
85 403
86 - public function createTicket(Request $request)
404 + public function getMentionableAgents(Request $request, $ticket_id)
87 405 {
88 - $ticketData = $request->get('ticket', []);
89 - $this->validate($ticketData, [
90 - 'customer_id' => 'required',
91 - 'title' => 'required',
92 - 'content' => 'required'
93 - ]);
406 + try {
407 + $ticket = Ticket::findOrFail($ticket_id);
94 408
95 - $customer = Customer::findOrFail($ticketData['customer_id']);
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 + }
96 412
97 - if (empty($ticketData['mailbox_id'])) {
98 - $mailbox = Helper::getDefaultMailBox();
99 - $ticketData['mailbox_id'] = $mailbox->id;
100 - } else {
101 - $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); // just for validation
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 + ]);
102 425 }
426 + }
103 427
104 - if (!empty($ticketData['product_id'])) {
105 - $data['product_source'] = 'local';
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 [];
106 438 }
107 439
108 - $ticketData['title'] = sanitize_text_field(wp_unslash($ticketData['title']));
440 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
441 + $ticketAccess = new AgentTicketAccess();
442 + $results = [];
109 443
110 - $ticketData['content'] = wp_unslash(wp_kses_post($ticketData['content']));
444 + foreach ($allAgents as $agent) {
445 + if (!$ticketAccess->canAccess($agent, $ticket, $restrictions[$agent->id] ?? [])) {
446 + continue;
447 + }
111 448
112 - if (!empty($ticketData['priority'])) {
113 - $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
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 + }
114 459 }
115 460
116 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
461 + return $results;
462 + }
117 463
118 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
119 - do_action('fluent_support/before_ticket_create', $ticketData, $customer);
464 + protected function getAgentRestrictionsMap(array $agentIds)
465 + {
466 + if (!$agentIds) {
467 + return [];
468 + }
120 469
121 - $createdTicket = Ticket::create($ticketData);
470 + $metas = Meta::where('object_type', 'person_meta')
471 + ->where('key', 'agent_restrictions')
472 + ->whereIn('object_id', $agentIds)
473 + ->get();
122 474
123 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
124 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
125 - $createdTicket->custom_fields = $createdTicket->customData();
475 + $restrictions = [];
476 + foreach ($metas as $meta) {
477 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
126 478 }
127 479
128 - do_action('fluent_support/ticket_created', $createdTicket, $customer);
480 + return $restrictions;
481 + }
129 482
130 - return [
131 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
132 - 'ticket' => $createdTicket
133 - ];
483 + /**
484 + * @param TicketResponseRequest $request
485 + * @param int $ticket_id
486 + * @return array
487 + * @throws \FluentSupport\Framework\Validator\ValidationException
488 + */
489 + public function createResponse(TicketResponseRequest $request, $ticket_id)
490 + {
491 + $data = $request->sanitize();
134 492
493 + try {
494 + $convoType = Arr::get($data, 'conversation_type', 'response');
495 + $isDraft = $convoType === 'draft_response';
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 + }
135 547 }
136 548
137 - public function getTicket(Request $request, $ticketId)
549 + public function getFluentBookingEventTypes()
138 550 {
139 - $agent = Helper::getAgentByUserId();
140 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
141 - $q->where('status', 'active');
142 - }]);
143 - $responseWith = $request->get('response_with', ['person', 'attachments']);
551 + try {
552 + // All FluentBooking endpoints need manage permission. View-only agents can't book meetings.
553 + $this->ensureCanManageTickets();
144 554
145 - $ticket = Ticket::with($ticketWith)
146 - ->findOrFail($ticketId);
555 + $service = new FluentBookingService();
556 + $eventTypes = $service->getEventTypes();
147 557
148 - if ($ticket->customer) {
149 - $ticket->customer->profile_edit_url = $ticket->customer->getUserProfileEditUrl();
558 + return [
559 + 'status' => $service->getStatus($eventTypes),
560 + 'event_types' => $eventTypes
561 + ];
562 + } catch (\Exception $e) {
563 + return $this->sendError([
564 + 'message' => Helper::getSafeErrorMessage($e)
565 + ]);
150 566 }
567 + }
151 568
152 - if (!PermissionManager::hasTicketPermission($ticket)) {
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) {
153 592 return $this->sendError([
154 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
593 + 'message' => Helper::getSafeErrorMessage($e)
155 594 ]);
156 595 }
596 + }
157 597
598 + public function getFluentBookingAvailability(Request $request, $ticket_id)
599 + {
600 + try {
601 + $this->ensureCanManageTickets();
158 602
159 - if ($ticket->status == 'closed') {
160 - $ticket->load('closed_by_person');
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 + ]);
161 628 }
629 + }
162 630
163 - $responses = Conversation::where('ticket_id', $ticketId)
164 - ->with($responseWith)
165 - ->orderBy('id', 'DESC')
166 - ->get();
631 + public function getFluentBookingMeetings($ticket_id)
632 + {
633 + try {
634 + $this->ensureCanManageTickets();
167 635
168 - foreach ($responses as $response) {
169 - $response->content = make_clickable(wpautop($response->content, false));
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 + ]);
170 647 }
648 + }
171 649
172 - $ticket->content = make_clickable(wpautop($ticket->content, false));
650 + /**
651 + * @param TicketResponseRequest $request
652 + * @param int $ticket_id
653 + * @return array
654 + * @throws \FluentSupport\Framework\Validator\ValidationException
655 + */
656 + public function createOrUpdatDraft(TicketResponseRequest $request, $ticket_id)
657 + {
658 + $data = $request->sanitize();
173 659
174 - $ticket->live_activity = TicketHelper::getActivity($ticketId, $agent->id);
660 + try {
661 + $agent = Helper::getAgentByUserId();
175 662
176 - if (defined('FLUENTSUPPORTPRO')) {
177 - $ticket->custom_fields = $ticket->customData('admin', true);
178 - }
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 + }
179 668
180 - $data = [
181 - 'ticket' => $ticket,
182 - 'responses' => $responses,
183 - 'agent_id' => $agent->id
184 - ];
669 + $ticket = Ticket::findOrFail($ticket_id);
185 670
186 - if (in_array('fluentcrm_profile', $request->get('with_data', [])) && defined('FLUENTCRM')) {
187 - $data['fluentcrm_profile'] = Helper::getFluentCrmContactData($ticket->customer);
188 - }
671 + $this->ensureCanAccessTicket($ticket);
189 672
190 - return $data;
673 + $key = 'ticket_no_' . $ticket_id . '_agent_id_' . $agent->id . '_response_draft';
674 + $previousDraft = Meta::where('key', $key)->first();
191 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 + ];
698 + } catch (\Exception $e) {
699 + return $this->sendError([
700 + 'message' => Helper::getSafeErrorMessage($e)
701 + ]);
702 + }
192 703 }
193 704
194 - public function createResponse(Request $request, $ticketId)
705 + public function getDraft($ticket_id)
195 706 {
196 - $data = $request->all();
707 + try {
708 + $agent = Helper::getAgentByUserId();
197 709
198 - $this->validate($data, [
199 - 'content' => 'required'
200 - ]);
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 + }
201 715
202 - $agent = Helper::getAgentByUserId(get_current_user_id());
716 + $ticket = Ticket::findOrFail($ticket_id);
203 717
204 - if (!$agent) {
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 + ];
734 + } catch (\Exception $e) {
205 735 return $this->sendError([
206 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
736 + 'message' => Helper::getSafeErrorMessage($e)
207 737 ]);
208 738 }
739 + }
209 740
210 - $ticket = Ticket::findOrFail($ticketId);
741 + public function deleteDraft($draft_id)
742 + {
743 + $draft_id = intval($draft_id);
211 744
212 - if (!PermissionManager::hasTicketPermission($ticket)) {
745 + try {
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 + ];
783 + } catch (\Exception $e) {
213 784 return $this->sendError([
214 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
785 + 'message' => Helper::getSafeErrorMessage($e)
215 786 ]);
216 787 }
788 + }
217 789
218 - $responseData = (new ResponseService())->createResponse($data, $agent, $ticket);
790 + /**
791 + * The customer's other tickets, plus extra profile widgets on request.
792 + *
793 + * @param Request $request
794 + * @param int $ticket_id
795 + * @return array
796 + */
797 + public function getTicketWidgets(Request $request, $ticket_id)
798 + {
799 + try {
800 + $ticket = Ticket::with('customer')->findOrFail($ticket_id);
219 801
220 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
802 + $this->ensureCanAccessTicket($ticket);
221 803
222 - return [
223 - 'message' => __('Response has been added'),
224 - 'response' => $responseData['response'],
225 - 'ticket' => $responseData['ticket'],
226 - 'update_data' => $responseData['update_data']
227 - ];
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;
833 + } catch (\Exception $e) {
834 + return $this->sendError([
835 + 'message' => Helper::getSafeErrorMessage($e)
836 + ]);
837 + }
228 838 }
229 839
230 - public function getTicketWidgets(Request $request, $ticketId)
840 + /**
841 + * @param Request $request
842 + * @param int $ticket_id
843 + * @return array
844 + */
845 + public function updateTicketProperty(Request $request, $ticket_id)
231 846 {
232 - $ticket = Ticket::with('customer')->findOrFail($ticketId);
847 + try {
848 + $assigner = Helper::getAgentByUserId();
849 + $ticket = Ticket::findOrFail($ticket_id);
233 850
234 - if (!PermissionManager::hasTicketPermission($ticket)) {
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 + ];
916 + } catch (\Exception $e) {
235 917 return $this->sendError([
236 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
918 + 'message' => Helper::getSafeErrorMessage($e)
237 919 ]);
238 920 }
921 + }
239 922
240 - $otherTickets = Ticket::where('id', '!=', $ticketId)
241 - ->select(['id', 'title', 'status', 'created_at'])
242 - ->where('customer_id', $ticket->customer_id)
243 - ->orderBy('id', 'DESC')
923 + /**
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 + *
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')
244 948 ->limit(10)
245 949 ->get();
246 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 + {
247 979 return [
248 - 'other_tickets' => $otherTickets,
249 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
980 + 'agent_id',
981 + 'title',
982 + 'product_id',
983 + 'status',
984 + 'priority',
985 + 'client_priority',
250 986 ];
251 987 }
252 988
253 - public function updateTicketProperty(Request $request, $ticketId)
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)
254 1000 {
255 - $assigner = Helper::getAgentByUserId(get_current_user_id());
256 - $ticket = Ticket::findOrFail($ticketId);
257 - $propName = $request->get('prop_name');
258 - $propValue = $request->get('prop_value');
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;
259 1008
260 - if (!PermissionManager::hasTicketPermission($ticket)) {
261 - return $this->sendError([
262 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
263 - ]);
264 - }
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 + }
265 1018
266 - $prevValue = $ticket->{$propName};
267 - if ($propName && $propValue && $prevValue != $propValue) {
268 - $ticket->{$propName} = $propValue;
269 - $ticket->save();
270 - }
1019 + if (!in_array($propValue, $allowedStatuses, true)) {
1020 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1021 + }
271 1022
272 - $updateData = [];
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 + }
273 1028
274 - if ($propName == 'product_id') {
275 - $ticket->load('product');
276 - $updateData['product'] = $ticket->product;
277 - } else if ($propName == 'agent_id') {
278 - $ticket->load('agent');
279 - $updateData['agent'] = $ticket->agent;
280 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
281 - if ($prevValue != $ticket->{$propName}) {
282 - do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket);
283 - }
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;
284 1063 }
285 1064
286 - return [
287 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
288 - 'update_data' => $updateData
289 - ];
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1066 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
290 1067 }
291 1068
292 - public function closeTicket(Request $request, $ticketId)
1069 + /**
1070 + * @param Request $request
1071 + * @param int $ticket_id
1072 + * @return array
1073 + */
1074 + public function closeTicket(Request $request, $ticket_id)
293 1075 {
294 - $agent = Helper::getAgentByUserId(get_current_user_id());
1076 + try {
1077 + $agent = Helper::getAgentByUserId();
1078 + $ticket = Ticket::findOrFail($ticket_id);
295 1079
296 - $ticket = Ticket::findOrFail($ticketId);
1080 + $this->ensureCanAccessTicket($ticket);
297 1081
298 - if (!PermissionManager::hasTicketPermission($ticket)) {
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 + ];
1088 + } catch (\Exception $e) {
299 1089 return $this->sendError([
300 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1090 + 'message' => Helper::getSafeErrorMessage($e)
301 1091 ]);
302 1092 }
303 -
304 - return [
305 - 'message' => __('Ticket has been closed', 'fluent_support'),
306 - 'ticket' => (new TicketService())->close($ticket, $agent)
307 - ];
308 1093 }
309 1094
310 - public function reOpenTicket(Request $request, $ticketId)
1095 + /**
1096 + * @param int $ticket_id
1097 + * @return array
1098 + */
1099 + public function reOpenTicket($ticket_id)
311 1100 {
312 - $agent = Helper::getAgentByUserId(get_current_user_id());
1101 + try {
1102 + $agent = Helper::getAgentByUserId();
1103 + $ticket = Ticket::findOrFail($ticket_id);
313 1104
314 - $ticket = Ticket::findOrFail($ticketId);
1105 + $this->ensureCanAccessTicket($ticket);
315 1106
316 - if (!PermissionManager::hasTicketPermission($ticket)) {
1107 + return [
1108 + 'message' => __('Ticket has been opened again', 'fluent-support'),
1109 + 'ticket' => (new TicketService())->reopen($ticket, $agent)
1110 + ];
1111 + } catch (\Exception $e) {
317 1112 return $this->sendError([
318 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1113 + 'message' => Helper::getSafeErrorMessage($e)
319 1114 ]);
320 1115 }
321 -
322 - return [
323 - 'message' => __('Ticket has been opened again', 'fluent_support'),
324 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
325 - ];
326 1116 }
327 1117
1118 + /**
1119 + * @param Request $request
1120 + * @return array|string[]|void
1121 + * @throws \Exception
1122 + */
328 1123 public function doBulkActions(Request $request)
329 1124 {
330 - $ticketIds = $request->get('ticket_ids', []);
331 - $action = $request->get('bulk_action');
332 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
333 - $agent = Helper::getAgentByUserId();
334 - $query = Ticket::whereIn('id', $ticketIds);
1125 + try {
1126 + $action = $request->getSafe('bulk_action', 'sanitize_text_field');
1127 + $ticketIds = array_map('intval', (array) $request->get('ticket_ids', null, []));
335 1128
336 - if (!$hasAllPermission) {
337 - $query->where('agent_id', $agent->id);
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'));
1344 + } catch (\Exception $e) {
1345 + return $this->sendError([
1346 + 'message' => Helper::getSafeErrorMessage($e)
1347 + ]);
338 1348 }
1349 + }
339 1350
340 - if ($action == 'close_tickets') {
341 - $query->where('status', '!=', 'closed');
342 - $tickets = $query->get();
343 - foreach ($tickets as $ticket) {
344 - (new TicketService())->close($ticket, $agent);
1351 + /**
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;
345 1368 }
1369 + $ticket->{$column} = $value;
1370 + $ticket->save();
1371 + $changed++;
1372 + }
346 1373
1374 + return $changed;
1375 + }
1376 +
1377 + /**
1378 + * @param int $ticket_id
1379 + * @return array
1380 + */
1381 + public function deleteTicket($ticket_id)
1382 + {
1383 + try {
1384 + $ticket = Ticket::findOrFail($ticket_id);
1385 +
1386 + $this->ensureCanAccessTicket($ticket);
1387 +
1388 + (new TicketService())->deleteTicket($ticket);
1389 +
347 1390 return [
348 - 'message' => sprintf(__('%d tickets have been closed', 'fluent-support'), count($tickets))
1391 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
349 1392 ];
350 - } else if ($action == 'delete_tickets') {
351 - $tickets = $query->get();
1393 + } catch (\Exception $e) {
1394 + return $this->sendError([
1395 + 'message' => Helper::getSafeErrorMessage($e)
1396 + ]);
1397 + }
1398 + }
352 1399
353 - foreach ($tickets as $ticket) {
354 - $ticket->deleteTicket();
1400 + /**
1401 + * @param Request $request
1402 + * @return array
1403 + * @throws \Exception
1404 + */
1405 + public function doBulkReplies(Request $request)
1406 + {
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 + }
355 1422 }
356 1423
357 - return [
358 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
359 - ];
360 - } else if ($action == 'assign_agent') {
361 - $agentId = absint($request->get('agent_id'));
362 - if (!$agentId) {
363 - $this->sendError([
364 - 'message' => __('agent_id param is required', 'fluent-support')
365 - ]);
366 - }
1424 + $this->validate($data, [
1425 + 'content' => 'required',
1426 + 'ticket_ids' => 'required|array'
1427 + ]);
367 1428
368 - $agent = Agent::findOrFail($agentId);
1429 + $agent = Helper::getAgentByUserId();
1430 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
369 1431
370 - $query->where(function ($q) use ($agent) {
371 - $q->where('agent_id', '!=', $agent->id)
372 - ->orWhereNull('agent_id');
373 - });
1432 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
374 1433
1434 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1435 +
375 1436 $tickets = $query->get();
376 1437
377 - foreach ($tickets as $ticket) {
378 - $ticket->agent_id = $agent->id;
379 - $ticket->save();
380 - do_action('fluent_support/agent_assigned_to_ticket', $agent, $ticket);
1438 + if ($tickets->isEmpty()) {
1439 + throw new \Exception(esc_html__('Sorry no tickets found based on your filter and bulk actions', 'fluent-support'));
381 1440 }
382 1441
383 - return [
384 - 'message' => __(count($tickets) . ' tickets has been assigned to', 'fluent-support') . ' ' . $agent->full_name
1442 + $responseData = [
1443 + 'content' => wp_kses_post(Arr::get($data, 'content', '')),
1444 + 'conversation_type' => 'response',
1445 + 'close_ticket' => Arr::get($data, 'close_ticket'),
385 1446 ];
386 - } else if ($action == 'assign_tags') {
387 1447
388 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
389 - if (!$tags) {
390 - $this->sendError([
391 - 'message' => __('tag_ids param is required', 'fluent-support')
392 - ]);
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();
393 1455 }
394 1456
395 - $tickets = $query->get();
1457 + $responseService = new ResponseService();
396 1458
397 1459 foreach ($tickets as $ticket) {
398 - $ticket->applyTags($tags);
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);
399 1483 }
400 1484
401 1485 return [
402 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1486 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
403 1487 ];
404 -
1488 + } catch (\Exception $e) {
1489 + return $this->sendError([
1490 + 'message' => Helper::getSafeErrorMessage($e)
1491 + ]);
405 1492 }
406 -
407 - $this->sendError([
408 - 'message' => __('Sorry no action found as available', 'fluent-support')
409 - ]);
410 1493 }
411 1494
412 - public function doBulkReplies(Request $request)
1495 + /**
1496 + * @param int $ticket_id
1497 + * @param int $response_id
1498 + * @return array
1499 + */
1500 + public function deleteResponse($ticket_id, $response_id)
413 1501 {
414 - $data = $request->all();
415 - $this->validate($data, [
416 - 'content' => 'required',
417 - 'ticket_ids' => 'required|array'
418 - ]);
1502 + try {
1503 + $ticket = Ticket::findOrFail($ticket_id);
419 1504
420 - $ticketIds = $request->get('ticket_ids');
421 - $ticketIds = array_filter($ticketIds, 'absint');
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 + }
422 1508
423 - $agent = Helper::getAgentByUserId();
1509 + $this->ensureCanAccessTicket($ticket);
424 1510
425 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
426 1517
427 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1518 + $response = Conversation::where('id', $response_id)
1519 + ->where('ticket_id', $ticket_id)
1520 + ->firstOrFail();
428 1521
429 - if (!$hasAllPermission) {
430 - $query->where('agent_id', $agent->id);
1522 + $response->delete();
1523 + $response->ccinfo()->delete();
1524 +
1525 + return [
1526 + 'message' => __('Selected response has been deleted', 'fluent-support')
1527 + ];
1528 + } catch (\Exception $e) {
1529 + return $this->sendError([
1530 + 'message' => Helper::getSafeErrorMessage($e)
1531 + ]);
431 1532 }
1533 + }
432 1534
433 - $tickets = $query->get();
1535 + /**
1536 + * @param TicketResponseRequest $request
1537 + * @param int $ticket_id
1538 + * @param int $response_id
1539 + * @return array
1540 + * @throws \Exception
1541 + */
1542 + public function updateResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1543 + {
1544 + try {
1545 + $ticket = Ticket::findOrFail($ticket_id);
434 1546
435 - if ($tickets->isEmpty()) {
436 - $this->sendError([
437 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
438 - ]);
439 - }
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 + }
440 1550
441 - $responseData = [
442 - 'content' => $request->get('content'),
443 - 'conversation_type' => $request->get('conversation_type', 'response'),
444 - 'close_ticket' => $request->get('close_ticket', 'no')
445 - ];
1551 + $this->ensureCanAccessTicket($ticket);
446 1552
447 - $attachments = $request->get('attachments', []);
1553 + $response = Conversation::where('id', $response_id)
1554 + ->where('ticket_id', $ticket_id)
1555 + ->with('person')
1556 + ->firstOrFail();
1557 + $agent = Helper::getAgentByUserId();
448 1558
449 - if ($attachments) {
450 - $attachments = Attachment::whereNull('ticket_id')
451 - ->orderBy('id', 'asc')
452 - ->whereIn('file_hash', $attachments)
453 - ->get();
454 - }
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 + }
455 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 + }
456 1572
457 - $responseService = new ResponseService();
1573 + $isDraft = $response->conversation_type == 'draft_response';
1574 + $isAuthor = (int) $response->person_id === (int) $agent->id;
1575 + $canApproveDraft = PermissionManager::currentUserCan('fst_approve_draft_reply');
458 1576
459 - foreach ($tickets as $ticket) {
460 - if ($attachments) {
461 - $responseData['attachments'] = [];
462 - foreach ($attachments as $attachment) {
463 - $attachedFile = $attachment->replicate();
464 - $attachedFile->ticket_id = $ticket->id;
465 - $attachedFile->save();
466 - $responseData['attachments'][] = $attachedFile->file_hash;
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 + );
467 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 + );
468 1587 }
469 1588
470 - $responseService->createResponse($responseData, $agent, $ticket);
471 - }
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;
472 1592
1593 + if ($isDraft && !$isAuthor && $canApproveDraft) {
1594 + $response = $this->approveDraftConversation($ticket, $response, $agent, $content);
1595 + } else {
1596 + $response->save();
1597 + }
473 1598
474 - return [
475 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
476 - ];
477 -
1599 + return [
1600 + 'message' => __('Selected response has been updated', 'fluent-support'),
1601 + 'response' => $response
1602 + ];
1603 + } catch (\Exception $e) {
1604 + return $this->sendError([
1605 + 'message' => Helper::getSafeErrorMessage($e)
1606 + ]);
1607 + }
478 1608 }
479 1609
480 - public function deleteResponse(Request $request, $ticketId, $responseId)
1610 + public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
481 1611 {
482 - $ticket = Ticket::findOrFail($ticketId);
483 - $response = Conversation::findOrFail($responseId);
484 - $agent = Helper::getAgentByUserId();
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 + }
485 1618
486 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1619 + $ticket = Ticket::findOrFail($ticket_id);
487 1620
488 - if (!$hasAllPermission) {
489 - if ($ticket->agent_id != $agent->id) {
490 - return $this->sendError([
491 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
492 - ]);
493 - }
494 - }
1621 + $this->ensureCanAccessTicket($ticket);
495 1622
496 - Conversation::where('id', $response->id)->delete();
1623 + $response = Conversation::where('id', $response_id)
1624 + ->where('ticket_id', $ticket_id)
1625 + ->where('conversation_type', 'draft_response')
1626 + ->firstOrFail();
497 1627
498 - return [
499 - 'message' => __('Selected response has been deleted', 'fluent-support')
500 - ];
1628 + $person = Helper::getAgentByUserId();
501 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 + ];
1641 + } catch (\Exception $e) {
1642 + return $this->sendError([
1643 + 'message' => Helper::getSafeErrorMessage($e)
1644 + ]);
1645 + }
502 1646 }
503 1647
504 - public function updateResponse(Request $request, $ticketId, $responseId)
1648 + protected function approveDraftConversation($ticket, $response, $person, $content)
505 1649 {
506 - $data = $request->all();
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1651 + }
507 1652
508 - $this->validate($data, [
509 - 'content' => 'required'
510 - ]);
1653 + /**
1654 + * Agents currently viewing the ticket.
1655 + *
1656 + * @param Request $request
1657 + * @param int $ticket_id
1658 + * @return array
1659 + */
1660 + public function getLiveActivity(Request $request, $ticket_id)
1661 + {
1662 + try {
1663 + $ticket = Ticket::findOrFail($ticket_id);
511 1664
512 - $ticket = Ticket::findOrFail($ticketId);
513 - $response = Conversation::findOrFail($responseId);
514 - $agent = Helper::getAgentByUserId();
1665 + $this->ensureCanAccessTicket($ticket);
515 1666
516 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1667 + $agent = Helper::getAgentByUserId();
517 1668
518 - if (!$hasAllPermission) {
519 - if ($ticket->agent_id != $agent->id) {
520 - return $this->sendError([
521 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
522 - ]);
523 - }
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 + ]);
524 1676 }
1677 + }
525 1678
526 - $response->content = wp_unslash(wp_kses_post($data['content']));
527 - $response->save();
1679 + /**
1680 + * Remove the current agent from the ticket's live activity.
1681 + *
1682 + * @param Request $request
1683 + * @param int $ticket_id
1684 + * @return array
1685 + */
1686 + public function removeLiveActivity(Request $request, $ticket_id)
1687 + {
1688 + try {
1689 + $ticket = Ticket::findOrFail($ticket_id);
528 1690
529 - return [
530 - 'message' => __('Selected response has been updated', 'fluent-support'),
531 - 'response' => $response
532 - ];
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 + }
533 1704 }
534 1705
535 - public function getLiveActivity(Request $request, $ticketId)
1706 + /**
1707 + * @param Request $request
1708 + * @param int $ticket_id
1709 + * @return array
1710 + */
1711 + public function addTag(Request $request, $ticket_id)
536 1712 {
537 - $agent = Helper::getAgentByUserId();
1713 + try {
1714 + $ticket = Ticket::findOrFail($ticket_id);
538 1715
539 - return [
540 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
541 - ];
1716 + $this->ensureCanAccessTicket($ticket);
1717 +
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 + }
542 1729 }
543 1730
544 - public function removeLiveActivity(Request $request, $ticketId)
1731 + /**
1732 + * Remove one tag from the ticket.
1733 + *
1734 + * @param int $ticket_id
1735 + * @param int $tag_id
1736 + * @return array
1737 + */
1738 + public function detachTag($ticket_id, $tag_id)
545 1739 {
546 - $agent = Helper::getAgentByUserId();
1740 + try {
1741 + $ticket = Ticket::findOrFail($ticket_id);
547 1742
548 - return [
549 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
550 - 'agent_id' => $agent->id
551 - ];
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 + }
552 1756 }
553 1757
554 - public function addTag(Request $request, $ticketId)
1758 + /**
1759 + * @param Request $request
1760 + * @param int $ticket_id
1761 + * @return array
1762 + */
1763 + public function changeTicketCustomer(Request $request, $ticket_id)
555 1764 {
556 - $ticket = Ticket::findOrFail($ticketId);
1765 + $ticketId = (int) $ticket_id;
1766 + $newCustomerId = $request->getSafe('customer', 'intval');
557 1767
558 - $tagId = intval($request->get('tag_id'));
1768 + if (!$newCustomerId) {
1769 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1770 + }
559 1771
560 - if (!$ticket->hasTag($tagId)) {
561 - $ticket->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
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'));
562 1776 }
563 1777
564 - return [
565 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
566 - 'tags' => $ticket->tags
567 - ];
568 - }
1778 + try {
1779 + $ticket = Ticket::findOrFail($ticketId);
569 1780
570 - public function detachTag($ticketId, $tagId)
571 - {
572 - $ticket = Ticket::findOrFail($ticketId);
573 - $ticket->tags()->detach($tagId);
1781 + $this->ensureCanAccessTicket($ticket);
574 1782
575 - return [
576 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
577 - 'tags' => $ticket->tags
578 - ];
579 - }
1783 + $targetCustomer = Customer::where('id', $newCustomerId)
1784 + ->where('person_type', 'customer')
1785 + ->first();
580 1786
581 - public function changeTicketCustomer(Request $request)
582 - {
583 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
584 - ->update(['customer_id' => $request->get('customer')]);
585 - return [
586 - 'message' => __('Customer has been updated', 'fluent-support'),
587 - 'updatedCustomer' => $updateCustomer
588 - ];
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 +
1800 + } catch (\Exception $e) {
1801 + return $this->sendError([
1802 + 'message' => Helper::getSafeErrorMessage($e)
1803 + ]);
1804 + }
589 1805 }
590 1806
591 - public function getTicketCustomData(Request $request, $ticketId)
1807 + /**
1808 + * @param Request $request
1809 + * @param int $ticket_id
1810 + * @return array|array[]
1811 + */
1812 + public function getTicketCustomData(Request $request, $ticket_id)
592 1813 {
593 1814 if (!defined('FLUENTSUPPORTPRO')) {
594 1815 return [
595 1816 'custom_data' => [],
@@ -596,65 +1817,130 @@
596 1817 'rendered_fields' => []
597 1818 ];
598 1819 }
599 1820
600 - $ticket = Ticket::findOrFail($ticketId);
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
601 1823
602 - return [
603 - 'custom_data' => (object)$ticket->customData(),
604 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
605 - ];
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 + }
606 1835 }
607 1836
608 - public function syncFluentCrmTags(Request $request)
1837 + /**
1838 + * @param Request $request
1839 + * @param FluentCRMServices $fluentCRMServices
1840 + * @return array
1841 + */
1842 + public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
609 1843 {
1844 + $data = [
1845 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1846 + 'tags' => $request->get('tags', null)
1847 + ];
610 1848
611 - if (!defined('FLUENTCRM')) {
1849 + if (is_array($data['tags'])) {
1850 + $data['tags'] = array_map('intval', $data['tags']);
1851 + }
1852 +
1853 + try {
1854 + return $fluentCRMServices->syncCrmTags($data);
1855 + } catch (\Exception $e) {
612 1856 return $this->sendError([
613 - 'message' => 'FluentCRM is not installed'
1857 + 'message' => Helper::getSafeErrorMessage($e)
614 1858 ]);
615 1859 }
1860 + }
616 1861
617 - $contactId = absint($request->get('contact_id'));
1862 + /**
1863 + * @param Request $request
1864 + * @param FluentCRMServices $fluentCRMServices
1865 + * @return array
1866 + */
618 1867
619 - if (!$contactId) {
1868 + public function syncFluentCrmLists(Request $request, FluentCRMServices $fluentCRMServices)
1869 + {
1870 + $data = [
1871 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1872 + 'lists' => $request->get('lists', null, [])
1873 + ];
1874 +
1875 + if (is_array($data['lists'])) {
1876 + $data['lists'] = array_map('intval', $data['lists']);
1877 + }
1878 +
1879 + try {
1880 + return $fluentCRMServices->syncCrmLists($data);
1881 + } catch (\Exception $e) {
620 1882 return $this->sendError([
621 - 'message' => 'Contact could not be found'
1883 + 'message' => Helper::getSafeErrorMessage($e)
622 1884 ]);
623 1885 }
1886 + }
624 1887
625 - $tagIds = array_filter($request->get('tags', []), 'absint');
626 - $canAddTags = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contacts');
627 - $canAddTags = apply_filters('fluent_support/can_user_add_tags_to_customer', $canAddTags);
1888 + /**
1889 + * @param Request $request
1890 + * @return array
1891 + */
1892 + public function getTicketEssentials(Request $request)
1893 + {
1894 + $type = $request->getSafe('type', 'sanitize_text_field');
628 1895
629 - if (!$canAddTags) {
1896 + return TicketHelper::getTicketEssentials($type);
1897 + }
1898 +
1899 + public function fetchLabelSearch()
1900 + {
1901 + try {
1902 + $agent_id = get_current_user_id();
1903 + return TicketHelper::getLabelSearch($agent_id);
1904 + } catch (\Exception $e) {
630 1905 return $this->sendError([
631 - 'message' => 'Sorry you do not have permission to add contact tags'
1906 + 'message' => Helper::getSafeErrorMessage($e)
632 1907 ]);
633 1908 }
1909 + }
634 1910
635 - $contact = \FluentCrm\App\Models\Subscriber::findOrFail($contactId);
1911 + public function storeOrUpdateLabelSearch(Request $request)
1912 + {
1913 + try {
1914 + $agent_id = get_current_user_id();
1915 + $searchData = $request->get('query', null, []);
1916 + if (is_array($searchData)) {
1917 + $searchData = map_deep($searchData, 'sanitize_text_field');
1918 + }
1919 + $filterType = Arr::get($searchData, 'filter_type', '');
1920 + if ($filterType == 'advanced') {
1921 + return TicketHelper::saveSearchLabel($agent_id, $searchData, $filterType);
1922 + }
636 1923
637 - $existingTags = $contact->tags;
638 - $existingTagIds = [];
639 - foreach ($existingTags as $tag) {
640 - $existingTagIds[] = $tag->id;
641 - }
642 - $newTagIds = array_diff($tagIds, $existingTagIds);
643 - $removedTagIds = array_diff($existingTagIds, $tagIds);
1924 + return [
1925 + 'message' => __('Invalid filter type.', 'fluent-support'),
1926 + ];
644 1927
645 - if ($newTagIds) {
646 - $contact->attachTags($newTagIds);
1928 + } catch (\Exception $e) {
1929 + return $this->sendError([
1930 + 'message' => Helper::getSafeErrorMessage($e)
1931 + ]);
647 1932 }
1933 + }
648 1934
649 - if ($removedTagIds) {
650 - $contact->detachTags($removedTagIds);
1935 + public function deleteLabelSearch(Request $request, $search_id)
1936 + {
1937 + try {
1938 + $agent_id = get_current_user_id();
1939 + return TicketHelper::deleteSavedSearch($search_id);
1940 + } catch (\Exception $e) {
1941 + return $this->sendError([
1942 + 'message' => Helper::getSafeErrorMessage($e)
1943 + ]);
651 1944 }
652 -
653 -
654 - return [
655 - 'tags' => $contact->tags,
656 - 'message' => 'FluentCRM contact tags has been updated'
657 - ];
658 -
659 1945 }
660 1946 }