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 +1693 -358 1.4.0 → 2.4.5 View file →
@@ -3,609 +3,1944 @@
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;
26 +use FluentSupport\App\Services\Integrations\FluentBooking\FluentBookingService;
18 27
28 +/**
29 + * Agent-side REST endpoints for tickets.
30 + */
19 31 class TicketController extends Controller
20 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 + */
21 45 public function me(Request $request)
22 46 {
23 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 + }
24 57
25 - return [
26 - 'user_id' => $user->id,
58 + $settings = [
59 + 'user_id' => $user->ID,
27 60 'email' => $user->user_email,
28 61 'person' => Helper::getAgentByUserId($user->ID),
29 62 'permissions' => PermissionManager::currentUserPermissions(),
30 - 'request' => $request->all()
63 + 'request' => $sanitizedRequest
31 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;
32 85 }
33 86
87 + /**
88 + * @param Request $request
89 + * @return array
90 + */
34 91 public function index(Request $request)
35 92 {
36 - $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([
37 117 'customer' => function ($query) {
38 - $query->select(['first_name', 'last_name', 'email', 'id']);
118 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
39 119 }, 'agent' => function ($query) {
40 - $query->select(['first_name', 'last_name', 'id']);
120 + $query->select(['first_name', 'last_name', 'email', 'avatar', 'id']);
41 121 },
122 + 'mailbox',
42 123 'product',
43 124 'tags',
44 125 'preview_response' => function ($query) {
45 - $query->orderBy('id', 'desc');
126 + $query->latest('id');
46 127 }
47 128 ]);
48 129
49 - // apply filters by access level
50 - $permissionLevel = PermissionManager::currentUserTicketsPermissionLevel();
51 - if ($permissionLevel != 'all') {
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 + ];
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 + ]);
216 + }
217 + }
218 +
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 {
52 227 $agent = Helper::getAgentByUserId();
53 - if ($permissionLevel == 'own') {
54 - $ticketsQuery->where('agent_id', $agent->id);
55 - } else {
56 - $ticketsQuery->where(function ($q) use ($agent) {
57 - $q->where('agent_id', $agent->id);
58 - $q->orWhereNull('agent_id');
59 - });
228 +
229 + $ticketWith = $request->get('with');
230 + $ticketWith = is_array($ticketWith) ? map_deep($ticketWith, 'sanitize_text_field') : null;
231 +
232 + if (!$ticketWith) {
233 + $ticketWith = ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
234 + $q->where('status', 'active');
235 + }];
60 236 }
61 - }
62 237
63 - if ($customerId = $request->get('customer_id')) {
64 - $ticketsQuery = $ticketsQuery->where('customer_id', $customerId);
65 - }
238 + $ticket = Ticket::with($ticketWith)->findOrFail($ticket_id);
66 239
67 - if ($filters = $request->get('filters', [])) {
68 - $ticketsQuery->applyFilters($filters);
69 - }
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 + }]);
70 249
71 - if ($search = $request->get('search')) {
72 - $ticketsQuery->searchBy($search);
73 - }
250 + $restrictedBusinessBoxes = PermissionManager::getRestrictedMailboxIds();
74 251
75 - $ticketsQuery->orderBy($request->get('order_by', 'id'), $request->get('order_type', 'ASC'));
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 + }
76 255
77 - $tickets = $ticketsQuery->paginate();
256 + $this->ensureCanAccessTicket($ticket);
78 257
79 - $perPage = $request->get('per_page');
258 + if ($ticket->customer) {
259 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
260 + $ticket->customer->custom_field_keys = $customFieldsKey;
80 261
81 - foreach ($tickets as $ticket) {
82 - if ($perPage < 15) {
83 - if ($ticket->status != 'closed') {
84 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
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);
85 319 } else {
86 - $ticket->live_activity = [];
320 + $responseContent = links_add_target(make_clickable(wpautop($responseContent, false)));
87 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 + }
88 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 + ]);
89 401 }
90 -
91 - return [
92 - 'tickets' => $tickets
93 - ];
94 402 }
95 403
96 - public function createTicket(Request $request)
404 + public function getMentionableAgents(Request $request, $ticket_id)
97 405 {
98 - $ticketData = $request->get('ticket', []);
99 - $this->validate($ticketData, [
100 - 'customer_id' => 'required',
101 - 'title' => 'required',
102 - 'content' => 'required'
103 - ]);
406 + try {
407 + $ticket = Ticket::findOrFail($ticket_id);
104 408
105 - $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 + }
106 412
107 - if (empty($ticketData['mailbox_id'])) {
108 - $mailbox = Helper::getDefaultMailBox();
109 - $ticketData['mailbox_id'] = $mailbox->id;
110 - } else {
111 - $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 + ]);
112 425 }
426 + }
113 427
114 - if (!empty($ticketData['product_id'])) {
115 - $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 [];
116 438 }
117 439
118 - $ticketData['title'] = sanitize_text_field(wp_unslash($ticketData['title']));
440 + $restrictions = $this->getAgentRestrictionsMap($allAgents->pluck('id')->all());
441 + $ticketAccess = new AgentTicketAccess();
442 + $results = [];
119 443
120 - $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 + }
121 448
122 - if (!empty($ticketData['priority'])) {
123 - $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 + }
124 459 }
125 460
126 - $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']);
461 + return $results;
462 + }
127 463
128 - $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
129 - do_action('fluent_support/before_ticket_create', $ticketData, $customer);
464 + protected function getAgentRestrictionsMap(array $agentIds)
465 + {
466 + if (!$agentIds) {
467 + return [];
468 + }
130 469
131 - $createdTicket = Ticket::create($ticketData);
470 + $metas = Meta::where('object_type', 'person_meta')
471 + ->where('key', 'agent_restrictions')
472 + ->whereIn('object_id', $agentIds)
473 + ->get();
132 474
133 - if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
134 - $createdTicket->syncCustomFields($ticketData['custom_fields']);
135 - $createdTicket->custom_fields = $createdTicket->customData();
475 + $restrictions = [];
476 + foreach ($metas as $meta) {
477 + $restrictions[$meta->object_id] = Helper::safeUnserialize($meta->value) ?: [];
136 478 }
137 479
138 - do_action('fluent_support/ticket_created', $createdTicket, $customer);
480 + return $restrictions;
481 + }
139 482
140 - return [
141 - 'message' => __('Ticket has been created successfully', 'fluent-support'),
142 - 'ticket' => $createdTicket
143 - ];
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();
144 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 + }
145 547 }
146 548
147 - public function getTicket(Request $request, $ticketId)
549 + public function getFluentBookingEventTypes()
148 550 {
149 - $agent = Helper::getAgentByUserId();
150 - $ticketWith = $request->get('with', ['customer', 'agent', 'product', 'mailbox', 'tags', 'attachments' => function ($q) {
151 - $q->where('status', 'active');
152 - }]);
153 - $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();
154 554
155 - $ticket = Ticket::with($ticketWith)
156 - ->findOrFail($ticketId);
555 + $service = new FluentBookingService();
556 + $eventTypes = $service->getEventTypes();
157 557
158 - if ($ticket->customer) {
159 - $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 + ]);
160 566 }
567 + }
161 568
162 - 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) {
163 592 return $this->sendError([
164 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
593 + 'message' => Helper::getSafeErrorMessage($e)
165 594 ]);
166 595 }
596 + }
167 597
598 + public function getFluentBookingAvailability(Request $request, $ticket_id)
599 + {
600 + try {
601 + $this->ensureCanManageTickets();
168 602
169 - if ($ticket->status == 'closed') {
170 - $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 + ]);
171 628 }
629 + }
172 630
173 - $responses = Conversation::where('ticket_id', $ticketId)
174 - ->with($responseWith)
175 - ->orderBy('id', 'DESC')
176 - ->get();
631 + public function getFluentBookingMeetings($ticket_id)
632 + {
633 + try {
634 + $this->ensureCanManageTickets();
177 635
178 - foreach ($responses as $response) {
179 - $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 + ]);
180 647 }
648 + }
181 649
182 - $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();
183 659
184 - $ticket->live_activity = TicketHelper::getActivity($ticketId, $agent->id);
660 + try {
661 + $agent = Helper::getAgentByUserId();
185 662
186 - if (defined('FLUENTSUPPORTPRO')) {
187 - $ticket->custom_fields = $ticket->customData('admin', true);
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 + ];
698 + } catch (\Exception $e) {
699 + return $this->sendError([
700 + 'message' => Helper::getSafeErrorMessage($e)
701 + ]);
188 702 }
189 -
190 - return [
191 - 'ticket' => $ticket,
192 - 'responses' => $responses,
193 - 'agent_id' => $agent->id
194 - ];
195 703 }
196 704
197 - public function createResponse(Request $request, $ticketId)
705 + public function getDraft($ticket_id)
198 706 {
199 - $data = $request->all();
707 + try {
708 + $agent = Helper::getAgentByUserId();
200 709
201 - $this->validate($data, [
202 - 'content' => 'required'
203 - ]);
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 + }
204 715
205 - $agent = Helper::getAgentByUserId(get_current_user_id());
716 + $ticket = Ticket::findOrFail($ticket_id);
206 717
207 - 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) {
208 735 return $this->sendError([
209 - 'message' => __('Sorry, You do not have permission. Please add yourself as support agent first', 'fluent-support')
736 + 'message' => Helper::getSafeErrorMessage($e)
210 737 ]);
211 738 }
739 + }
212 740
213 - $ticket = Ticket::findOrFail($ticketId);
741 + public function deleteDraft($draft_id)
742 + {
743 + $draft_id = intval($draft_id);
214 744
215 - 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) {
216 784 return $this->sendError([
217 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
785 + 'message' => Helper::getSafeErrorMessage($e)
218 786 ]);
219 787 }
788 + }
220 789
221 - $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);
222 801
223 - $responseData['response']->content = make_clickable(wpautop($responseData['response']->content, false));
802 + $this->ensureCanAccessTicket($ticket);
224 803
225 - return [
226 - 'message' => __('Response has been added'),
227 - 'response' => $responseData['response'],
228 - 'ticket' => $responseData['ticket'],
229 - 'update_data' => $responseData['update_data']
230 - ];
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 + }
231 838 }
232 839
233 - 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)
234 846 {
235 - $ticket = Ticket::with('customer')->findOrFail($ticketId);
847 + try {
848 + $assigner = Helper::getAgentByUserId();
849 + $ticket = Ticket::findOrFail($ticket_id);
236 850
237 - 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) {
238 917 return $this->sendError([
239 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
918 + 'message' => Helper::getSafeErrorMessage($e)
240 919 ]);
241 920 }
921 + }
242 922
243 - $otherTickets = Ticket::where('id', '!=', $ticketId)
244 - ->select(['id', 'title', 'status', 'created_at'])
245 - ->where('customer_id', $ticket->customer_id)
246 - ->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')
247 948 ->limit(10)
248 949 ->get();
249 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 + {
250 979 return [
251 - 'other_tickets' => $otherTickets,
252 - 'extra_widgets' => ProfileInfoService::getProfileExtraWidgets($ticket->customer)
980 + 'agent_id',
981 + 'title',
982 + 'product_id',
983 + 'status',
984 + 'priority',
985 + 'client_priority',
253 986 ];
254 987 }
255 988
256 - 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)
257 1000 {
258 - $assigner = Helper::getAgentByUserId(get_current_user_id());
259 - $ticket = Ticket::findOrFail($ticketId);
260 - $propName = $request->get('prop_name');
261 - $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;
262 1008
263 - if (!PermissionManager::hasTicketPermission($ticket)) {
264 - return $this->sendError([
265 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
266 - ]);
267 - }
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 + }
268 1018
269 - $prevValue = $ticket->{$propName};
270 - if ($propName && $propValue && $prevValue != $propValue) {
271 - $ticket->{$propName} = $propValue;
272 - $ticket->save();
273 - }
1019 + if (!in_array($propValue, $allowedStatuses, true)) {
1020 + throw new \Exception(esc_html__('Invalid ticket status.', 'fluent-support'), 422);
1021 + }
274 1022
275 - $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 + }
276 1028
277 - if ($propName == 'product_id') {
278 - $ticket->load('product');
279 - $updateData['product'] = $ticket->product;
280 - } else if ($propName == 'agent_id') {
281 - $ticket->load('agent');
282 - $updateData['agent'] = $ticket->agent;
283 - $updateData['assigner'] = (new TicketService())->onAgentChange($ticket, $assigner);
284 - if ($prevValue != $ticket->{$propName}) {
285 - do_action('fluent_support/agent_assigned_to_ticket', $ticket->agent, $ticket);
286 - }
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;
287 1063 }
288 1064
289 - return [
290 - 'message' => __(str_replace('_', ' ', ucwords($propName)) . ' has been updated', 'fluent-support'),
291 - 'update_data' => $updateData
292 - ];
1065 + // Unreachable after the allowlist check. Fail closed anyway.
1066 + throw new \Exception(esc_html__('This ticket property cannot be updated.', 'fluent-support'), 403);
293 1067 }
294 1068
295 - 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)
296 1075 {
297 - $agent = Helper::getAgentByUserId(get_current_user_id());
1076 + try {
1077 + $agent = Helper::getAgentByUserId();
1078 + $ticket = Ticket::findOrFail($ticket_id);
298 1079
299 - $ticket = Ticket::findOrFail($ticketId);
1080 + $this->ensureCanAccessTicket($ticket);
300 1081
301 - 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) {
302 1089 return $this->sendError([
303 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1090 + 'message' => Helper::getSafeErrorMessage($e)
304 1091 ]);
305 1092 }
306 -
307 - return [
308 - 'message' => __('Ticket has been closed', 'fluent_support'),
309 - 'ticket' => (new TicketService())->close($ticket, $agent)
310 - ];
311 1093 }
312 1094
313 - public function reOpenTicket(Request $request, $ticketId)
1095 + /**
1096 + * @param int $ticket_id
1097 + * @return array
1098 + */
1099 + public function reOpenTicket($ticket_id)
314 1100 {
315 - $agent = Helper::getAgentByUserId(get_current_user_id());
1101 + try {
1102 + $agent = Helper::getAgentByUserId();
1103 + $ticket = Ticket::findOrFail($ticket_id);
316 1104
317 - $ticket = Ticket::findOrFail($ticketId);
1105 + $this->ensureCanAccessTicket($ticket);
318 1106
319 - 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) {
320 1112 return $this->sendError([
321 - 'message' => __('Sorry, You do not have permission to this ticket', 'fluent-support')
1113 + 'message' => Helper::getSafeErrorMessage($e)
322 1114 ]);
323 1115 }
324 -
325 - return [
326 - 'message' => __('Ticket has been opened again', 'fluent_support'),
327 - 'ticket' => (new TicketService())->reopen($ticket, $agent)
328 - ];
329 1116 }
330 1117
1118 + /**
1119 + * @param Request $request
1120 + * @return array|string[]|void
1121 + * @throws \Exception
1122 + */
331 1123 public function doBulkActions(Request $request)
332 1124 {
333 - $ticketIds = $request->get('ticket_ids', []);
334 - $action = $request->get('bulk_action');
335 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
336 - $agent = Helper::getAgentByUserId();
337 - $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, []));
338 1128
339 - if (!$hasAllPermission) {
340 - $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 + ]);
341 1348 }
1349 + }
342 1350
343 - if ($action == 'close_tickets') {
344 - $query->where('status', '!=', 'closed');
345 - $tickets = $query->get();
346 - foreach ($tickets as $ticket) {
347 - (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;
348 1368 }
1369 + $ticket->{$column} = $value;
1370 + $ticket->save();
1371 + $changed++;
1372 + }
349 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 +
350 1390 return [
351 - 'message' => sprintf( __('%d tickets have been closed', 'fluent-support'), count($tickets))
1391 + 'message' => __('Ticket has been deleted successfully', 'fluent-support')
352 1392 ];
353 - } else if ($action == 'delete_tickets') {
354 - $tickets = $query->get();
1393 + } catch (\Exception $e) {
1394 + return $this->sendError([
1395 + 'message' => Helper::getSafeErrorMessage($e)
1396 + ]);
1397 + }
1398 + }
355 1399
356 - foreach ($tickets as $ticket) {
357 - $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 + }
358 1422 }
359 1423
360 - return [
361 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
362 - ];
363 - } else if ($action == 'assign_agent') {
364 - $agentId = absint($request->get('agent_id'));
365 - if (!$agentId) {
366 - $this->sendError([
367 - 'message' => __('agent_id param is required', 'fluent-support')
368 - ]);
369 - }
1424 + $this->validate($data, [
1425 + 'content' => 'required',
1426 + 'ticket_ids' => 'required|array'
1427 + ]);
370 1428
371 - $agent = Agent::findOrFail($agentId);
1429 + $agent = Helper::getAgentByUserId();
1430 + $ticketIds = array_filter($data['ticket_ids'], 'absint');
372 1431
373 - $query->where(function ($q) use ($agent) {
374 - $q->where('agent_id', '!=', $agent->id)
375 - ->orWhereNull('agent_id');
376 - });
1432 + $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
377 1433
1434 + (new AgentTicketAccess())->applyAccessScope($query, $agent);
1435 +
378 1436 $tickets = $query->get();
379 1437
380 - foreach ($tickets as $ticket) {
381 - $ticket->agent_id = $agent->id;
382 - $ticket->save();
383 - 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'));
384 1440 }
385 1441
386 - return [
387 - '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'),
388 1446 ];
389 - } else if ($action == 'assign_tags') {
390 1447
391 - $tags = array_filter(array_map('absint', $request->get('tag_ids', [])));
392 - if (!$tags) {
393 - $this->sendError([
394 - 'message' => __('tag_ids param is required', 'fluent-support')
395 - ]);
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();
396 1455 }
397 1456
398 - $tickets = $query->get();
1457 + $responseService = new ResponseService();
399 1458
400 1459 foreach ($tickets as $ticket) {
401 - $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);
402 1483 }
403 1484
404 1485 return [
405 - 'message' => __('Selected tags has been added to tickets', 'fluent-support')
1486 + 'message' => __('Response has been added to the selected tickets', 'fluent-support')
406 1487 ];
407 -
1488 + } catch (\Exception $e) {
1489 + return $this->sendError([
1490 + 'message' => Helper::getSafeErrorMessage($e)
1491 + ]);
408 1492 }
409 -
410 - $this->sendError([
411 - 'message' => __('Sorry no action found as available', 'fluent-support')
412 - ]);
413 1493 }
414 1494
415 - 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)
416 1501 {
417 - $data = $request->all();
418 - $this->validate($data, [
419 - 'content' => 'required',
420 - 'ticket_ids' => 'required|array'
421 - ]);
1502 + try {
1503 + $ticket = Ticket::findOrFail($ticket_id);
422 1504
423 - $ticketIds = $request->get('ticket_ids');
424 - $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 + }
425 1508
426 - $agent = Helper::getAgentByUserId();
1509 + $this->ensureCanAccessTicket($ticket);
427 1510
428 - $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 + }
429 1517
430 - $query = Ticket::whereIn('id', $ticketIds)->where('status', '!=', 'closed');
1518 + $response = Conversation::where('id', $response_id)
1519 + ->where('ticket_id', $ticket_id)
1520 + ->firstOrFail();
431 1521
432 - if (!$hasAllPermission) {
433 - $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 + ]);
434 1532 }
1533 + }
435 1534
436 - $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);
437 1546
438 - if ($tickets->isEmpty()) {
439 - $this->sendError([
440 - 'message' => __('Sorry no tickets found based on your filter and bulk actions', 'fluent-support')
441 - ]);
442 - }
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 + }
443 1550
444 - $responseData = [
445 - 'content' => $request->get('content'),
446 - 'conversation_type' => $request->get('conversation_type', 'response'),
447 - 'close_ticket' => $request->get('close_ticket', 'no')
448 - ];
1551 + $this->ensureCanAccessTicket($ticket);
449 1552
450 - $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();
451 1558
452 - if ($attachments) {
453 - $attachments = Attachment::whereNull('ticket_id')
454 - ->orderBy('id', 'asc')
455 - ->whereIn('file_hash', $attachments)
456 - ->get();
457 - }
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 + }
458 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 + }
459 1572
460 - $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');
461 1576
462 - foreach ($tickets as $ticket) {
463 - if ($attachments) {
464 - $responseData['attachments'] = [];
465 - foreach ($attachments as $attachment) {
466 - $attachedFile = $attachment->replicate();
467 - $attachedFile->ticket_id = $ticket->id;
468 - $attachedFile->save();
469 - $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 + );
470 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 + );
471 1587 }
472 1588
473 - $responseService->createResponse($responseData, $agent, $ticket);
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 + ];
1603 + } catch (\Exception $e) {
1604 + return $this->sendError([
1605 + 'message' => Helper::getSafeErrorMessage($e)
1606 + ]);
474 1607 }
1608 + }
475 1609
1610 + public function approveDraftResponse(TicketResponseRequest $request, $ticket_id, $response_id)
1611 + {
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 + }
476 1618
477 - return [
478 - 'message' => __('Response has been added to the selected tickets', 'fluent-support')
479 - ];
1619 + $ticket = Ticket::findOrFail($ticket_id);
480 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 + ];
1641 + } catch (\Exception $e) {
1642 + return $this->sendError([
1643 + 'message' => Helper::getSafeErrorMessage($e)
1644 + ]);
1645 + }
481 1646 }
482 1647
1648 + protected function approveDraftConversation($ticket, $response, $person, $content)
1649 + {
1650 + return (new ResponseService())->publishDraft($ticket, $response, $person, $content);
1651 + }
483 1652
484 - public function deleteResponse(Request $request, $ticketId, $responseId)
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)
485 1661 {
486 - $ticket = Ticket::findOrFail($ticketId);
487 - $response = Conversation::findOrFail($responseId);
488 - $agent = Helper::getAgentByUserId();
1662 + try {
1663 + $ticket = Ticket::findOrFail($ticket_id);
489 1664
490 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
1665 + $this->ensureCanAccessTicket($ticket);
491 1666
492 - if (!$hasAllPermission) {
493 - if ($ticket->agent_id != $agent->id) {
494 - return $this->sendError([
495 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
496 - ]);
497 - }
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 + ]);
498 1676 }
1677 + }
499 1678
500 - Conversation::where('id', $response->id)->delete();
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);
501 1690
502 - return [
503 - 'message' => __('Selected response has been deleted', 'fluent-support')
504 - ];
1691 + $this->ensureCanAccessTicket($ticket);
505 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 + }
506 1704 }
507 1705
508 - public function updateResponse(Request $request, $ticketId, $responseId)
1706 + /**
1707 + * @param Request $request
1708 + * @param int $ticket_id
1709 + * @return array
1710 + */
1711 + public function addTag(Request $request, $ticket_id)
509 1712 {
510 - $data = $request->all();
1713 + try {
1714 + $ticket = Ticket::findOrFail($ticket_id);
511 1715
512 - $this->validate($data, [
513 - 'content' => 'required'
514 - ]);
1716 + $this->ensureCanAccessTicket($ticket);
515 1717
516 - $ticket = Ticket::findOrFail($ticketId);
517 - $response = Conversation::findOrFail($responseId);
518 - $agent = Helper::getAgentByUserId();
1718 + $ticket->applyTags($request->getSafe('tag_id', 'intval'));
519 1719
520 - $hasAllPermission = PermissionManager::currentUserCan('fst_manage_other_tickets');
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 + }
1729 + }
521 1730
522 - if (!$hasAllPermission) {
523 - if ($ticket->agent_id != $agent->id) {
524 - return $this->sendError([
525 - 'message' => __('Sorry, You do not have permission to delete this response', 'fluent-support')
526 - ]);
527 - }
528 - }
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)
1739 + {
1740 + try {
1741 + $ticket = Ticket::findOrFail($ticket_id);
529 1742
530 - $response->content = wp_unslash(wp_kses_post($data['content']));
531 - $response->save();
1743 + $this->ensureCanAccessTicket($ticket);
532 1744
533 - return [
534 - 'message' => __('Selected response has been updated', 'fluent-support'),
535 - 'response' => $response
536 - ];
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 + }
537 1756 }
538 1757
539 - public function getLiveActivity(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)
540 1764 {
541 - $agent = Helper::getAgentByUserId();
1765 + $ticketId = (int) $ticket_id;
1766 + $newCustomerId = $request->getSafe('customer', 'intval');
542 1767
543 - return [
544 - 'live_activity' => TicketHelper::getActivity($ticketId, $agent->id)
545 - ];
1768 + if (!$newCustomerId) {
1769 + return $this->sendError(__('Invalid customer selected.', 'fluent-support'));
1770 + }
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 +
1778 + try {
1779 + $ticket = Ticket::findOrFail($ticketId);
1780 +
1781 + $this->ensureCanAccessTicket($ticket);
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 +
1800 + } catch (\Exception $e) {
1801 + return $this->sendError([
1802 + 'message' => Helper::getSafeErrorMessage($e)
1803 + ]);
1804 + }
546 1805 }
547 1806
548 - public function removeLiveActivity(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)
549 1813 {
550 - $agent = Helper::getAgentByUserId();
1814 + if (!defined('FLUENTSUPPORTPRO')) {
1815 + return [
1816 + 'custom_data' => [],
1817 + 'rendered_fields' => []
1818 + ];
1819 + }
551 1820
552 - return [
553 - 'result' => TicketHelper::removeFromActivities($ticketId, $agent->id),
554 - 'agent_id' => $agent->id
555 - ];
1821 + try {
1822 + $ticket = Ticket::findOrFail($ticket_id);
1823 +
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 + }
556 1835 }
557 1836
558 - public function addTag(Request $request, $ticketId)
1837 + /**
1838 + * @param Request $request
1839 + * @param FluentCRMServices $fluentCRMServices
1840 + * @return array
1841 + */
1842 + public function syncFluentCrmTags(Request $request, FluentCRMServices $fluentCRMServices)
559 1843 {
560 - $ticket = Ticket::findOrFail($ticketId);
1844 + $data = [
1845 + 'contact_id' => $request->getSafe('contact_id', 'intval'),
1846 + 'tags' => $request->get('tags', null)
1847 + ];
561 1848
562 - $tagId = intval($request->get('tag_id'));
1849 + if (is_array($data['tags'])) {
1850 + $data['tags'] = array_map('intval', $data['tags']);
1851 + }
563 1852
564 - if (!$ticket->hasTag($tagId)) {
565 - $ticket->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
1853 + try {
1854 + return $fluentCRMServices->syncCrmTags($data);
1855 + } catch (\Exception $e) {
1856 + return $this->sendError([
1857 + 'message' => Helper::getSafeErrorMessage($e)
1858 + ]);
566 1859 }
1860 + }
567 1861
568 - return [
569 - 'message' => __('Tag has been added to this ticket', 'fluent-support'),
570 - 'tags' => $ticket->tags
1862 + /**
1863 + * @param Request $request
1864 + * @param FluentCRMServices $fluentCRMServices
1865 + * @return array
1866 + */
1867 +
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, [])
571 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) {
1882 + return $this->sendError([
1883 + 'message' => Helper::getSafeErrorMessage($e)
1884 + ]);
1885 + }
572 1886 }
573 1887
574 - public function detachTag($ticketId, $tagId)
1888 + /**
1889 + * @param Request $request
1890 + * @return array
1891 + */
1892 + public function getTicketEssentials(Request $request)
575 1893 {
576 - $ticket = Ticket::findOrFail($ticketId);
577 - $ticket->tags()->detach($tagId);
1894 + $type = $request->getSafe('type', 'sanitize_text_field');
578 1895
579 - return [
580 - 'message' => __('Tag has been removed from this ticket', 'fluent-support'),
581 - 'tags' => $ticket->tags
582 - ];
1896 + return TicketHelper::getTicketEssentials($type);
583 1897 }
584 1898
585 - public function changeTicketCustomer(Request $request)
1899 + public function fetchLabelSearch()
586 1900 {
587 - $updateCustomer = Ticket::where('id', $request->get('ticket_id'))
588 - ->update(['customer_id' => $request->get('customer')]);
589 - return [
590 - 'message' => __('Customer has been updated', 'fluent-support'),
591 - 'updatedCustomer' => $updateCustomer
592 - ];
1901 + try {
1902 + $agent_id = get_current_user_id();
1903 + return TicketHelper::getLabelSearch($agent_id);
1904 + } catch (\Exception $e) {
1905 + return $this->sendError([
1906 + 'message' => Helper::getSafeErrorMessage($e)
1907 + ]);
1908 + }
593 1909 }
594 1910
595 - public function getTicketCustomData(Request $request, $ticketId)
1911 + public function storeOrUpdateLabelSearch(Request $request)
596 1912 {
597 - if (!defined('FLUENTSUPPORTPRO')) {
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 + }
1923 +
598 1924 return [
599 - 'custom_data' => [],
600 - 'rendered_fields' => []
1925 + 'message' => __('Invalid filter type.', 'fluent-support'),
601 1926 ];
1927 +
1928 + } catch (\Exception $e) {
1929 + return $this->sendError([
1930 + 'message' => Helper::getSafeErrorMessage($e)
1931 + ]);
602 1932 }
1933 + }
603 1934
604 - $ticket = Ticket::findOrFail($ticketId);
605 -
606 - return [
607 - 'custom_data' => (object) $ticket->customData(),
608 - 'rendered_fields' => \FluentSupportPro\App\Services\CustomFieldsService::getRenderedPublicFields($ticket->customer)
609 - ];
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 + ]);
1944 + }
610 1945 }
611 1946 }