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