| @@ -12,14 +12,13 @@ | ||
| 12 | 12 | |
| 13 | 13 | class TicketService |
| 14 | 14 | { |
| 15 | 15 | /** |
| 16 | - * this function will set the ticket status as closed | |
| 17 | - * @param $ticket | |
| 18 | - * @param $person | |
| 16 | + * @param \FluentSupport\App\Models\Ticket $ticket | |
| 17 | + * @param \FluentSupport\App\Models\Person $person | |
| 19 | 18 | * @param string $internalNote |
| 20 | - * @param bool $silently | |
| 21 | - * @return mixed | |
| 19 | + * @param string $silently 'yes' skips the close hooks. | |
| 20 | + * @return \FluentSupport\App\Models\Ticket | |
| 22 | 21 | */ |
| 23 | 22 | |
| 24 | 23 | public function close($ticket, $person, $internalNote = '', $silently = 'no') |
| 25 | 24 | { |
| @@ -38,9 +37,8 @@ | ||
| 38 | 37 | if (!$internalNote) { |
| 39 | 38 | $internalNote = __('Ticket has been closed', 'fluent-support'); |
| 40 | 39 | } |
| 41 | 40 | |
| 42 | - //Keep track in conversation | |
| 43 | 41 | Conversation::create([ |
| 44 | 42 | 'ticket_id' => $ticket->id, |
| 45 | 43 | 'person_id' => $person->id, |
| 46 | 44 | 'conversation_type' => 'internal_info', |
| @@ -50,9 +48,13 @@ | ||
| 50 | 48 | |
| 51 | 49 | return $ticket; |
| 52 | 50 | } |
| 53 | 51 | |
| 54 | - 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) | |
| 55 | 57 | { |
| 56 | 58 | if ($ticket->status == 'closed') { |
| 57 | 59 | $ticket->status = 'active'; |
| 58 | 60 | $ticket->waiting_since = current_time('mysql'); |
| @@ -58,17 +60,12 @@ | ||
| 58 | 60 | $ticket->waiting_since = current_time('mysql'); |
| 59 | 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', |
| @@ -78,8 +75,21 @@ | ||
| 78 | 75 | |
| 79 | 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 ', 'fluent-support') . $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,27 +106,35 @@ | ||
| 94 | 106 | return $person; |
| 95 | 107 | } |
| 96 | 108 | |
| 97 | 109 | /** |
| 98 | - * This `addTicketAttachments` method is responsible for adding attachments to ticket | |
| 110 | + * Move the ticket's inline images and pending uploads into the ticket folder. | |
| 111 | + * | |
| 99 | 112 | * @param array $data |
| 100 | 113 | * @param array $disabledFields |
| 101 | 114 | * @param \FluentSupport\App\Models\Ticket $ticket |
| 102 | 115 | * @param object $customer |
| 116 | + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's. | |
| 103 | 117 | * @return \FluentSupport\App\Models\Ticket $ticket |
| 104 | 118 | * @since 1.5.7 |
| 105 | 119 | */ |
| 106 | - public static function addTicketAttachments($data, $disabledFields, $ticket, $customer) | |
| 120 | + public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false) | |
| 107 | 121 | { |
| 108 | 122 | Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id); |
| 109 | 123 | |
| 110 | 124 | if (($attachmentsHashes = Arr::get($data, 'attachments')) && !in_array('file_upload', $disabledFields)) { |
| 111 | 125 | $uploader = Helper::getCurrentAgent() ?: $customer; |
| 126 | + $uploaderIds = [$uploader->id]; | |
| 112 | 127 | |
| 128 | + // A remote portal acting on_behalf uploads as the customer it files for. | |
| 129 | + if ($onBehalf) { | |
| 130 | + $uploaderIds[] = $customer->id; | |
| 131 | + } | |
| 132 | + | |
| 113 | 133 | $attachments = Attachment::whereIn('file_hash', $attachmentsHashes) |
| 114 | 134 | ->where('status', 'in-active') |
| 115 | 135 | ->whereNull('ticket_id') |
| 116 | - ->where('person_id', $uploader->id) | |
| 136 | + ->whereIn('person_id', $uploaderIds) | |
| 117 | 137 | ->get(); |
| 118 | 138 | |
| 119 | 139 | if ($attachments->isEmpty()) { |
| 120 | 140 | return $ticket; |
| @@ -149,10 +169,10 @@ | ||
| 149 | 169 | return $ticket; |
| 150 | 170 | } |
| 151 | 171 | |
| 152 | 172 | /** |
| 153 | - * Sanitize ticket data, create the ticket, handle attachments & custom fields, fire hooks. | |
| 154 | - * Shared by TicketController::createTicket and ProTicketService::handleSplitTicket. | |
| 173 | + * Sanitize and create the ticket, then handle attachments, custom fields and hooks. | |
| 174 | + * Used by TicketController::createTicket and ProTicketService::handleSplitTicket. | |
| 155 | 175 | * |
| 156 | 176 | * @param array $ticketData |
| 157 | 177 | * @param \FluentSupport\App\Models\Customer $customer |
| 158 | 178 | * @return \FluentSupport\App\Models\Ticket |
| @@ -167,13 +187,11 @@ | ||
| 167 | 187 | } else { |
| 168 | 188 | $mailbox = MailBox::findOrFail($ticketData['mailbox_id']); |
| 169 | 189 | } |
| 170 | 190 | |
| 171 | - // The target box is caller-supplied. Returns [] when no agent is logged in, | |
| 172 | - // so cron/email piping/API callers are unaffected. | |
| 191 | + // Empty when no agent is logged in, so cron, email piping and API callers pass. | |
| 173 | 192 | $restrictedBoxes = (new AgentTicketAccess())->getRestrictedMailboxIds(); |
| 174 | 193 | |
| 175 | - // Reject an explicitly forbidden target up front, before any filter runs. | |
| 176 | 194 | $this->assertMailboxAllowed($ticketData, $restrictedBoxes); |
| 177 | 195 | |
| 178 | 196 | if (!empty($ticketData['product_id'])) { |
| 179 | 197 | $ticketData['product_source'] = 'local'; |
| @@ -178,12 +196,11 @@ | ||
| 178 | 196 | if (!empty($ticketData['product_id'])) { |
| 179 | 197 | $ticketData['product_source'] = 'local'; |
| 180 | 198 | } |
| 181 | 199 | |
| 182 | - // Input is already unslashed at the request boundary; sanitize only, so | |
| 183 | - // literal backslashes in the title and body survive. | |
| 200 | + // Already unslashed at the request boundary. Don't unslash again or literal backslashes are lost. | |
| 184 | 201 | $ticketData['title'] = sanitize_text_field($ticketData['title']); |
| 185 | - $ticketData['content'] = wp_specialchars_decode(wp_kses_post($ticketData['content'])); | |
| 202 | + $ticketData['content'] = wp_kses_post($ticketData['content']); | |
| 186 | 203 | |
| 187 | 204 | if (!empty($ticketData['priority'])) { |
| 188 | 205 | $ticketData['priority'] = sanitize_text_field($ticketData['priority']); |
| 189 | 206 | } |
| @@ -193,10 +210,9 @@ | ||
| 193 | 210 | } |
| 194 | 211 | |
| 195 | 212 | $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer); |
| 196 | 213 | |
| 197 | - // Re-check the final target: a routing filter may have moved the ticket into a | |
| 198 | - // box this agent cannot reach. This is the value that actually gets persisted. | |
| 214 | + // Check again: a filter may have routed the ticket into a restricted box. | |
| 199 | 215 | $this->assertMailboxAllowed($ticketData, $restrictedBoxes); |
| 200 | 216 | |
| 201 | 217 | do_action('fluent_support/before_ticket_create', $ticketData, $customer); |
| 202 | 218 | |
| @@ -215,18 +231,29 @@ | ||
| 215 | 231 | $isAgentInitiated = Arr::get($ticketData, 'agent_initiated') === 'yes'; |
| 216 | 232 | $createdTicket->created_by = $agent->id; |
| 217 | 233 | |
| 218 | 234 | if ($isAgentInitiated) { |
| 219 | - // Skip all ticket creation emails for agent-initiated tickets | |
| 220 | - add_filter('fluent_support/should_send_notification', function ($shouldSend, $channel, $type) { | |
| 221 | - if ($channel === 'email' && in_array($type, ['ticket_created_email_to_customer', 'ticket_created_email_to_admin', 'ticket_created_by_agent_email_to_customer'])) { | |
| 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)) { | |
| 222 | 242 | return false; |
| 223 | 243 | } |
| 224 | 244 | return $shouldSend; |
| 225 | - }, 10, 3); | |
| 245 | + }; | |
| 246 | + add_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10, 4); | |
| 226 | 247 | |
| 227 | - $initializedMessage = $agent->full_name . __(' initialized this ticket', 'fluent-support'); | |
| 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; | |
| 228 | 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 | + | |
| 229 | 256 | $responseContent = apply_filters('fluent_support/parse_smartcode_data', $createdTicket->content, [ |
| 230 | 257 | 'customer' => $customer, |
| 231 | 258 | 'agent' => $agent, |
| 232 | 259 | 'ticket' => $createdTicket, |
| @@ -231,17 +258,16 @@ | ||
| 231 | 258 | 'agent' => $agent, |
| 232 | 259 | 'ticket' => $createdTicket, |
| 233 | 260 | ]); |
| 234 | 261 | |
| 235 | - $responseContent = (new ResponseService())->maybeAppendSignature($responseContent, $agent); | |
| 236 | 262 | $responseContent = wp_kses_post($responseContent); |
| 237 | 263 | |
| 238 | - // Agent response: actual ticket content — sends reply email to customer | |
| 239 | 264 | $agentResponse = Conversation::create([ |
| 240 | 265 | 'ticket_id' => $createdTicket->id, |
| 241 | 266 | 'person_id' => $agent->id, |
| 242 | 267 | 'conversation_type' => 'response', |
| 243 | - 'content' => $responseContent | |
| 268 | + 'content' => $responseContent, | |
| 269 | + 'source' => $channel, | |
| 244 | 270 | ]); |
| 245 | 271 | |
| 246 | 272 | if ($attachmentHashes = Arr::get($ticketData, 'attachments', [])) { |
| 247 | 273 | Attachment::where('ticket_id', $createdTicket->id) |
| @@ -254,10 +280,27 @@ | ||
| 254 | 280 | |
| 255 | 281 | $agentResponse->load('attachments'); |
| 256 | 282 | } |
| 257 | 283 | |
| 258 | - // The outreach email may render {{ticket.content}}, so expose the parsed | |
| 259 | - // body while the hook runs, then restore the log message that gets persisted. | |
| 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. | |
| 260 | 303 | $createdTicket->content = $responseContent; |
| 261 | 304 | |
| 262 | 305 | do_action('fluent_support/agent_initiated_ticket_response', $agentResponse, $createdTicket, $agent); |
| 263 | 306 | |
| @@ -270,8 +313,12 @@ | ||
| 270 | 313 | |
| 271 | 314 | do_action('fluent_support/ticket_created', $createdTicket, $customer); |
| 272 | 315 | do_action('fluent_support/ticket_created_behalf_of_customer', $createdTicket, $customer, $agent); |
| 273 | 316 | |
| 317 | + if (!empty($suppressCreatedEmails)) { | |
| 318 | + remove_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10); | |
| 319 | + } | |
| 320 | + | |
| 274 | 321 | return $createdTicket; |
| 275 | 322 | } |
| 276 | 323 | |
| 277 | 324 | /** |
| @@ -276,11 +323,8 @@ | ||
| 276 | 323 | |
| 277 | 324 | /** |
| 278 | 325 | * Block ticket creation in a business box the acting agent is restricted from. |
| 279 | 326 | * |
| 280 | - * $restrictedBoxes is resolved once per create and passed in, so both the | |
| 281 | - * pre-filter and post-filter checks read the same agent meta. | |
| 282 | - * | |
| 283 | 327 | * @param array $ticketData |
| 284 | 328 | * @param array $restrictedBoxes Already int-normalized by AgentTicketAccess |
| 285 | 329 | * @return void |
| 286 | 330 | * @throws \Exception |
| @@ -297,9 +341,9 @@ | ||
| 297 | 341 | ); |
| 298 | 342 | } |
| 299 | 343 | } |
| 300 | 344 | |
| 301 | - // Callers own authorization — this also runs in system contexts (developer API, GDPR erasure, cron) with no current user. | |
| 345 | + // No auth check here: callers handle it, and it also runs with no current user (API, GDPR erasure, cron). | |
| 302 | 346 | public function deleteTicket($ticket, $agent = null) |
| 303 | 347 | { |
| 304 | 348 | if (!$agent) { |
| 305 | 349 | $agent = Helper::getAgentByUserId(); |