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