| @@ -3,24 +3,22 @@ | ||
| 3 | 3 | namespace FluentSupport\App\Services\Tickets; |
| 4 | 4 | |
| 5 | 5 | use FluentSupport\App\Models\Attachment; |
| 6 | 6 | use FluentSupport\App\Models\Conversation; |
| 7 | -use FluentSupport\App\Modules\PermissionManager; | |
| 7 | +use FluentSupport\App\Models\MailBox; | |
| 8 | +use FluentSupport\App\Models\Ticket; | |
| 8 | 9 | use FluentSupport\App\Services\Helper; |
| 9 | 10 | use FluentSupport\App\Services\Includes\UploadService; |
| 10 | -use FluentSupport\App\Services\TicketHelper; | |
| 11 | -use FluentSupport\App\Services\TicketQueryService; | |
| 12 | 11 | use FluentSupport\Framework\Support\Arr; |
| 13 | 12 | |
| 14 | 13 | class TicketService |
| 15 | 14 | { |
| 16 | 15 | /** |
| 17 | - * this function will set the ticket status as closed | |
| 18 | - * @param $ticket | |
| 19 | - * @param $person | |
| 16 | + * @param \FluentSupport\App\Models\Ticket $ticket | |
| 17 | + * @param \FluentSupport\App\Models\Person $person | |
| 20 | 18 | * @param string $internalNote |
| 21 | - * @param bool $silently | |
| 22 | - * @return mixed | |
| 19 | + * @param string $silently 'yes' skips the close hooks. | |
| 20 | + * @return \FluentSupport\App\Models\Ticket | |
| 23 | 21 | */ |
| 24 | 22 | |
| 25 | 23 | public function close($ticket, $person, $internalNote = '', $silently = 'no') |
| 26 | 24 | { |
| @@ -39,9 +37,8 @@ | ||
| 39 | 37 | if (!$internalNote) { |
| 40 | 38 | $internalNote = __('Ticket has been closed', 'fluent-support'); |
| 41 | 39 | } |
| 42 | 40 | |
| 43 | - //Keep track in conversation | |
| 44 | 41 | Conversation::create([ |
| 45 | 42 | 'ticket_id' => $ticket->id, |
| 46 | 43 | 'person_id' => $person->id, |
| 47 | 44 | 'conversation_type' => 'internal_info', |
| @@ -51,24 +48,24 @@ | ||
| 51 | 48 | |
| 52 | 49 | return $ticket; |
| 53 | 50 | } |
| 54 | 51 | |
| 55 | - 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) | |
| 56 | 57 | { |
| 57 | 58 | if ($ticket->status == 'closed') { |
| 58 | 59 | $ticket->status = 'active'; |
| 59 | 60 | $ticket->waiting_since = current_time('mysql'); |
| 61 | + $ticket->resolved_at = null; | |
| 60 | 62 | $ticket->save(); |
| 61 | 63 | |
| 62 | - /* | |
| 63 | - * Action on ticket reopen | |
| 64 | - * | |
| 65 | - * @since v1.0.0 | |
| 66 | - * @param object $ticket | |
| 67 | - * @param object $person | |
| 68 | - */ | |
| 69 | - do_action('fluent_support/ticket_reopen', $ticket, $person); | |
| 70 | - do_action('fluent_support/ticket_reopen_by_' . $person->person_type, $ticket, $person); | |
| 64 | + if (!$deferHooks) { | |
| 65 | + $this->fireReopenHooks($ticket, $person); | |
| 66 | + } | |
| 67 | + | |
| 71 | 68 | Conversation::create([ |
| 72 | 69 | 'ticket_id' => $ticket->id, |
| 73 | 70 | 'person_id' => $person->id, |
| 74 | 71 | 'conversation_type' => 'internal_info', |
| @@ -75,11 +72,24 @@ | ||
| 75 | 72 | 'content' => __('Ticket has been reopened', 'fluent-support') |
| 76 | 73 | ]); |
| 77 | 74 | } |
| 78 | 75 | |
| 79 | - return $person; | |
| 76 | + return $ticket; | |
| 80 | 77 | } |
| 81 | 78 | |
| 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 | + | |
| 82 | 92 | public function onAgentChange($ticket, $person) |
| 83 | 93 | { |
| 84 | 94 | do_action('fluent_support/ticket_agent_change', $ticket, $person); |
| 85 | 95 | Conversation::create([ |
| @@ -85,11 +95,13 @@ | ||
| 85 | 95 | Conversation::create([ |
| 86 | 96 | 'ticket_id' => $ticket->id, |
| 87 | 97 | 'person_id' => $person->id, |
| 88 | 98 | 'conversation_type' => 'internal_info', |
| 89 | - 'content' => $ticket->agent->user_id !== $person->user_id ? | |
| 90 | - $person->full_name . __(' assigned ') . $ticket->agent->full_name . __(' in this ticket', 'fluent-support') : | |
| 91 | - $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) | |
| 92 | 104 | ]); |
| 93 | 105 | |
| 94 | 106 | return $person; |
| 95 | 107 | } |
| @@ -94,107 +106,35 @@ | ||
| 94 | 106 | return $person; |
| 95 | 107 | } |
| 96 | 108 | |
| 97 | 109 | /** |
| 98 | - * This `getTickets` method will return the all tickets | |
| 99 | - * @param array $data This is the data that will be used to filter the tickets | |
| 100 | - * @param string $filterType This is the type of filter that will be used to filter the tickets | |
| 101 | - * @return array $tickets | |
| 102 | - */ | |
| 103 | - public function getTickets($data, $filterType) | |
| 104 | - { | |
| 105 | - $queryArgs = $this->prepareQuery($data, $filterType); | |
| 106 | - $tickets = $this->getTicketsByQuery($queryArgs); | |
| 107 | - | |
| 108 | - foreach ($tickets as $ticket) { | |
| 109 | - if (Arr::get($data, 'per_page') < 15) { | |
| 110 | - if ($ticket->status != 'closed') { | |
| 111 | - $ticket->live_activity = TicketHelper::getActivity($ticket->id); | |
| 112 | - } else { | |
| 113 | - $ticket->live_activity = []; | |
| 114 | - } | |
| 115 | - } | |
| 116 | - } | |
| 117 | - | |
| 118 | - return [ | |
| 119 | - 'tickets' => $tickets | |
| 120 | - ]; | |
| 121 | - } | |
| 122 | - | |
| 123 | - /** | |
| 124 | - * This is a supporting method for getTickets method | |
| 125 | - * it prepares the query arguments for tickets filtering | |
| 110 | + * Move the ticket's inline images and pending uploads into the ticket folder. | |
| 111 | + * | |
| 126 | 112 | * @param array $data |
| 127 | - * @param string $filterType | |
| 128 | - * @return array | |
| 129 | - */ | |
| 130 | - private function prepareQuery($data, $filterType) | |
| 131 | - { | |
| 132 | - $queryArgs = [ | |
| 133 | - 'with' => [], | |
| 134 | - 'filter_type' => $filterType, | |
| 135 | - 'sort_by' => sanitize_sql_orderby(Arr::get($data, 'order_by', 'id')), | |
| 136 | - 'sort_type' => Arr::get($data, 'order_type', 'DESC') == 'DESC' ? 'DESC' : 'ASC', | |
| 137 | - ]; | |
| 138 | - | |
| 139 | - if ($filterType == 'advanced') { | |
| 140 | - //Get the selected query params for advanced filter | |
| 141 | - $queryArgs['filters_groups_raw'] = json_decode(Arr::get($data, 'advanced_filters', '[]'), true); | |
| 142 | - } else { | |
| 143 | - //Selected filter type is simple | |
| 144 | - $queryArgs['simple_filters'] = Arr::get($data, 'filters', []); | |
| 145 | - $queryArgs['search'] = trim(Arr::get($data, 'search', '')); | |
| 146 | - if ($customerId = Arr::get($data, 'customer_id')) { | |
| 147 | - $queryArgs['customer_id'] = $customerId; | |
| 148 | - } | |
| 149 | - } | |
| 150 | - | |
| 151 | - return $queryArgs; | |
| 152 | - } | |
| 153 | - | |
| 154 | - // This is a supporting method for getTickets method | |
| 155 | - // it returns the tickets by query arguments | |
| 156 | - private function getTicketsByQuery($queryArgs) | |
| 157 | - { | |
| 158 | - $ticketsModel = (new TicketQueryService($queryArgs))->getModel(); | |
| 159 | - | |
| 160 | - $ticketsModel = $ticketsModel->with([ | |
| 161 | - 'customer' => function ($query) { | |
| 162 | - $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']); | |
| 163 | - }, 'agent' => function ($query) { | |
| 164 | - $query->select(['first_name', 'last_name', 'id']); | |
| 165 | - }, | |
| 166 | - 'mailbox', | |
| 167 | - 'product', | |
| 168 | - 'tags', | |
| 169 | - 'preview_response' => function ($query) { | |
| 170 | - $query->latest('id'); | |
| 171 | - } | |
| 172 | - ]); | |
| 173 | - | |
| 174 | - | |
| 175 | - // apply filters by access level | |
| 176 | - do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]); | |
| 177 | - | |
| 178 | - return $ticketsModel->paginate(); | |
| 179 | - } | |
| 180 | - | |
| 181 | - /** | |
| 182 | - * This `addTicketAttachments` method is responsible for adding attachments to ticket | |
| 183 | - * @param array $data | |
| 184 | 113 | * @param array $disabledFields |
| 185 | 114 | * @param \FluentSupport\App\Models\Ticket $ticket |
| 186 | 115 | * @param object $customer |
| 116 | + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's. | |
| 187 | 117 | * @return \FluentSupport\App\Models\Ticket $ticket |
| 188 | 118 | * @since 1.5.7 |
| 189 | 119 | */ |
| 190 | - public static function addTicketAttachments($data, $disabledFields, $ticket, $customer) | |
| 120 | + public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false) | |
| 191 | 121 | { |
| 192 | - Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create'); | |
| 122 | + Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id); | |
| 193 | 123 | |
| 194 | 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 | + | |
| 195 | 133 | $attachments = Attachment::whereIn('file_hash', $attachmentsHashes) |
| 196 | 134 | ->where('status', 'in-active') |
| 135 | + ->whereNull('ticket_id') | |
| 136 | + ->whereIn('person_id', $uploaderIds) | |
| 197 | 137 | ->get(); |
| 198 | 138 | |
| 199 | 139 | if ($attachments->isEmpty()) { |
| 200 | 140 | return $ticket; |
| @@ -229,40 +169,194 @@ | ||
| 229 | 169 | return $ticket; |
| 230 | 170 | } |
| 231 | 171 | |
| 232 | 172 | /** |
| 233 | - * This `delete` method is responsible for deleting the ticket by ticket id | |
| 234 | - * @param int $id | |
| 235 | - * @return array | |
| 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 | |
| 236 | 179 | */ |
| 237 | - public function delete($ticket) | |
| 180 | + public function storeTicket($ticketData, $customer) | |
| 238 | 181 | { |
| 239 | - $deletePermission = PermissionManager::currentUserCan('fst_delete_tickets'); | |
| 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 | + | |
| 240 | 229 | $agent = Helper::getAgentByUserId(); |
| 241 | - if (!$deletePermission && $ticket->agent_id != $agent->id) { | |
| 242 | - throw new \Exception(esc_html__('You are not allowed to delete this ticket', 'fluent-support')); | |
| 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(); | |
| 243 | 312 | } |
| 244 | 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 | + | |
| 245 | 352 | $ticketData = [ |
| 246 | 353 | 'id' => $ticket->id, |
| 247 | 354 | 'title' => $ticket->title |
| 248 | 355 | ]; |
| 356 | + | |
| 249 | 357 | do_action('fluent_support/deleting_ticket', $ticket); |
| 250 | 358 | $ticket->delete(); |
| 251 | 359 | do_action('fluent_support/ticket_deleted', $agent, $ticketData); |
| 252 | - | |
| 253 | - return [ | |
| 254 | - 'message' => __('Ticket has been deleted successfully', 'fluent-support') | |
| 255 | - ]; | |
| 256 | 360 | } |
| 257 | 361 | |
| 258 | - public function deleteTickets($tickets) | |
| 259 | - { | |
| 260 | - $tickets->each(function ($ticket) { | |
| 261 | - $this->delete($ticket); | |
| 262 | - }); | |
| 263 | - | |
| 264 | - return [ | |
| 265 | - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support') | |
| 266 | - ]; | |
| 267 | - } | |
| 268 | 362 | } |