PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Services/Tickets/TicketService.php +90 -46 2.4.0 → 2.4.5 View file →
@@ -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();