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