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 +349 -92 1.10.1 → 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,15 @@
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 +
79 + // translators: %s is the time duration (e.g., "2 hours", "3 days")
46 80 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
47 81
48 82 $customer = $this->getCustomer($customerAdditionalData, $ticket);
49 83
@@ -50,27 +84,30 @@
50 84 $this->checkCustomerTicketAccess($customer, $ticket);
51 85
52 86 return [
53 87 'ticket' => $this->syncTicketAdditionData($ticket),
54 - 'responses' => $this->getResponses($ticketId),
88 + 'responses' => $this->getResponses($ticket->id),
55 89 'sign_on_id' => $ticket->customer_id
56 90 ];
57 91 }
58 92
59 93 /**
60 - * This `createTicket` method is responsible for creating ticket for customer
61 94 * @param object $customer
62 95 * @param array $data
63 96 * @param int $mailboxId
97 + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
64 98 * @return Ticket
65 99 * @throws Exception
66 100 */
67 - public function createTicket($customer, $data, $mailboxId)
101 + public function createTicket($customer, $data, $mailboxId, $onBehalf = false)
68 102 {
69 103 $this->validateCustomer($customer);
70 104
71 - $data['title'] = sanitize_text_field(wp_unslash($data['title']));
72 - $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']);
73 110 $data['customer_id'] = $customer->id;
74 111 $data['product_source'] = 'local';
75 112 $data['mailbox_id'] = $this->resolveMailboxId($mailboxId);
76 113 $data['source'] = 'web';
@@ -76,14 +113,36 @@
76 113 $data['source'] = 'web';
77 114
78 115 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
79 116 $this->validateDisabledFields($data, $disabledFields);
80 - return $this->storeTicket($data, $customer, $disabledFields);
117 +
118 + return $this->storeTicket($data, $customer, $disabledFields, $onBehalf);
81 119 }
82 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 + ];
83 139
140 + return array_intersect_key($data, array_flip($allowedKeys));
141 + }
142 +
143 +
84 144 /**
85 - * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data
86 145 * @param array $customerAdditionalData
87 146 * @param int $ticketId
88 147 * @param array $data
89 148 * @return array
@@ -91,28 +150,52 @@
91 150 * @since 1.5.7
92 151 */
93 152 public function createResponse($customerAdditionalData, $ticketId, $data)
94 153 {
95 - $data['content'] = wp_specialchars_decode(wp_unslash($data['content']));
154 + // Already unslashed at the request boundary; ResponseService sanitizes it.
96 155 $data['conversation_type'] = 'response';
97 156
98 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
157 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
99 158 $customer = $this->getCustomer($customerAdditionalData, $ticket);
100 159
101 160 $this->checkCustomerTicketAccess($customer, $ticket, 'response');
102 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 +
103 175 $responseData = (new ResponseService())->createResponse($data, $customer, $ticket);
104 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 +
105 189 return [
106 190 'message' => __('Reply has been added', 'fluent-support'),
107 191 'response' => $responseData['response'],
108 - 'ticket' => $responseData['ticket']
192 + 'ticket' => self::hidePrivateTicketFields($responseData['ticket'])
109 193 ];
110 194 }
111 195
112 196
113 197 /**
114 - * This `closeTicket` is responsible for closing ticket by ticket id
115 198 * @param array $customerAdditionalData
116 199 * @param int $ticketId
117 200 * @return array
118 201 * @throws Exception
@@ -118,9 +201,9 @@
118 201 * @throws Exception
119 202 */
120 203 public function closeTicket($customerAdditionalData, $ticketId)
121 204 {
122 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
205 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
123 206 $customer = $this->getCustomer($customerAdditionalData, $ticket);
124 207
125 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
126 209
@@ -125,14 +208,13 @@
125 208 $this->checkCustomerTicketAccess($customer, $ticket, 'close');
126 209
127 210 return [
128 211 'message' => __('Ticket has been closed', 'fluent-support'),
129 - 'ticket' => (new TicketService())->close($ticket, $customer)
212 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer))
130 213 ];
131 214 }
132 215
133 216 /**
134 - * This `reOpenTicket` is responsible for reopening ticket by ticket id
135 217 * @param array $customerAdditionalData
136 218 * @param int $ticketId
137 219 * @return array
138 220 * @throws Exception
@@ -138,9 +220,9 @@
138 220 * @throws Exception
139 221 */
140 222 public function reOpenTicket($customerAdditionalData, $ticketId)
141 223 {
142 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
224 + $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail();
143 225 $customer = $this->getCustomer($customerAdditionalData, $ticket);
144 226
145 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
146 228
@@ -145,14 +227,13 @@
145 227 $this->checkCustomerTicketAccess($customer, $ticket, 'reopen');
146 228
147 229 return [
148 230 'message' => __('Ticket has been opened again', 'fluent-support'),
149 - 'ticket' => (new TicketService())->reopen($ticket, $customer)
231 + 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer))
150 232 ];
151 233 }
152 234
153 235 /**
154 - * This `validateDisabledFields` method is responsible for validating disabled fields
155 236 * @param array $data
156 237 * @param array $disabledFields
157 238 * @return array $data
158 239 * @since 1.5.7
@@ -159,10 +240,10 @@
159 240 */
160 241 private function validateDisabledFields($data, $disabledFields)
161 242 {
162 243 if (!in_array('priority', $disabledFields)) {
163 - $data['priority'] = sanitize_text_field($data['client_priority']);
164 - $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'] ?? '');
165 246 }
166 247
167 248 if (in_array('product_services', $disabledFields)) {
168 249 unset($data['product_id']);
@@ -172,19 +253,19 @@
172 253 }
173 254
174 255
175 256 /**
176 - * This `storeTicket` method is responsible for storing a ticket in Ticket Model
177 257 * @param array $data
178 258 * @param object $customer
179 259 * @param array $disabledFields
260 + * @param bool $onBehalf
180 261 * @return Ticket
181 262 * @since 1.5.7
182 263 */
183 - private function storeTicket($data, $customer, $disabledFields)
264 + private function storeTicket($data, $customer, $disabledFields, $onBehalf = false)
184 265 {
185 266 /*
186 - * Filter ticket data
267 + * Filter ticket data before it's saved.
187 268 *
188 269 * @since v1.0.0
189 270 * @param array $data
190 271 * @param object $customer
@@ -191,9 +272,9 @@
191 272 */
192 273 $data = apply_filters('fluent_support/create_ticket_data', $data, $customer);
193 274
194 275 /*
195 - * Action before ticket create
276 + * Fires before the ticket is created.
196 277 *
197 278 * @since v1.0.0
198 279 * @param array $data
199 280 * @param object $customer
@@ -201,9 +282,9 @@
201 282 do_action('fluent_support/before_ticket_create', $data, $customer);
202 283
203 284 $ticket = Ticket::create($data);
204 285
205 - TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer);
286 + TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf);
206 287 $this->addCustomData($data, $ticket);
207 288
208 289 do_action('fluent_support/ticket_created', $ticket, $customer);
209 290
@@ -211,9 +292,8 @@
211 292 }
212 293
213 294
214 295 /**
215 - * This `addCustomData` method is responsible for adding custom data to ticket
216 296 * @param array $data
217 297 * @param object $ticket
218 298 * @return void
219 299 */
@@ -221,9 +301,8 @@
221 301 {
222 302 if (defined('FLUENTSUPPORTPRO')) {
223 303 $customData = Arr::get($data, 'custom_data');
224 304 if ($customData) {
225 - $customData = wp_unslash($customData);
226 305 $ticket->syncCustomFields($customData);
227 306 }
228 307 }
229 308 }
@@ -228,10 +307,9 @@
228 307 }
229 308 }
230 309
231 310 /**
232 - * This `validateCustomer` method is responsible for validating customer
233 - * @param object|null $customer // It can be null if there's no customer
311 + * @param object|null $customer
234 312 * @since 1.5.7
235 313 * @throws Exception
236 314 */
237 315 private function validateCustomer($customer)
@@ -236,18 +314,19 @@
236 314 */
237 315 private function validateCustomer($customer)
238 316 {
239 317 if (!$customer) {
240 - throw new \Exception('Customer not found');
318 + throw new \Exception(esc_html__('Customer not found', 'fluent-support'));
241 319 }
242 320
243 - if ($customer->status == 'inactive') {
244 - 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'));
245 323 }
246 324 }
247 325
248 326 /**
249 - * 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 + *
250 329 * @param array $customerAdditionalData
251 330 * @param object $ticket
252 331 * @return object $customer
253 332 * @throws Exception
@@ -255,16 +334,33 @@
255 334 * @since 1.5.7
256 335 */
257 336 public function getCustomer($customerAdditionalData, $ticket)
258 337 {
259 - 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 + }
260 343 $customer = $ticket->customer;
261 344 } else {
262 - $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'));
263 359 }
264 360
265 361 if (!$customer) {
266 - throw new \Exception('Sorry! No customer found');
362 + throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support'));
267 363 }
268 364
269 365 return $customer;
270 366 }
@@ -269,9 +365,8 @@
269 365 return $customer;
270 366 }
271 367
272 368 /**
273 - * This `getTicketStatues` method is responsible for getting ticket statuses
274 369 * @param string $requestedStatus
275 370 * @return array
276 371 * @since 1.8.1
277 372 */
@@ -287,9 +382,8 @@
287 382 }
288 383
289 384
290 385 /**
291 - * This `ticketsAdditionalData` method is responsible for getting tickets with additional data
292 386 * @param object $customer
293 387 * @param array $statuses
294 388 * @param array|null $options
295 389 * @return object $tickets
@@ -312,9 +406,9 @@
312 406 $query->select(['first_name', 'last_name', 'id']);
313 407 }
314 408 ])->where('customer_id', $customer->id)
315 409 ->when(!empty($ticketOptions['sorting'] && !empty($ticketOptions['sorting']['sort_by'])), function ($query) use ($ticketOptions) {
316 - 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']));
317 411 })
318 412 ->when(!empty($options['filters']['product_id']), function ($query) use ($ticketOptions) {
319 413 return $query->where('product_id', $ticketOptions['filters']['product_id']);
320 414 })
@@ -329,22 +423,47 @@
329 423 })
330 424 ->paginate();
331 425
332 426 foreach ($tickets as $ticket) {
427 + // translators: %s is the time duration (e.g., "2 hours", "3 days")
333 428 $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp')));
334 429 $ticket->preview_response = $ticket->getLastResponse();
335 430 }
336 431
337 - return $tickets;
432 + return self::hidePrivateTicketFields($tickets);
338 433 }
339 434
340 435 /**
341 - * `resolveCustomer` method will create and return or only return existing customer
342 - * 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 + *
343 462 * @param array $onBehalf
344 - * @param string $userIp // IP address of user
345 - * @param bool $forceCreate Default: false // If true, it will create a new customer
346 - * @return Customer // Collection
463 + * @param string $userIp
464 + * @param bool $forceCreate
465 + * @return Customer|false
347 466 */
348 467 public function resolveCustomer($onBehalf, $userIp, $forceCreate = false)
349 468 {
350 469 if (!$onBehalf) {
@@ -351,8 +470,9 @@
351 470 $user = get_user_by('ID', get_current_user_id());
352 471 if (!$user) {
353 472 return false;
354 473 }
474 +
355 475 $onBehalf = [
356 476 'user_id' => $user->ID,
357 477 'email' => $user->user_email,
358 478 'last_ip_address' => $userIp
@@ -366,13 +486,37 @@
366 486 return Customer::getCustomerFromData($onBehalf);
367 487 }
368 488
369 489 /**
370 - * 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 + *
371 515 * @param int $mailboxId
372 - * @return null
516 + * @return int|null
373 517 */
374 - private function resolveMailboxId($mailboxId)
518 + public function resolveMailboxId($mailboxId)
375 519 {
376 520 $mailbox = MailBox::find($mailboxId);
377 521 if ($mailbox) {
378 522 return $mailbox->id;
@@ -385,18 +529,15 @@
385 529 }
386 530 return null;
387 531 }
388 532
389 - // Supportive methods for getTicket
390 -
391 533 /**
392 - * This `getTicketByID` method is responsible for getting a ticket by id
393 - * @param $ticketId
534 + * @param int|string $ticketId
394 535 * @return object $ticket
395 536 */
396 537 private function getTicketByID($ticketId)
397 538 {
398 - $ticket = Ticket::where('id', $ticketId)
539 + $ticket = Ticket::wherePublicIdentifier($ticketId)
399 540 ->with([
400 541 'customer' => function ($query) {
401 542 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'avatar']);
402 543 }, 'agent' => function ($query) {
@@ -403,9 +544,9 @@
403 544 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
404 545 },
405 546 'product',
406 547 'attachments' => function ($q) {
407 - $q->whereIn('status', ['active', 'inline']);
548 + $q->where('status', 'active');
408 549 }
409 550 ])
410 551 ->first();
411 552
@@ -412,11 +553,11 @@
412 553 return $ticket;
413 554 }
414 555
415 556 /**
416 - * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access
417 557 * @param object $customer
418 558 * @param object $ticket
559 + * @param string|false $action
419 560 * @return bool true if access is granted
420 561 * @throws Exception
421 562 */
422 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
@@ -421,18 +562,19 @@
421 562 */
422 563 public function checkCustomerTicketAccess($customer, $ticket, $action = false)
423 564 {
424 565 if (!$customer) {
425 - 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'));
426 567 }
427 568
428 - if ($customer->status == 'inactive') {
429 - 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'));
430 571 }
431 572
432 573 if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) {
433 574 if ($action) {
434 575 throw new \Exception(sprintf(
576 + // translators: %s is the action being performed (e.g., "view", "edit", "delete")
435 577 esc_html__("Sorry! You cannot %s this ticket", 'fluent-support'),
436 578 esc_html($action)
437 579 ));
438 580 } else {
@@ -454,9 +596,8 @@
454 596 }
455 597
456 598
457 599 /**
458 - * This `getResponses` method is responsible for getting a ticket's responses by ticket id
459 600 * @param int $ticketId
460 601 * @return mixed
461 602 */
462 603 private function getResponses($ticketId)
@@ -465,44 +606,69 @@
465 606 ->with([
466 607 'person' => function ($query) {
467 608 $query->select(['first_name', 'email', 'person_type', 'last_name', 'id', 'title', 'avatar']);
468 609 },
469 - 'attachments'
610 + 'attachments' => function ($query) {
611 + $query->where('status', 'active');
612 + }
470 613 ])
471 614 ->filterByType(['response', 'ticket_merge_activity', 'ticket_split_activity'])
472 - ->latest('id')
615 + ->orderBy('created_at', 'desc')
616 + ->orderBy('id', 'desc')
473 617 ->get();
474 618
475 - foreach ($responses as $response) {
476 - if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && Helper::isAgentFeedbackEnabled()) {
477 - $agentFeedback = Meta::where('object_id', $response->id)
478 - ->where('object_type', 'conversation_meta')
479 - ->where('key', 'agent_feedback_ratings')
480 - ->first();
619 + $contents = [];
620 + foreach ($responses as $response) {
621 + $contents[$response->id] = $response->content;
622 + }
481 623
482 - if ($agentFeedback) {
483 - $response->agent_feedback = $agentFeedback->value;
484 - }
485 - }
624 + $contents = Helper::refreshSignedAttachmentUrlsInContents($contents, $ticketId);
486 625
487 - $response->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($response->created_at), current_time('timestamp')));
488 - $response->content = links_add_target(make_clickable($response->content));
489 - if ($response->person) {
490 - $response->person->setHidden(['email']);
491 - }
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');
492 635 }
636 + }
493 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 +
494 658 return $responses;
495 659 }
496 660
497 661 /**
498 - * This `syncTicketAdditionData` method is responsible for syncing ticket additional data
499 662 * @param object $ticket
500 663 * @return object $ticket
501 664 */
502 665 private function syncTicketAdditionData($ticket)
503 666 {
504 - $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);
505 671
506 672 if ($ticket->customer) {
507 673 $ticket->customer->setHidden(['email']);
508 674 }
@@ -521,29 +687,120 @@
521 687 if (defined('FLUENTSUPPORTPRO')) {
522 688 $ticket->custom_fields = $ticket->customData('public', true);
523 689 }
524 690
525 - 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);
526 708 }
527 709
528 - 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)
529 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 +
530 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([
531 748 'object_id' => $conversationID,
532 749 'key' => 'agent_feedback_ratings',
533 - ])->first();
750 + 'object_type' => 'conversation_meta',
751 + 'value' => $approvalStatus,
752 + ]);
534 753
535 - if ($existingAgentFeedback) {
536 - return $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus);
537 - } else {
538 - $agentFeedback = Meta::create([
539 - 'object_id' => $conversationID,
540 - 'key' => 'agent_feedback_ratings',
541 - 'object_type' => 'conversation_meta',
542 - 'value' => $approvalStatus,
543 - ]);
544 - 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;
545 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);
546 803 }
547 804
548 805 private function updateExistingFeedback($existingAgentFeedback, $approvalStatus)
549 806 {