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