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/Services/CustomerPortalService.php +346 -93 1.10.4 → 2.4.5 View file →
@@ -2,12 +2,14 @@
2 2
3 3 namespace FluentSupport\App\Services;
4 4
5 5 use Exception;
6 +use FluentSupport\App\Http\Policies\PortalPolicy;
6 7 use FluentSupport\App\Models\MailBox;
7 8 use FluentSupport\App\Models\Meta;
8 9 use FluentSupport\App\Models\Ticket;
9 10 use FluentSupport\App\Models\Customer;
11 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
10 12 use FluentSupport\App\Services\Tickets\ResponseService;
11 13 use FluentSupport\App\Services\Tickets\TicketService;
12 14 use FluentSupport\Framework\Support\Arr;
13 15 use FluentSupport\App\Models\Attachment;
@@ -15,9 +17,35 @@
15 17
16 18 class CustomerPortalService
17 19 {
18 20 /**
19 - * This `getTickets` method is responsible for getting tickets for customer
21 + * Ticket columns a customer never sees: the agent-set priority and the
22 + * email threading / dedupe internals.
23 + */
24 + const PRIVATE_TICKET_FIELDS = ['priority', 'message_id', 'content_hash', 'secret_content'];
25 +
26 + /**
27 + * Hide PRIVATE_TICKET_FIELDS on a ticket, or on each ticket of a list or page.
28 + *
29 + * @param mixed $tickets
30 + * @return mixed
31 + */
32 + public static function hidePrivateTicketFields($tickets)
33 + {
34 + if ($tickets instanceof Ticket) {
35 + $tickets->makeHidden(self::PRIVATE_TICKET_FIELDS);
36 + } elseif (is_iterable($tickets)) {
37 + foreach ($tickets as $ticket) {
38 + if ($ticket instanceof Ticket) {
39 + $ticket->makeHidden(self::PRIVATE_TICKET_FIELDS);
40 + }
41 + }
42 + }
43 +
44 + return $tickets;
45 + }
46 +
47 + /**
20 48 * @param object $customer
21 49 * @param string $requestedStatus
22 50 * @param array|null $options
23 51 * @return object
@@ -33,9 +61,8 @@
33 61 return $this->ticketsAdditionalData($customer, $statuses, $options);
34 62 }
35 63
36 64 /**
37 - * getTicket method will get the ticket information with customer and agent as well as response in a ticket by ticket id
38 65 * @param array $customerAdditionalData
39 66 * @param int $ticketId
40 67 * @return array
41 68 * @since 1.5.7
@@ -42,8 +69,14 @@
42 69 */
43 70 public function getTicket($customerAdditionalData, $ticketId)
44 71 {
45 72 $ticket = $this->getTicketByID($ticketId);
73 +
74 + // Same message as a denied ticket, so an unknown number reveals nothing.
75 + if (!$ticket) {
76 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
77 + }
78 +
46 79 // translators: %s is the time duration (e.g., "2 hours", "3 days")
47 80 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
48 81
49 82 $customer = $this->getCustomer($customerAdditionalData, $ticket);
@@ -51,27 +84,30 @@
51 84 $this->checkCustomerTicketAccess($customer, $ticket);
52 85
53 86 return [
54 87 'ticket' => $this->syncTicketAdditionData($ticket),
55 - 'responses' => $this->getResponses($ticketId),
88 + 'responses' => $this->getResponses($ticket->id),
56 89 'sign_on_id' => $ticket->customer_id
57 90 ];
58 91 }
59 92
60 93 /**
61 - * This `createTicket` method is responsible for creating ticket for customer
62 94 * @param object $customer
63 95 * @param array $data
64 96 * @param int $mailboxId
97 + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
65 98 * @return Ticket
66 99 * @throws Exception
67 100 */
68 - public function createTicket($customer, $data, $mailboxId)
101 + public function createTicket($customer, $data, $mailboxId, $onBehalf = false)
69 102 {
70 103 $this->validateCustomer($customer);
71 104
72 - $data['title'] = sanitize_text_field(wp_unslash($data['title']));
73 - $data['content'] = wp_specialchars_decode(wp_unslash(wp_kses_post($data['content'])));
105 + $data = $this->onlyAllowedPortalTicketFields($data);
106 +
107 + // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost.
108 + $data['title'] = sanitize_text_field($data['title']);
109 + $data['content'] = wp_kses_post($data['content']);
74 110 $data['customer_id'] = $customer->id;
75 111 $data['product_source'] = 'local';
76 112 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
77 113 $data['source'] = 'web';
@@ -77,14 +113,36 @@
77 113 $data['source'] = 'web';
78 114
79 115 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
80 116 $this->validateDisabledFields($data, $disabledFields);
81 - return $this->storeTicket($data, $customer, $disabledFields);
117 +
118 + return $this->storeTicket($data, $customer, $disabledFields, $onBehalf);
82 119 }
83 120
121 + /**
122 + * The validator returns the whole payload, and many fillable Ticket columns (agent_id,
123 + * status, privacy, ...) are agent-only, so keep only what a customer may set.
124 + *
125 + * @param array $data
126 + * @return array
127 + */
128 + private function onlyAllowedPortalTicketFields($data)
129 + {
130 + $allowedKeys = [
131 + 'title',
132 + 'content',
133 + 'product_id',
134 + 'client_priority',
135 + 'custom_data',
136 + 'attachments',
137 + 'message_id',
138 + ];
84 139
140 + return array_intersect_key($data, array_flip($allowedKeys));
141 + }
142 +
143 +
85 144 /**
86 - * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data
87 145 * @param array $customerAdditionalData
88 146 * @param int $ticketId
89 147 * @param array $data
90 148 * @return array
@@ -92,28 +150,52 @@
92 150 * @since 1.5.7
93 151 */
94 152 public function createResponse($customerAdditionalData, $ticketId, $data)
95 153 {
96 - $data['content'] = wp_specialchars_decode(wp_unslash($data['content']));
154 + // Already unslashed at the request boundary; ResponseService sanitizes it.
97 155 $data['conversation_type'] = 'response';
98 156
99 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
157 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
100 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
101 159
102 160 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
103 161
162 + // Checked before anything changes, so a blank reply neither reopens nor saves.
163 + if (!$this->hasReplyContent(Arr::get($data, 'content'))) {
164 + throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support'));
165 + }
166 +
167 + // A reply reopens a closed ticket, as an emailed reply does, so it is never left
168 + // closed with an unanswered customer message in it. Reopened before the reply is
169 + // saved, as email piping does, so the reply hooks see an active ticket. Not when
170 + // the customer asked to close it with this reply.
171 + if ($ticket->status == 'closed' && Arr::get($data, 'close_ticket') != 'yes') {
172 + (new TicketService())->reopen($ticket, $customer);
173 + }
174 +
104 175 $responseData = (new ResponseService())->createResponse($data, $customer, $ticket);
105 176
177 + if (!$responseData) {
178 + throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support'));
179 + }
180 +
181 + $responseData['ticket'] = Ticket::find($ticket->id);
182 + $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id);
183 + $responseData['response']->load([
184 + 'attachments' => function ($query) {
185 + $query->where('status', 'active');
186 + }
187 + ]);
188 +
106 189 return [
107 190 'message' => __('Reply has been added', 'fluent-support'),
108 191 'response' => $responseData['response'],
109 - 'ticket' => $responseData['ticket']
192 + 'ticket' => self::hidePrivateTicketFields($responseData['ticket'])
110 193 ];
111 194 }
112 195
113 196
114 197 /**
115 - * This `closeTicket` is responsible for closing ticket by ticket id
116 198 * @param array $customerAdditionalData
117 199 * @param int $ticketId
118 200 * @return array
119 201 * @throws Exception
@@ -119,9 +201,9 @@
119 201 * @throws Exception
120 202 */
121 203 public function closeTicket($customerAdditionalData, $ticketId)
122 204 {
123 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
205 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
124 206 $customer = $this->getCustomer($customerAdditionalData, $ticket);
125 207
126 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
127 209
@@ -126,14 +208,13 @@
126 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
127 209
128 210 return [
129 211 'message' => __('Ticket has been closed', 'fluent-support'),
130 - 'ticket' => (new TicketService())->close($ticket, $customer)
212 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer))
131 213 ];
132 214 }
133 215
134 216 /**
135 - * This `reOpenTicket` is responsible for reopening ticket by ticket id
136 217 * @param array $customerAdditionalData
137 218 * @param int $ticketId
138 219 * @return array
139 220 * @throws Exception
@@ -139,9 +220,9 @@
139 220 * @throws Exception
140 221 */
141 222 public function reOpenTicket($customerAdditionalData, $ticketId)
142 223 {
143 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
224 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
144 225 $customer = $this->getCustomer($customerAdditionalData, $ticket);
145 226
146 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
147 228
@@ -146,14 +227,13 @@
146 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
147 228
148 229 return [
149 230 'message' => __('Ticket has been opened again', 'fluent-support'),
150 - 'ticket' => (new TicketService())->reopen($ticket, $customer)
231 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer))
151 232 ];
152 233 }
153 234
154 235 /**
155 - * This `validateDisabledFields` method is responsible for validating disabled fields
156 236 * @param array $data
157 237 * @param array $disabledFields
158 238 * @return array $data
159 239 * @since 1.5.7
@@ -160,10 +240,10 @@
160 240 */
161 241 private function validateDisabledFields($data, $disabledFields)
162 242 {
163 243 if (!in_array('priority', $disabledFields)) {
164 - $data['priority'] = sanitize_text_field($data['client_priority']);
165 - $data['client_priority'] = sanitize_text_field($data['client_priority']);
244 + $data['priority'] = sanitize_text_field($data['client_priority'] ?? '');
245 + $data['client_priority'] = sanitize_text_field($data['client_priority'] ?? '');
166 246 }
167 247
168 248 if (in_array('product_services', $disabledFields)) {
169 249 unset($data['product_id']);
@@ -173,19 +253,19 @@
173 253 }
174 254
175 255
176 256 /**
177 - * This `storeTicket` method is responsible for storing a ticket in Ticket Model
178 257 * @param array $data
179 258 * @param object $customer
180 259 * @param array $disabledFields
260 + * @param bool $onBehalf
181 261 * @return Ticket
182 262 * @since 1.5.7
183 263 */
184 - private function storeTicket($data, $customer, $disabledFields)
264 + private function storeTicket($data, $customer, $disabledFields, $onBehalf = false)
185 265 {
186 266 /*
187 - * Filter ticket data
267 + * Filter ticket data before it's saved.
188 268 *
189 269 * @since v1.0.0
190 270 * @param array $data
191 271 * @param object $customer
@@ -192,9 +272,9 @@
192 272 */
193 273 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
194 274
195 275 /*
196 - * Action before ticket create
276 + * Fires before the ticket is created.
197 277 *
198 278 * @since v1.0.0
199 279 * @param array $data
200 280 * @param object $customer
@@ -202,9 +282,9 @@
202 282 do_action('fluent_support/before_ticket_create', $data, $customer);
203 283
204 284 $ticket = Ticket::create($data);
205 285
206 - TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer);
286 + TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf);
207 287 $this->addCustomData($data, $ticket);
208 288
209 289 do_action('fluent_support/ticket_created', $ticket, $customer);
210 290
@@ -212,9 +292,8 @@
212 292 }
213 293
214 294
215 295 /**
216 - * This `addCustomData` method is responsible for adding custom data to ticket
217 296 * @param array $data
218 297 * @param object $ticket
219 298 * @return void
220 299 */
@@ -222,9 +301,8 @@
222 301 {
223 302 if (defined('FLUENTSUPPORTPRO')) {
224 303 $customData = Arr::get($data, 'custom_data');
225 304 if ($customData) {
226 - $customData = wp_unslash($customData);
227 305 $ticket->syncCustomFields($customData);
228 306 }
229 307 }
230 308 }
@@ -229,10 +307,9 @@
229 307 }
230 308 }
231 309
232 310 /**
233 - * This `validateCustomer` method is responsible for validating customer
234 - * @param object|null $customer // It can be null if there's no customer
311 + * @param object|null $customer
235 312 * @since 1.5.7
236 313 * @throws Exception
237 314 */
238 315 private function validateCustomer($customer)
@@ -237,18 +314,19 @@
237 314 */
238 315 private function validateCustomer($customer)
239 316 {
240 317 if (!$customer) {
241 - throw new \Exception('Customer not found');
318 + throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
242 319 }
243 320
244 - if ($customer->status == 'inactive') {
245 - throw new \Exception('Sorry, You do not have access to customer portal');
321 + if (!$customer->canAccessPortal()) {
322 + throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
246 323 }
247 324 }
248 325
249 326 /**
250 - * This `getCustomer` method is responsible for getting customer
327 + * With a valid signed ticket hash the ticket's own customer is used, otherwise the current user.
328 + *
251 329 * @param array $customerAdditionalData
252 330 * @param object $ticket
253 331 * @return object $customer
254 332 * @throws Exception
@@ -256,16 +334,33 @@
256 334 * @since 1.5.7
257 335 */
258 336 public function getCustomer($customerAdditionalData, $ticket)
259 337 {
260 - if (Arr::get($customerAdditionalData, 'intended_ticket_hash') && Helper::isPublicSignedTicketEnabled()) {
338 + $intendedHash = Arr::get($customerAdditionalData, 'intended_ticket_hash');
339 + if ($intendedHash && Helper::isPublicSignedTicketEnabled()) {
340 + if (!is_string($intendedHash) || !hash_equals((string) $ticket->hash, $intendedHash)) {
341 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
342 + }
261 343 $customer = $ticket->customer;
262 344 } else {
263 - $customer = $this->resolveCustomer(Arr::get($customerAdditionalData, 'on_behalf'), Arr::get($customerAdditionalData, 'user_ip'));
345 + $onBehalf = Arr::get($customerAdditionalData, 'on_behalf');
346 +
347 + // Signed links are off: see PortalPolicy::maybePublicSignedRequest().
348 + if ($intendedHash && !$onBehalf && PortalPolicy::canActOnBehalf()) {
349 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
350 + }
351 +
352 + // An agent acting for a customer keeps their own mailbox and visibility limits.
353 + // Site admins have full access and may not have an agent record at all.
354 + if ($onBehalf && !current_user_can('manage_options') && !(new AgentTicketAccess())->currentAgentCanAccess($ticket)) {
355 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
356 + }
357 +
358 + $customer = $this->resolveCustomer($onBehalf, Arr::get($customerAdditionalData, 'user_ip'));
264 359 }
265 360
266 361 if (!$customer) {
267 - throw new \Exception('Sorry! No customer found');
362 + throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
268 363 }
269 364
270 365 return $customer;
271 366 }
@@ -270,9 +365,8 @@
270 365 return $customer;
271 366 }
272 367
273 368 /**
274 - * This `getTicketStatues` method is responsible for getting ticket statuses
275 369 * @param string $requestedStatus
276 370 * @return array
277 371 * @since 1.8.1
278 372 */
@@ -288,9 +382,8 @@
288 382 }
289 383
290 384
291 385 /**
292 - * This `ticketsAdditionalData` method is responsible for getting tickets with additional data
293 386 * @param object $customer
294 387 * @param array $statuses
295 388 * @param array|null $options
296 389 * @return object $tickets
@@ -313,9 +406,9 @@
313 406 $query->select(['first_name', 'last_name', 'id']);
314 407 }
315 408 ])->where('customer_id', $customer->id)
316 409 ->when(!empty($ticketOptions['sorting'] && !empty($ticketOptions['sorting']['sort_by'])), function ($query) use ($ticketOptions) {
317 - return $query->orderBy($ticketOptions['sorting']['sort_by'], $ticketOptions['sorting']['sort_type']);
410 + return $query->orderBy(sanitize_sql_orderby($ticketOptions['sorting']['sort_by']), sanitize_sql_orderby($ticketOptions['sorting']['sort_type']));
318 411 })
319 412 ->when(!empty($options['filters']['product_id']), function ($query) use ($ticketOptions) {
320 413 return $query->where('product_id', $ticketOptions['filters']['product_id']);
321 414 })
@@ -335,18 +428,42 @@
335 428 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
336 429 $ticket->preview_response = $ticket->getLastResponse();
337 430 }
338 431
339 - return $tickets;
432 + return self::hidePrivateTicketFields($tickets);
340 433 }
341 434
342 435 /**
343 - * `resolveCustomer` method will create and return or only return existing customer
344 - * This method will get customer id or customer info or option to force create as parameter.
436 + * Keeps only scalar values, so sanitize_text_field() never gets an array.
437 + * An empty result makes resolveCustomer() use the logged-in user.
438 + *
439 + * @param mixed $onBehalf
440 + * @return array
441 + */
442 + public function sanitizeOnBehalf($onBehalf)
443 + {
444 + if (!is_array($onBehalf)) {
445 + return [];
446 + }
447 +
448 + $sanitized = [];
449 +
450 + foreach ($onBehalf as $key => $value) {
451 + if (is_scalar($value)) {
452 + $sanitized[sanitize_text_field($key)] = sanitize_text_field($value);
453 + }
454 + }
455 +
456 + return $sanitized;
457 + }
458 +
459 + /**
460 + * Find the customer for $onBehalf (or the current user), creating it when $forceCreate is set.
461 + *
345 462 * @param array $onBehalf
346 - * @param string $userIp // IP address of user
347 - * @param bool $forceCreate Default: false // If true, it will create a new customer
348 - * @return Customer // Collection
463 + * @param string $userIp
464 + * @param bool $forceCreate
465 + * @return Customer|false
349 466 */
350 467 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
351 468 {
352 469 if (!$onBehalf) {
@@ -353,8 +470,9 @@
353 470 $user = get_user_by('ID', get_current_user_id());
354 471 if (!$user) {
355 472 return false;
356 473 }
474 +
357 475 $onBehalf = [
358 476 'user_id' => $user->ID,
359 477 'email' => $user->user_email,
360 478 'last_ip_address' => $userIp
@@ -368,13 +486,37 @@
368 486 return Customer::getCustomerFromData($onBehalf);
369 487 }
370 488
371 489 /**
372 - * resolveMailboxId method will either get information of the mailbox added by user or default and return the id
490 + * Whether a reply has something to read: text, or an image. Editors leave markup
491 + * such as <p>&nbsp;</p> behind when the text is deleted.
492 + *
493 + * @param mixed $content
494 + * @return bool
495 + */
496 + private function hasReplyContent($content)
497 + {
498 + // ResponseService refuses whatever empty() treats as empty, "0" included.
499 + if (!is_string($content) || empty($content)) {
500 + return false;
501 + }
502 +
503 + if (stripos($content, '<img') !== false) {
504 + return true;
505 + }
506 +
507 + $text = wp_strip_all_tags(str_ireplace(['&nbsp;', '&#160;'], ' ', $content));
508 +
509 + return trim(html_entity_decode($text, ENT_QUOTES, 'UTF-8'), " \t\n\r\0\x0B\xC2\xA0") !== '';
510 + }
511 +
512 + /**
513 + * The given mailbox's id, falling back to the default mailbox.
514 + *
373 515 * @param int $mailboxId
374 - * @return null
516 + * @return int|null
375 517 */
376 - private function resolveMailboxId($mailboxId)
518 + public function resolveMailboxId($mailboxId)
377 519 {
378 520 $mailbox = MailBox::find($mailboxId);
379 521 if ($mailbox) {
380 522 return $mailbox->id;
@@ -387,18 +529,15 @@
387 529 }
388 530 return null;
389 531 }
390 532
391 - // Supportive methods for getTicket
392 -
393 533 /**
394 - * This `getTicketByID` method is responsible for getting a ticket by id
395 - * @param $ticketId
534 + * @param int|string $ticketId
396 535 * @return object $ticket
397 536 */
398 537 private function getTicketByID($ticketId)
399 538 {
400 - $ticket = Ticket::where('id', $ticketId)
539 + $ticket = Ticket::wherePublicIdentifier($ticketId)
401 540 ->with([
402 541 'customer' => function ($query) {
403 542 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'avatar']);
404 543 }, 'agent' => function ($query) {
@@ -405,9 +544,9 @@
405 544 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
406 545 },
407 546 'product',
408 547 'attachments' => function ($q) {
409 - $q->whereIn('status', ['active', 'inline']);
548 + $q->where('status', 'active');
410 549 }
411 550 ])
412 551 ->first();
413 552
@@ -414,11 +553,11 @@
414 553 return $ticket;
415 554 }
416 555
417 556 /**
418 - * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access
419 557 * @param object $customer
420 558 * @param object $ticket
559 + * @param string|false $action
421 560 * @return bool true if access is granted
422 561 * @throws Exception
423 562 */
424 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
@@ -423,13 +562,13 @@
423 562 */
424 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
425 564 {
426 565 if (!$customer) {
427 - throw new \Exception('Sorry, You do not have permission to this support ticket');
566 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
428 567 }
429 568
430 - if ($customer->status == 'inactive') {
431 - throw new \Exception('Sorry, You do not have access to customer portal');
569 + if (!$customer->canAccessPortal()) {
570 + throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support'));
432 571 }
433 572
434 573 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
435 574 if ($action) {
@@ -457,9 +596,8 @@
457 596 }
458 597
459 598
460 599 /**
461 - * This `getResponses` method is responsible for getting a ticket's responses by ticket id
462 600 * @param int $ticketId
463 601 * @return mixed
464 602 */
465 603 private function getResponses($ticketId)
@@ -468,45 +606,69 @@
468 606 ->with([
469 607 'person' => function ($query) {
470 608 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
471 609 },
472 - 'attachments'
610 + 'attachments' => function ($query) {
611 + $query->where('status', 'active');
612 + }
473 613 ])
474 614 ->filterByType(['response', 'ticket_merge_activity', 'ticket_split_activity'])
475 - ->latest('id')
615 + ->orderBy('created_at', 'desc')
616 + ->orderBy('id', 'desc')
476 617 ->get();
477 618
478 - foreach ($responses as $response) {
479 - if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
480 - $agentFeedback = Meta::where('object_id', $response->id)
481 - ->where('object_type', 'conversation_meta')
482 - ->where('key', 'agent_feedback_ratings')
483 - ->first();
619 + $contents = [];
620 + foreach ($responses as $response) {
621 + $contents[$response->id] = $response->content;
622 + }
484 623
485 - if ($agentFeedback) {
486 - $response->agent_feedback = $agentFeedback->value;
487 - }
488 - }
624 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticketId);
489 625
490 - // translators: %s is the time duration (e.g., "2 hours", "3 days")
491 - $response->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($response->created_at), current_time('timestamp')));
492 - $response->content = links_add_target(make_clickable($response->content));
493 - if ($response->person) {
494 - $response->person->setHidden(['email']);
495 - }
626 + $feedbacks = [];
627 + if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
628 + $responseIds = $responses->pluck('id')->toArray();
629 + if ($responseIds) {
630 + $feedbacks = Meta::where('object_type', 'conversation_meta')
631 + ->where('key', 'agent_feedback_ratings')
632 + ->whereIn('object_id', $responseIds)
633 + ->get()
634 + ->keyBy('object_id');
496 635 }
636 + }
497 637
638 + foreach ($responses as $response) {
639 + if ($feedbacks && $feedbacks->has($response->id)) {
640 + $response->agent_feedback = $feedbacks->get($response->id)->value;
641 + }
642 +
643 + // translators: %s is the time duration (e.g., "2 hours", "3 days")
644 + $response->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($response->created_at), current_time('timestamp')));
645 + if (isset($contents[$response->id])) {
646 + $response->content = $contents[$response->id];
647 + }
648 +
649 + $responseContent = apply_filters('fluent_support/response_content_before_render', $response->content, $response, null);
650 + $responseContent = links_add_target(make_clickable($responseContent));
651 + $response->content = apply_filters('fluent_support/response_content_after_render', $responseContent, $response, null);
652 +
653 + if ($response->person) {
654 + $response->person->setHidden(['email']);
655 + }
656 + }
657 +
498 658 return $responses;
499 659 }
500 660
501 661 /**
502 - * This `syncTicketAdditionData` method is responsible for syncing ticket additional data
503 662 * @param object $ticket
504 663 * @return object $ticket
505 664 */
506 665 private function syncTicketAdditionData($ticket)
507 666 {
508 - $ticket->content = links_add_target(make_clickable($ticket->content));
667 + $ticket->content = Helper::refreshSignedAttachmentUrls($ticket->content, $ticket->id);
668 + $ticketContent = apply_filters('fluent_support/ticket_content_before_render', $ticket->content, $ticket);
669 + $ticketContent = links_add_target(make_clickable($ticketContent));
670 + $ticket->content = apply_filters('fluent_support/ticket_content_after_render', $ticketContent, $ticket);
509 671
510 672 if ($ticket->customer) {
511 673 $ticket->customer->setHidden(['email']);
512 674 }
@@ -525,29 +687,120 @@
525 687 if (defined('FLUENTSUPPORTPRO')) {
526 688 $ticket->custom_fields = $ticket->customData('public', true);
527 689 }
528 690
529 - return $ticket;
691 + // Set when an agent opened the ticket on the customer's behalf. Only the
692 + // display fields leave the server: the loaded Agent row carries the
693 + // agent's email, IP and user id, so it is dropped again after reading.
694 + if ($ticket->created_by) {
695 + $ticket->load('created_by_person');
696 + if ($ticket->created_by_person) {
697 + $ticket->created_by_agent = [
698 + 'full_name' => $ticket->created_by_person->full_name,
699 + 'photo' => $ticket->created_by_person->photo,
700 + // The agent's first response is the opening message; skip the placeholder body.
701 + 'agent_initiated' => $ticket->isAgentInitiated(),
702 + ];
703 + }
704 + $ticket->unsetRelation('created_by_person');
705 + }
706 +
707 + return self::hidePrivateTicketFields($ticket);
530 708 }
531 709
532 - public function addUserFeedback($approvalStatus, $conversationID)
710 + /**
711 + * @param string $approvalStatus
712 + * @param int $conversationID
713 + * @param int $ticketId The authorized ticket ID the conversation must belong to
714 + * @throws Exception
715 + */
716 + public function addUserFeedback($approvalStatus, $conversationID, $ticketId)
533 717 {
718 + if (!in_array($approvalStatus, ['like', 'dislike'], true)) {
719 + throw new Exception(esc_html__('Invalid feedback value provided', 'fluent-support'));
720 + }
721 +
722 + $conversation = Conversation::with('person')
723 + ->where('id', $conversationID)
724 + ->where('ticket_id', $ticketId)
725 + ->where('conversation_type', 'response')
726 + ->first();
727 +
728 + if (!$conversation || !$conversation->person || $conversation->person->person_type !== 'agent') {
729 + throw new Exception(esc_html__('Invalid conversation for feedback', 'fluent-support'));
730 + }
731 +
534 732 $existingAgentFeedback = Meta::where([
733 + 'object_id' => $conversationID,
734 + 'object_type' => 'conversation_meta',
735 + 'key' => 'agent_feedback_ratings',
736 + ])->first();
737 +
738 + if ($existingAgentFeedback) {
739 + $wasRemoved = $existingAgentFeedback->value === $approvalStatus;
740 + $result = $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
741 + if (!$wasRemoved) {
742 + $this->fireFeedbackReceived($approvalStatus, $conversation);
743 + }
744 + return $result;
745 + }
746 +
747 + $agentFeedback = Meta::create([
535 748 'object_id' => $conversationID,
536 749 'key' => 'agent_feedback_ratings',
537 - ])->first();
750 + 'object_type' => 'conversation_meta',
751 + 'value' => $approvalStatus,
752 + ]);
538 753
539 - if ($existingAgentFeedback) {
540 - return $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
541 - } else {
542 - $agentFeedback = Meta::create([
543 - 'object_id' => $conversationID,
544 - 'key' => 'agent_feedback_ratings',
545 - 'object_type' => 'conversation_meta',
546 - 'value' => $approvalStatus,
547 - ]);
548 - return $agentFeedback;
754 + $this->fireFeedbackReceived($approvalStatus, $conversation);
755 +
756 + return $agentFeedback;
757 + }
758 +
759 + /**
760 + * A customer rated an agent's reply (or changed the rating). Clicking the
761 + * same rating again removes it and fires nothing. Each rating fires at most
762 + * once per reply, so clicking back and forth can't rerun workflows.
763 + *
764 + * @param string $rating 'like' or 'dislike'
765 + * @param Conversation $conversation The rated reply.
766 + * @return void
767 + */
768 + private function fireFeedbackReceived($rating, $conversation)
769 + {
770 + $ticket = Ticket::with('customer')->find($conversation->ticket_id);
771 +
772 + if (!$ticket) {
773 + return;
549 774 }
775 +
776 + $firedKey = '_agent_feedback_fired_' . $rating;
777 + $alreadyFired = Meta::where('object_type', 'conversation_meta')
778 + ->where('object_id', $conversation->id)
779 + ->where('key', $firedKey)
780 + ->exists();
781 +
782 + if ($alreadyFired) {
783 + return;
784 + }
785 +
786 + Meta::create([
787 + 'object_type' => 'conversation_meta',
788 + 'object_id' => $conversation->id,
789 + 'key' => $firedKey,
790 + 'value' => current_time('mysql'),
791 + ]);
792 +
793 + /*
794 + * Fires when a customer likes or dislikes an agent's reply.
795 + *
796 + * @since 2.4.1
797 + * @param string $rating 'like' or 'dislike'
798 + * @param Conversation $conversation The rated reply.
799 + * @param Ticket $ticket
800 + * @param Customer $customer
801 + */
802 + do_action('fluent_support/agent_feedback_received', $rating, $conversation, $ticket, $ticket->customer);
550 803 }
551 804
552 805 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
553 806 {