| @@ -1,13 +1,27 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentSupport\App\Services\Tickets; |
| 4 | 4 | |
| 5 | +use FluentSupport\App\Models\Attachment; | |
| 5 | 6 | use FluentSupport\App\Models\Conversation; |
| 7 | +use FluentSupport\App\Models\MailBox; | |
| 8 | +use FluentSupport\App\Models\Ticket; | |
| 9 | +use FluentSupport\App\Services\Helper; | |
| 10 | +use FluentSupport\App\Services\Includes\UploadService; | |
| 11 | +use FluentSupport\Framework\Support\Arr; | |
| 6 | 12 | |
| 7 | 13 | class TicketService |
| 8 | 14 | { |
| 9 | - public function close($ticket, $person, $internalNote = '') | |
| 15 | + /** | |
| 16 | + * @param \FluentSupport\App\Models\Ticket $ticket | |
| 17 | + * @param \FluentSupport\App\Models\Person $person | |
| 18 | + * @param string $internalNote | |
| 19 | + * @param string $silently 'yes' skips the close hooks. | |
| 20 | + * @return \FluentSupport\App\Models\Ticket | |
| 21 | + */ | |
| 22 | + | |
| 23 | + public function close($ticket, $person, $internalNote = '', $silently = 'no') | |
| 10 | 24 | { |
| 11 | 25 | if ($ticket->status != 'closed') { |
| 12 | 26 | $ticket->status = 'closed'; |
| 13 | 27 | $ticket->resolved_at = current_time('mysql'); |
| @@ -13,55 +27,336 @@ | ||
| 13 | 27 | $ticket->resolved_at = current_time('mysql'); |
| 14 | 28 | $ticket->closed_by = $person->id; |
| 15 | 29 | $ticket->total_close_time = current_time('timestamp') - strtotime($ticket->created_at); |
| 16 | 30 | $ticket->save(); |
| 17 | - do_action('fluent_support/ticket_closed', $ticket, $person); | |
| 18 | - do_action('fluent_support/ticket_closed_by_' . $person->person_type, $ticket, $person); | |
| 19 | 31 | |
| 20 | - if(!$internalNote) { | |
| 32 | + if ('no' == $silently) { | |
| 33 | + do_action('fluent_support/ticket_closed', $ticket, $person); | |
| 34 | + do_action('fluent_support/ticket_closed_by_' . $person->person_type, $ticket, $person); | |
| 35 | + } | |
| 36 | + | |
| 37 | + if (!$internalNote) { | |
| 21 | 38 | $internalNote = __('Ticket has been closed', 'fluent-support'); |
| 22 | 39 | } |
| 23 | 40 | |
| 24 | 41 | Conversation::create([ |
| 25 | - 'ticket_id' => $ticket->id, | |
| 26 | - 'person_id' => $person->id, | |
| 42 | + 'ticket_id' => $ticket->id, | |
| 43 | + 'person_id' => $person->id, | |
| 27 | 44 | 'conversation_type' => 'internal_info', |
| 28 | - 'content' => $internalNote | |
| 45 | + 'content' => $internalNote | |
| 29 | 46 | ]); |
| 30 | - | |
| 31 | 47 | } |
| 32 | 48 | |
| 33 | 49 | return $ticket; |
| 34 | 50 | } |
| 35 | 51 | |
| 36 | - public function reopen($ticket, $person) | |
| 52 | + /** | |
| 53 | + * @param bool $deferHooks Skip the reopen hooks so a caller running inside a | |
| 54 | + * transaction can fire them after commit (fireReopenHooks()). | |
| 55 | + */ | |
| 56 | + public function reopen($ticket, $person, $deferHooks = false) | |
| 37 | 57 | { |
| 38 | 58 | if ($ticket->status == 'closed') { |
| 39 | 59 | $ticket->status = 'active'; |
| 40 | 60 | $ticket->waiting_since = current_time('mysql'); |
| 61 | + $ticket->resolved_at = null; | |
| 41 | 62 | $ticket->save(); |
| 42 | - do_action('fluent_support/ticket_reopen', $ticket, $person); | |
| 43 | - do_action('fluent_support/ticket_reopen_by_' . $person->person_type, $ticket, $person); | |
| 63 | + | |
| 64 | + if (!$deferHooks) { | |
| 65 | + $this->fireReopenHooks($ticket, $person); | |
| 66 | + } | |
| 67 | + | |
| 44 | 68 | Conversation::create([ |
| 45 | - 'ticket_id' => $ticket->id, | |
| 46 | - 'person_id' => $person->id, | |
| 69 | + 'ticket_id' => $ticket->id, | |
| 70 | + 'person_id' => $person->id, | |
| 47 | 71 | 'conversation_type' => 'internal_info', |
| 48 | - 'content' => __('Ticket has been reopened', 'fluent-support') | |
| 72 | + 'content' => __('Ticket has been reopened', 'fluent-support') | |
| 49 | 73 | ]); |
| 50 | 74 | } |
| 51 | 75 | |
| 52 | - return $person; | |
| 76 | + return $ticket; | |
| 53 | 77 | } |
| 54 | 78 | |
| 55 | - public function onAgentChange($ticket, $person){ | |
| 79 | + public function fireReopenHooks($ticket, $person) | |
| 80 | + { | |
| 81 | + /* | |
| 82 | + * Fires when a closed ticket is reopened. | |
| 83 | + * | |
| 84 | + * @since v1.0.0 | |
| 85 | + * @param object $ticket | |
| 86 | + * @param object $person | |
| 87 | + */ | |
| 88 | + do_action('fluent_support/ticket_reopen', $ticket, $person); | |
| 89 | + do_action('fluent_support/ticket_reopen_by_' . $person->person_type, $ticket, $person); | |
| 90 | + } | |
| 91 | + | |
| 92 | + public function onAgentChange($ticket, $person) | |
| 93 | + { | |
| 94 | + do_action('fluent_support/ticket_agent_change', $ticket, $person); | |
| 56 | 95 | Conversation::create([ |
| 57 | - 'ticket_id' => $ticket->id, | |
| 58 | - 'person_id' => $person->id, | |
| 96 | + 'ticket_id' => $ticket->id, | |
| 97 | + 'person_id' => $person->id, | |
| 59 | 98 | 'conversation_type' => 'internal_info', |
| 60 | - 'content' => $ticket->agent->full_name !== $person->full_name ? | |
| 61 | - __($person->full_name .' assign '. $ticket->agent->full_name .' in this ticket' , 'fluent-support') : | |
| 62 | - __($person->full_name. ' assign this ticket to self', 'fluent-support') | |
| 99 | + 'content' => $ticket->agent->user_id !== $person->user_id | |
| 100 | + /* translators: %1$s is the name of the person who assigned the ticket, %2$s is the assigned agent's name */ | |
| 101 | + ? sprintf(__('%1$s assigned %2$s to this ticket', 'fluent-support'), $person->full_name, $ticket->agent->full_name) | |
| 102 | + /* translators: %s is the agent's name */ | |
| 103 | + : sprintf(__('%s assigned this ticket to themselves', 'fluent-support'), $person->full_name) | |
| 63 | 104 | ]); |
| 64 | 105 | |
| 65 | 106 | return $person; |
| 66 | 107 | } |
| 108 | + | |
| 109 | + /** | |
| 110 | + * Move the ticket's inline images and pending uploads into the ticket folder. | |
| 111 | + * | |
| 112 | + * @param array $data | |
| 113 | + * @param array $disabledFields | |
| 114 | + * @param \FluentSupport\App\Models\Ticket $ticket | |
| 115 | + * @param object $customer | |
| 116 | + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's. | |
| 117 | + * @return \FluentSupport\App\Models\Ticket $ticket | |
| 118 | + * @since 1.5.7 | |
| 119 | + */ | |
| 120 | + public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false) | |
| 121 | + { | |
| 122 | + Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id); | |
| 123 | + | |
| 124 | + if (($attachmentsHashes = Arr::get($data, 'attachments')) && !in_array('file_upload', $disabledFields)) { | |
| 125 | + $uploader = Helper::getCurrentAgent() ?: $customer; | |
| 126 | + $uploaderIds = [$uploader->id]; | |
| 127 | + | |
| 128 | + // A remote portal acting on_behalf uploads as the customer it files for. | |
| 129 | + if ($onBehalf) { | |
| 130 | + $uploaderIds[] = $customer->id; | |
| 131 | + } | |
| 132 | + | |
| 133 | + $attachments = Attachment::whereIn('file_hash', $attachmentsHashes) | |
| 134 | + ->where('status', 'in-active') | |
| 135 | + ->whereNull('ticket_id') | |
| 136 | + ->whereIn('person_id', $uploaderIds) | |
| 137 | + ->get(); | |
| 138 | + | |
| 139 | + if ($attachments->isEmpty()) { | |
| 140 | + return $ticket; | |
| 141 | + } | |
| 142 | + | |
| 143 | + $storageDriver = Helper::getUploadDriverKey(); | |
| 144 | + | |
| 145 | + if ($storageDriver != 'local') { | |
| 146 | + foreach ($attachments as $attachment) { | |
| 147 | + do_action_ref_array('fluent_support/finalize_file_upload_' . $storageDriver, [&$attachment, $ticket->id]); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + foreach ($attachments as $attachment) { | |
| 152 | + if ($attachment->driver == 'local') { | |
| 153 | + $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticket->id); | |
| 154 | + if ($newFileInfo) { | |
| 155 | + $attachment->file_path = $newFileInfo['file_path']; | |
| 156 | + $attachment->full_url = $newFileInfo['url']; | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + $attachment->ticket_id = $ticket->id; | |
| 161 | + $attachment->person_id = $customer->id; | |
| 162 | + $attachment->status = 'active'; | |
| 163 | + $attachment->save(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + $ticket->load('attachments'); | |
| 167 | + } | |
| 168 | + | |
| 169 | + return $ticket; | |
| 170 | + } | |
| 171 | + | |
| 172 | + /** | |
| 173 | + * Sanitize and create the ticket, then handle attachments, custom fields and hooks. | |
| 174 | + * Used by TicketController::createTicket and ProTicketService::handleSplitTicket. | |
| 175 | + * | |
| 176 | + * @param array $ticketData | |
| 177 | + * @param \FluentSupport\App\Models\Customer $customer | |
| 178 | + * @return \FluentSupport\App\Models\Ticket | |
| 179 | + */ | |
| 180 | + public function storeTicket($ticketData, $customer) | |
| 181 | + { | |
| 182 | + if (empty($ticketData['mailbox_id'])) { | |
| 183 | + $mailbox = Helper::getDefaultMailBox(); | |
| 184 | + if ($mailbox) { | |
| 185 | + $ticketData['mailbox_id'] = $mailbox->id; | |
| 186 | + } | |
| 187 | + } else { | |
| 188 | + $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); | |
| 189 | + } | |
| 190 | + | |
| 191 | + // Empty when no agent is logged in, so cron, email piping and API callers pass. | |
| 192 | + $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds(); | |
| 193 | + | |
| 194 | + $this->assertMailboxAllowed($ticketData, $restrictedBoxes); | |
| 195 | + | |
| 196 | + if (!empty($ticketData['product_id'])) { | |
| 197 | + $ticketData['product_source'] = 'local'; | |
| 198 | + } | |
| 199 | + | |
| 200 | + // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost. | |
| 201 | + $ticketData['title'] = sanitize_text_field($ticketData['title']); | |
| 202 | + $ticketData['content'] = wp_kses_post($ticketData['content']); | |
| 203 | + | |
| 204 | + if (!empty($ticketData['priority'])) { | |
| 205 | + $ticketData['priority'] = sanitize_text_field($ticketData['priority']); | |
| 206 | + } | |
| 207 | + | |
| 208 | + if (!empty($ticketData['client_priority'])) { | |
| 209 | + $ticketData['client_priority'] = sanitize_text_field($ticketData['client_priority']); | |
| 210 | + } | |
| 211 | + | |
| 212 | + $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer); | |
| 213 | + | |
| 214 | + // Check again: a filter may have routed the ticket into a restricted box. | |
| 215 | + $this->assertMailboxAllowed($ticketData, $restrictedBoxes); | |
| 216 | + | |
| 217 | + do_action('fluent_support/before_ticket_create', $ticketData, $customer); | |
| 218 | + | |
| 219 | + $createdTicket = Ticket::create($ticketData); | |
| 220 | + | |
| 221 | + $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []); | |
| 222 | + $disabledFields = array_diff($disabledFields, ['file_upload']); | |
| 223 | + self::addTicketAttachments($ticketData, $disabledFields, $createdTicket, $customer); | |
| 224 | + | |
| 225 | + if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) { | |
| 226 | + $createdTicket->syncCustomFields($ticketData['custom_fields']); | |
| 227 | + } | |
| 228 | + | |
| 229 | + $agent = Helper::getAgentByUserId(); | |
| 230 | + if ($agent) { | |
| 231 | + $isAgentInitiated = Arr::get($ticketData, 'agent_initiated') === 'yes'; | |
| 232 | + $createdTicket->created_by = $agent->id; | |
| 233 | + | |
| 234 | + if ($isAgentInitiated) { | |
| 235 | + // The agent's first response below sends its own email. Scoped to this | |
| 236 | + // ticket and removed after its creation hooks, so later tickets in the | |
| 237 | + // same request (MCP, imports, workflows) still get their emails. | |
| 238 | + $suppressCreatedEmails = function ($shouldSend, $channel, $type, $ticket = null) use ($createdTicket) { | |
| 239 | + if ($channel === 'email' | |
| 240 | + && $ticket && (int) $ticket->id === (int) $createdTicket->id | |
| 241 | + && in_array($type, ['ticket_created_email_to_customer', 'ticket_created_email_to_admin', 'ticket_created_by_agent_email_to_customer'], true)) { | |
| 242 | + return false; | |
| 243 | + } | |
| 244 | + return $shouldSend; | |
| 245 | + }; | |
| 246 | + add_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10, 4); | |
| 247 | + | |
| 248 | + // The channel it was sent from moves to the response; the ticket's source marks it as outreach. | |
| 249 | + $channel = $createdTicket->source ?: 'web'; | |
| 250 | + $createdTicket->source = Ticket::SOURCE_AGENT_INITIATED; | |
| 251 | + | |
| 252 | + // A placeholder only: both apps show the agent's response as the opening message. | |
| 253 | + /* translators: %s is the agent's name */ | |
| 254 | + $initializedMessage = sprintf(__('%s started this conversation', 'fluent-support'), $agent->full_name); | |
| 255 | + | |
| 256 | + $responseContent = apply_filters('fluent_support/parse_smartcode_data', $createdTicket->content, [ | |
| 257 | + 'customer' => $customer, | |
| 258 | + 'agent' => $agent, | |
| 259 | + 'ticket' => $createdTicket, | |
| 260 | + ]); | |
| 261 | + | |
| 262 | + $responseContent = wp_kses_post($responseContent); | |
| 263 | + | |
| 264 | + $agentResponse = Conversation::create([ | |
| 265 | + 'ticket_id' => $createdTicket->id, | |
| 266 | + 'person_id' => $agent->id, | |
| 267 | + 'conversation_type' => 'response', | |
| 268 | + 'content' => $responseContent, | |
| 269 | + 'source' => $channel, | |
| 270 | + ]); | |
| 271 | + | |
| 272 | + if ($attachmentHashes = Arr::get($ticketData, 'attachments', [])) { | |
| 273 | + Attachment::where('ticket_id', $createdTicket->id) | |
| 274 | + ->whereIn('file_hash', $attachmentHashes) | |
| 275 | + ->where('status', 'active') | |
| 276 | + ->update([ | |
| 277 | + 'person_id' => $agentResponse->person_id, | |
| 278 | + 'conversation_id' => $agentResponse->id | |
| 279 | + ]); | |
| 280 | + | |
| 281 | + $agentResponse->load('attachments'); | |
| 282 | + } | |
| 283 | + | |
| 284 | + // The agent's message is the first response, so the ticket must say so: | |
| 285 | + // the same counters ResponseService::createResponse() sets for an agent | |
| 286 | + // reply, otherwise the ticket lists as new/unanswered and the agent's | |
| 287 | + // next reply is measured as the first response. | |
| 288 | + $now = current_time('mysql'); | |
| 289 | + // Unset in memory when the column default supplied it on insert. | |
| 290 | + if (!$createdTicket->status || $createdTicket->status === 'new') { | |
| 291 | + $createdTicket->status = 'active'; | |
| 292 | + } | |
| 293 | + if (!$createdTicket->agent_id) { | |
| 294 | + $createdTicket->agent_id = $agent->id; | |
| 295 | + } | |
| 296 | + $createdTicket->response_count = (int) $createdTicket->response_count + 1; | |
| 297 | + $createdTicket->first_response_time = 0; | |
| 298 | + $createdTicket->last_agent_response = $now; | |
| 299 | + $createdTicket->waiting_since = $now; | |
| 300 | + | |
| 301 | + // The outreach email may render {{ticket.content}}, so expose the parsed body | |
| 302 | + // during the hook. The ticket itself stores the "initialized" message. | |
| 303 | + $createdTicket->content = $responseContent; | |
| 304 | + | |
| 305 | + do_action('fluent_support/agent_initiated_ticket_response', $agentResponse, $createdTicket, $agent); | |
| 306 | + | |
| 307 | + $createdTicket->content = $initializedMessage; | |
| 308 | + | |
| 309 | + } | |
| 310 | + | |
| 311 | + $createdTicket->save(); | |
| 312 | + } | |
| 313 | + | |
| 314 | + do_action('fluent_support/ticket_created', $createdTicket, $customer); | |
| 315 | + do_action('fluent_support/ticket_created_behalf_of_customer', $createdTicket, $customer, $agent); | |
| 316 | + | |
| 317 | + if (!empty($suppressCreatedEmails)) { | |
| 318 | + remove_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10); | |
| 319 | + } | |
| 320 | + | |
| 321 | + return $createdTicket; | |
| 322 | + } | |
| 323 | + | |
| 324 | + /** | |
| 325 | + * Block ticket creation in a business box the acting agent is restricted from. | |
| 326 | + * | |
| 327 | + * @param array $ticketData | |
| 328 | + * @param array $restrictedBoxes Already int-normalized by AgentTicketAccess | |
| 329 | + * @return void | |
| 330 | + * @throws \Exception | |
| 331 | + */ | |
| 332 | + protected function assertMailboxAllowed($ticketData, $restrictedBoxes) | |
| 333 | + { | |
| 334 | + if (empty($restrictedBoxes) || empty($ticketData['mailbox_id'])) { | |
| 335 | + return; | |
| 336 | + } | |
| 337 | + | |
| 338 | + if (in_array((int) $ticketData['mailbox_id'], $restrictedBoxes, true)) { | |
| 339 | + throw new \Exception( | |
| 340 | + esc_html__('Sorry, you do not have permission to create a ticket in this business box', 'fluent-support') | |
| 341 | + ); | |
| 342 | + } | |
| 343 | + } | |
| 344 | + | |
| 345 | + // No auth check here: callers handle it, and it also runs with no current user (API, GDPR erasure, cron). | |
| 346 | + public function deleteTicket($ticket, $agent = null) | |
| 347 | + { | |
| 348 | + if (!$agent) { | |
| 349 | + $agent = Helper::getAgentByUserId(); | |
| 350 | + } | |
| 351 | + | |
| 352 | + $ticketData = [ | |
| 353 | + 'id' => $ticket->id, | |
| 354 | + 'title' => $ticket->title | |
| 355 | + ]; | |
| 356 | + | |
| 357 | + do_action('fluent_support/deleting_ticket', $ticket); | |
| 358 | + $ticket->delete(); | |
| 359 | + do_action('fluent_support/ticket_deleted', $agent, $ticketData); | |
| 360 | + } | |
| 361 | + | |
| 67 | 362 | } |