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 +133 -32 2.2.1 → 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,24 +48,24 @@
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');
61 + $ticket->resolved_at = null;
59 62 $ticket->save();
60 63
61 - /*
62 - * Action on ticket reopen
63 - *
64 - * @since v1.0.0
65 - * @param object $ticket
66 - * @param object $person
67 - */
68 - do_action('fluent_support/ticket_reopen', $ticket, $person);
69 - do_action('fluent_support/ticket_reopen_by_' . $person->person_type, $ticket, $person);
64 + if (!$deferHooks) {
65 + $this->fireReopenHooks($ticket, $person);
66 + }
67 +
70 68 Conversation::create([
71 69 'ticket_id' => $ticket->id,
72 70 'person_id' => $person->id,
73 71 'conversation_type' => 'internal_info',
@@ -77,8 +75,21 @@
77 75
78 76 return $ticket;
79 77 }
80 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 +
81 92 public function onAgentChange($ticket, $person)
82 93 {
83 94 do_action('fluent_support/ticket_agent_change', $ticket, $person);
84 95 Conversation::create([
@@ -84,11 +95,13 @@
84 95 Conversation::create([
85 96 'ticket_id' => $ticket->id,
86 97 'person_id' => $person->id,
87 98 'conversation_type' => 'internal_info',
88 - 'content' => $ticket->agent->user_id !== $person->user_id ?
89 - $person->full_name . __(' assigned ', 'fluent-support') . $ticket->agent->full_name . __(' in this ticket', 'fluent-support') :
90 - $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)
91 104 ]);
92 105
93 106 return $person;
94 107 }
@@ -93,23 +106,35 @@
93 106 return $person;
94 107 }
95 108
96 109 /**
97 - * 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 + *
98 112 * @param array $data
99 113 * @param array $disabledFields
100 114 * @param \FluentSupport\App\Models\Ticket $ticket
101 115 * @param object $customer
116 + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
102 117 * @return \FluentSupport\App\Models\Ticket $ticket
103 118 * @since 1.5.7
104 119 */
105 - public static function addTicketAttachments($data, $disabledFields, $ticket, $customer)
120 + public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false)
106 121 {
107 122 Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id);
108 123
109 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 +
110 133 $attachments = Attachment::whereIn('file_hash', $attachmentsHashes)
111 134 ->where('status', 'in-active')
135 + ->whereNull('ticket_id')
136 + ->whereIn('person_id', $uploaderIds)
112 137 ->get();
113 138
114 139 if ($attachments->isEmpty()) {
115 140 return $ticket;
@@ -144,10 +169,10 @@
144 169 return $ticket;
145 170 }
146 171
147 172 /**
148 - * Sanitize ticket data, create the ticket, handle attachments & custom fields, fire hooks.
149 - * 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.
150 175 *
151 176 * @param array $ticketData
152 177 * @param \FluentSupport\App\Models\Customer $customer
153 178 * @return \FluentSupport\App\Models\Ticket
@@ -162,14 +187,20 @@
162 187 } else {
163 188 $mailbox = MailBox::findOrFail($ticketData['mailbox_id']);
164 189 }
165 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 +
166 196 if (!empty($ticketData['product_id'])) {
167 197 $ticketData['product_source'] = 'local';
168 198 }
169 199
170 - $ticketData['title'] = sanitize_text_field(wp_unslash($ticketData['title']));
171 - $ticketData['content'] = wp_specialchars_decode(wp_unslash(wp_kses_post($ticketData['content'])));
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']);
172 203
173 204 if (!empty($ticketData['priority'])) {
174 205 $ticketData['priority'] = sanitize_text_field($ticketData['priority']);
175 206 }
@@ -179,13 +210,17 @@
179 210 }
180 211
181 212 $ticketData = apply_filters('fluent_support/create_ticket_data', $ticketData, $customer);
182 213
214 + // Check again: a filter may have routed the ticket into a restricted box.
215 + $this->assertMailboxAllowed($ticketData, $restrictedBoxes);
216 +
183 217 do_action('fluent_support/before_ticket_create', $ticketData, $customer);
184 218
185 219 $createdTicket = Ticket::create($ticketData);
186 220
187 221 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
222 + $disabledFields = array_diff($disabledFields, ['file_upload']);
188 223 self::addTicketAttachments($ticketData, $disabledFields, $createdTicket, $customer);
189 224
190 225 if (defined('FLUENTSUPPORTPRO') && !empty($ticketData['custom_fields'])) {
191 226 $createdTicket->syncCustomFields($ticketData['custom_fields']);
@@ -196,24 +231,43 @@
196 231 $isAgentInitiated = Arr::get($ticketData, 'agent_initiated') === 'yes';
197 232 $createdTicket->created_by = $agent->id;
198 233
199 234 if ($isAgentInitiated) {
200 - // Skip all ticket creation emails for agent-initiated tickets
201 - add_filter('fluent_support/should_send_notification', function ($shouldSend, $channel, $type) {
202 - 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)) {
203 242 return false;
204 243 }
205 244 return $shouldSend;
206 - }, 10, 3);
245 + };
246 + add_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10, 4);
207 247
208 - $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;
209 251
210 - // Agent response: actual ticket content — sends reply email to customer
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 +
211 264 $agentResponse = Conversation::create([
212 265 'ticket_id' => $createdTicket->id,
213 266 'person_id' => $agent->id,
214 267 'conversation_type' => 'response',
215 - 'content' => $createdTicket->content
268 + 'content' => $responseContent,
269 + 'source' => $channel,
216 270 ]);
217 271
218 272 if ($attachmentHashes = Arr::get($ticketData, 'attachments', [])) {
219 273 Attachment::where('ticket_id', $createdTicket->id)
@@ -226,8 +280,29 @@
226 280
227 281 $agentResponse->load('attachments');
228 282 }
229 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 +
230 305 do_action('fluent_support/agent_initiated_ticket_response', $agentResponse, $createdTicket, $agent);
231 306
232 307 $createdTicket->content = $initializedMessage;
233 308
@@ -238,11 +313,37 @@
238 313
239 314 do_action('fluent_support/ticket_created', $createdTicket, $customer);
240 315 do_action('fluent_support/ticket_created_behalf_of_customer', $createdTicket, $customer, $agent);
241 316
317 + if (!empty($suppressCreatedEmails)) {
318 + remove_filter('fluent_support/should_send_notification', $suppressCreatedEmails, 10);
319 + }
320 +
242 321 return $createdTicket;
243 322 }
244 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).
245 346 public function deleteTicket($ticket, $agent = null)
246 347 {
247 348 if (!$agent) {
248 349 $agent = Helper::getAgentByUserId();