| @@ -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); |
| @@ -57,21 +90,24 @@ | ||
| 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'; |
| @@ -78,14 +114,35 @@ | ||
| 78 | 114 | |
| 79 | 115 | $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []); |
| 80 | 116 | $this->validateDisabledFields($data, $disabledFields); |
| 81 | 117 | |
| 82 | - return $this->storeTicket($data, $customer, $disabledFields); | |
| 118 | + return $this->storeTicket($data, $customer, $disabledFields, $onBehalf); | |
| 83 | 119 | } |
| 84 | 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 | + ]; | |
| 85 | 139 | |
| 140 | + return array_intersect_key($data, array_flip($allowedKeys)); | |
| 141 | + } | |
| 142 | + | |
| 143 | + | |
| 86 | 144 | /** |
| 87 | - * This `createResponse` method is responsible for creating response by customer in a ticket by ticket id, and data | |
| 88 | 145 | * @param array $customerAdditionalData |
| 89 | 146 | * @param int $ticketId |
| 90 | 147 | * @param array $data |
| 91 | 148 | * @return array |
| @@ -93,9 +150,9 @@ | ||
| 93 | 150 | * @since 1.5.7 |
| 94 | 151 | */ |
| 95 | 152 | public function createResponse($customerAdditionalData, $ticketId, $data) |
| 96 | 153 | { |
| 97 | - $data['content'] = wp_specialchars_decode(wp_unslash($data['content'])); | |
| 154 | + // Already unslashed at the request boundary; ResponseService sanitizes it. | |
| 98 | 155 | $data['conversation_type'] = 'response'; |
| 99 | 156 | |
| 100 | 157 | $ticket = Ticket::with(['customer'])->wherePublicIdentifier($ticketId)->firstOrFail(); |
| 101 | 158 | $customer = $this->getCustomer($customerAdditionalData, $ticket); |
| @@ -101,9 +158,27 @@ | ||
| 101 | 158 | $customer = $this->getCustomer($customerAdditionalData, $ticket); |
| 102 | 159 | |
| 103 | 160 | $this->checkCustomerTicketAccess($customer, $ticket, 'response'); |
| 104 | 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 | + | |
| 105 | 175 | $responseData = (new ResponseService())->createResponse($data, $customer, $ticket); |
| 176 | + | |
| 177 | + if (!$responseData) { | |
| 178 | + throw new \Exception(esc_html__('Please write a reply before sending.', 'fluent-support')); | |
| 179 | + } | |
| 180 | + | |
| 106 | 181 | $responseData['ticket'] = Ticket::find($ticket->id); |
| 107 | 182 | $responseData['response']->content = Helper::refreshSignedAttachmentUrls($responseData['response']->content, $ticket->id); |
| 108 | 183 | $responseData['response']->load([ |
| 109 | 184 | 'attachments' => function ($query) { |
| @@ -113,15 +188,14 @@ | ||
| 113 | 188 | |
| 114 | 189 | return [ |
| 115 | 190 | 'message' => __('Reply has been added', 'fluent-support'), |
| 116 | 191 | 'response' => $responseData['response'], |
| 117 | - 'ticket' => $responseData['ticket'] | |
| 192 | + 'ticket' => self::hidePrivateTicketFields($responseData['ticket']) | |
| 118 | 193 | ]; |
| 119 | 194 | } |
| 120 | 195 | |
| 121 | 196 | |
| 122 | 197 | /** |
| 123 | - * This `closeTicket` is responsible for closing ticket by ticket id | |
| 124 | 198 | * @param array $customerAdditionalData |
| 125 | 199 | * @param int $ticketId |
| 126 | 200 | * @return array |
| 127 | 201 | * @throws Exception |
| @@ -134,14 +208,13 @@ | ||
| 134 | 208 | $this->checkCustomerTicketAccess($customer, $ticket, 'close'); |
| 135 | 209 | |
| 136 | 210 | return [ |
| 137 | 211 | 'message' => __('Ticket has been closed', 'fluent-support'), |
| 138 | - 'ticket' => (new TicketService())->close($ticket, $customer) | |
| 212 | + 'ticket' => self::hidePrivateTicketFields((new TicketService())->close($ticket, $customer)) | |
| 139 | 213 | ]; |
| 140 | 214 | } |
| 141 | 215 | |
| 142 | 216 | /** |
| 143 | - * This `reOpenTicket` is responsible for reopening ticket by ticket id | |
| 144 | 217 | * @param array $customerAdditionalData |
| 145 | 218 | * @param int $ticketId |
| 146 | 219 | * @return array |
| 147 | 220 | * @throws Exception |
| @@ -154,14 +227,13 @@ | ||
| 154 | 227 | $this->checkCustomerTicketAccess($customer, $ticket, 'reopen'); |
| 155 | 228 | |
| 156 | 229 | return [ |
| 157 | 230 | 'message' => __('Ticket has been opened again', 'fluent-support'), |
| 158 | - 'ticket' => (new TicketService())->reopen($ticket, $customer) | |
| 231 | + 'ticket' => self::hidePrivateTicketFields((new TicketService())->reopen($ticket, $customer)) | |
| 159 | 232 | ]; |
| 160 | 233 | } |
| 161 | 234 | |
| 162 | 235 | /** |
| 163 | - * This `validateDisabledFields` method is responsible for validating disabled fields | |
| 164 | 236 | * @param array $data |
| 165 | 237 | * @param array $disabledFields |
| 166 | 238 | * @return array $data |
| 167 | 239 | * @since 1.5.7 |
| @@ -181,19 +253,19 @@ | ||
| 181 | 253 | } |
| 182 | 254 | |
| 183 | 255 | |
| 184 | 256 | /** |
| 185 | - * This `storeTicket` method is responsible for storing a ticket in Ticket Model | |
| 186 | 257 | * @param array $data |
| 187 | 258 | * @param object $customer |
| 188 | 259 | * @param array $disabledFields |
| 260 | + * @param bool $onBehalf | |
| 189 | 261 | * @return Ticket |
| 190 | 262 | * @since 1.5.7 |
| 191 | 263 | */ |
| 192 | - private function storeTicket($data, $customer, $disabledFields) | |
| 264 | + private function storeTicket($data, $customer, $disabledFields, $onBehalf = false) | |
| 193 | 265 | { |
| 194 | 266 | /* |
| 195 | - * Filter ticket data | |
| 267 | + * Filter ticket data before it's saved. | |
| 196 | 268 | * |
| 197 | 269 | * @since v1.0.0 |
| 198 | 270 | * @param array $data |
| 199 | 271 | * @param object $customer |
| @@ -200,9 +272,9 @@ | ||
| 200 | 272 | */ |
| 201 | 273 | $data = apply_filters('fluent_support/create_ticket_data', $data, $customer); |
| 202 | 274 | |
| 203 | 275 | /* |
| 204 | - * Action before ticket create | |
| 276 | + * Fires before the ticket is created. | |
| 205 | 277 | * |
| 206 | 278 | * @since v1.0.0 |
| 207 | 279 | * @param array $data |
| 208 | 280 | * @param object $customer |
| @@ -210,9 +282,9 @@ | ||
| 210 | 282 | do_action('fluent_support/before_ticket_create', $data, $customer); |
| 211 | 283 | |
| 212 | 284 | $ticket = Ticket::create($data); |
| 213 | 285 | |
| 214 | - TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer); | |
| 286 | + TicketService::addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf); | |
| 215 | 287 | $this->addCustomData($data, $ticket); |
| 216 | 288 | |
| 217 | 289 | do_action('fluent_support/ticket_created', $ticket, $customer); |
| 218 | 290 | |
| @@ -220,9 +292,8 @@ | ||
| 220 | 292 | } |
| 221 | 293 | |
| 222 | 294 | |
| 223 | 295 | /** |
| 224 | - * This `addCustomData` method is responsible for adding custom data to ticket | |
| 225 | 296 | * @param array $data |
| 226 | 297 | * @param object $ticket |
| 227 | 298 | * @return void |
| 228 | 299 | */ |
| @@ -230,9 +301,8 @@ | ||
| 230 | 301 | { |
| 231 | 302 | if (defined('FLUENTSUPPORTPRO')) { |
| 232 | 303 | $customData = Arr::get($data, 'custom_data'); |
| 233 | 304 | if ($customData) { |
| 234 | - $customData = wp_unslash($customData); | |
| 235 | 305 | $ticket->syncCustomFields($customData); |
| 236 | 306 | } |
| 237 | 307 | } |
| 238 | 308 | } |
| @@ -237,10 +307,9 @@ | ||
| 237 | 307 | } |
| 238 | 308 | } |
| 239 | 309 | |
| 240 | 310 | /** |
| 241 | - * This `validateCustomer` method is responsible for validating customer | |
| 242 | - * @param object|null $customer // It can be null if there's no customer | |
| 311 | + * @param object|null $customer | |
| 243 | 312 | * @since 1.5.7 |
| 244 | 313 | * @throws Exception |
| 245 | 314 | */ |
| 246 | 315 | private function validateCustomer($customer) |
| @@ -248,15 +317,16 @@ | ||
| 248 | 317 | if (!$customer) { |
| 249 | 318 | throw new \Exception(esc_html__('Customer not found', 'fluent-support')); |
| 250 | 319 | } |
| 251 | 320 | |
| 252 | - if ($customer->status == 'inactive') { | |
| 321 | + if (!$customer->canAccessPortal()) { | |
| 253 | 322 | throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support')); |
| 254 | 323 | } |
| 255 | 324 | } |
| 256 | 325 | |
| 257 | 326 | /** |
| 258 | - * 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 | + * | |
| 259 | 329 | * @param array $customerAdditionalData |
| 260 | 330 | * @param object $ticket |
| 261 | 331 | * @return object $customer |
| 262 | 332 | * @throws Exception |
| @@ -264,12 +334,29 @@ | ||
| 264 | 334 | * @since 1.5.7 |
| 265 | 335 | */ |
| 266 | 336 | public function getCustomer($customerAdditionalData, $ticket) |
| 267 | 337 | { |
| 268 | - 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 | + } | |
| 269 | 343 | $customer = $ticket->customer; |
| 270 | 344 | } else { |
| 271 | - $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')); | |
| 272 | 359 | } |
| 273 | 360 | |
| 274 | 361 | if (!$customer) { |
| 275 | 362 | throw new \Exception(esc_html__('Sorry! No customer found', 'fluent-support')); |
| @@ -278,9 +365,8 @@ | ||
| 278 | 365 | return $customer; |
| 279 | 366 | } |
| 280 | 367 | |
| 281 | 368 | /** |
| 282 | - * This `getTicketStatues` method is responsible for getting ticket statuses | |
| 283 | 369 | * @param string $requestedStatus |
| 284 | 370 | * @return array |
| 285 | 371 | * @since 1.8.1 |
| 286 | 372 | */ |
| @@ -296,9 +382,8 @@ | ||
| 296 | 382 | } |
| 297 | 383 | |
| 298 | 384 | |
| 299 | 385 | /** |
| 300 | - * This `ticketsAdditionalData` method is responsible for getting tickets with additional data | |
| 301 | 386 | * @param object $customer |
| 302 | 387 | * @param array $statuses |
| 303 | 388 | * @param array|null $options |
| 304 | 389 | * @return object $tickets |
| @@ -343,18 +428,42 @@ | ||
| 343 | 428 | $ticket->human_date = sprintf(__('%s ago', 'fluent-support'), human_time_diff(strtotime($ticket->created_at), current_time('timestamp'))); |
| 344 | 429 | $ticket->preview_response = $ticket->getLastResponse(); |
| 345 | 430 | } |
| 346 | 431 | |
| 347 | - return $tickets; | |
| 432 | + return self::hidePrivateTicketFields($tickets); | |
| 348 | 433 | } |
| 349 | 434 | |
| 350 | 435 | /** |
| 351 | - * `resolveCustomer` method will create and return or only return existing customer | |
| 352 | - * 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 | + * | |
| 353 | 462 | * @param array $onBehalf |
| 354 | - * @param string $userIp // IP address of user | |
| 355 | - * @param bool $forceCreate Default: false // If true, it will create a new customer | |
| 356 | - * @return Customer | false // | |
| 463 | + * @param string $userIp | |
| 464 | + * @param bool $forceCreate | |
| 465 | + * @return Customer|false | |
| 357 | 466 | */ |
| 358 | 467 | public function resolveCustomer($onBehalf, $userIp, $forceCreate = false) |
| 359 | 468 | { |
| 360 | 469 | if (!$onBehalf) { |
| @@ -377,13 +486,37 @@ | ||
| 377 | 486 | return Customer::getCustomerFromData($onBehalf); |
| 378 | 487 | } |
| 379 | 488 | |
| 380 | 489 | /** |
| 381 | - * 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> </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([' ', ' '], ' ', $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 | + * | |
| 382 | 515 | * @param int $mailboxId |
| 383 | - * @return null | |
| 516 | + * @return int|null | |
| 384 | 517 | */ |
| 385 | - private function resolveMailboxId($mailboxId) | |
| 518 | + public function resolveMailboxId($mailboxId) | |
| 386 | 519 | { |
| 387 | 520 | $mailbox = MailBox::find($mailboxId); |
| 388 | 521 | if ($mailbox) { |
| 389 | 522 | return $mailbox->id; |
| @@ -396,13 +529,10 @@ | ||
| 396 | 529 | } |
| 397 | 530 | return null; |
| 398 | 531 | } |
| 399 | 532 | |
| 400 | - // Supportive methods for getTicket | |
| 401 | - | |
| 402 | 533 | /** |
| 403 | - * This `getTicketByID` method is responsible for getting a ticket by id | |
| 404 | - * @param $ticketId | |
| 534 | + * @param int|string $ticketId | |
| 405 | 535 | * @return object $ticket |
| 406 | 536 | */ |
| 407 | 537 | private function getTicketByID($ticketId) |
| 408 | 538 | { |
| @@ -423,11 +553,11 @@ | ||
| 423 | 553 | return $ticket; |
| 424 | 554 | } |
| 425 | 555 | |
| 426 | 556 | /** |
| 427 | - * This `checkCustomerTicketAccess` method is responsible for checking customer ticket access | |
| 428 | 557 | * @param object $customer |
| 429 | 558 | * @param object $ticket |
| 559 | + * @param string|false $action | |
| 430 | 560 | * @return bool true if access is granted |
| 431 | 561 | * @throws Exception |
| 432 | 562 | */ |
| 433 | 563 | public function checkCustomerTicketAccess($customer, $ticket, $action = false) |
| @@ -435,9 +565,9 @@ | ||
| 435 | 565 | if (!$customer) { |
| 436 | 566 | throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support')); |
| 437 | 567 | } |
| 438 | 568 | |
| 439 | - if ($customer->status == 'inactive') { | |
| 569 | + if (!$customer->canAccessPortal()) { | |
| 440 | 570 | throw new \Exception(esc_html__('Sorry, You do not have access to customer portal', 'fluent-support')); |
| 441 | 571 | } |
| 442 | 572 | |
| 443 | 573 | if ($ticket->privacy == 'private' && $customer->id != $ticket->customer_id) { |
| @@ -466,9 +596,8 @@ | ||
| 466 | 596 | } |
| 467 | 597 | |
| 468 | 598 | |
| 469 | 599 | /** |
| 470 | - * This `getResponses` method is responsible for getting a ticket's responses by ticket id | |
| 471 | 600 | * @param int $ticketId |
| 472 | 601 | * @return mixed |
| 473 | 602 | */ |
| 474 | 603 | private function getResponses($ticketId) |
| @@ -529,9 +658,8 @@ | ||
| 529 | 658 | return $responses; |
| 530 | 659 | } |
| 531 | 660 | |
| 532 | 661 | /** |
| 533 | - * This `syncTicketAdditionData` method is responsible for syncing ticket additional data | |
| 534 | 662 | * @param object $ticket |
| 535 | 663 | * @return object $ticket |
| 536 | 664 | */ |
| 537 | 665 | private function syncTicketAdditionData($ticket) |
| @@ -559,40 +687,120 @@ | ||
| 559 | 687 | if (defined('FLUENTSUPPORTPRO')) { |
| 560 | 688 | $ticket->custom_fields = $ticket->customData('public', true); |
| 561 | 689 | } |
| 562 | 690 | |
| 563 | - // Load agent info if ticket was created on behalf of customer | |
| 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. | |
| 564 | 694 | if ($ticket->created_by) { |
| 565 | 695 | $ticket->load('created_by_person'); |
| 566 | 696 | if ($ticket->created_by_person) { |
| 567 | 697 | $ticket->created_by_agent = [ |
| 568 | - 'full_name' => $ticket->created_by_person->full_name, | |
| 569 | - 'photo' => $ticket->created_by_person->photo, | |
| 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(), | |
| 570 | 702 | ]; |
| 571 | 703 | } |
| 704 | + $ticket->unsetRelation('created_by_person'); | |
| 572 | 705 | } |
| 573 | 706 | |
| 574 | - return $ticket; | |
| 707 | + return self::hidePrivateTicketFields($ticket); | |
| 575 | 708 | } |
| 576 | 709 | |
| 577 | - 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) | |
| 578 | 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 | + | |
| 579 | 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([ | |
| 580 | 748 | 'object_id' => $conversationID, |
| 581 | 749 | 'key' => 'agent_feedback_ratings', |
| 582 | - ])->first(); | |
| 750 | + 'object_type' => 'conversation_meta', | |
| 751 | + 'value' => $approvalStatus, | |
| 752 | + ]); | |
| 583 | 753 | |
| 584 | - if ($existingAgentFeedback) { | |
| 585 | - return $this->updateExistingFeedback($existingAgentFeedback, $approvalStatus); | |
| 586 | - } else { | |
| 587 | - $agentFeedback = Meta::create([ | |
| 588 | - 'object_id' => $conversationID, | |
| 589 | - 'key' => 'agent_feedback_ratings', | |
| 590 | - 'object_type' => 'conversation_meta', | |
| 591 | - 'value' => $approvalStatus, | |
| 592 | - ]); | |
| 593 | - 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; | |
| 594 | 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); | |
| 595 | 803 | } |
| 596 | 804 | |
| 597 | 805 | private function updateExistingFeedback($existingAgentFeedback, $approvalStatus) |
| 598 | 806 | { |