| @@ -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> </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 | + * | |
| 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 | { |