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
fluent-support / app / Services / Tickets / TicketService.php

TicketService.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/Tickets/TicketService.php

363 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services\Tickets;
4
5 use FluentSupport\App\Models\Attachment;
6 use FluentSupport\App\Models\Conversation;
7 use FluentSupport\App\Models\MailBox;
8 use FluentSupport\App\Models\Ticket;
9 use FluentSupport\App\Services\Helper;
10 use FluentSupport\App\Services\Includes\UploadService;
11 use FluentSupport\Framework\Support\Arr;
12
13 class TicketService
14 {
15 /**
16 * @param \FluentSupport\App\Models\Ticket $ticket
17 * @param \FluentSupport\App\Models\Person $person
18 * @param string $internalNote
19 * @param string $silently 'yes' skips the close hooks.
20 * @return \FluentSupport\App\Models\Ticket
21 */
22
23 public function close($ticket, $person, $internalNote = '', $silently = 'no')
24 {
25 if ($ticket->status != 'closed') {
26 $ticket->status = 'closed';
27 $ticket->resolved_at = current_time('mysql');
28 $ticket->closed_by = $person->id;
29 $ticket->total_close_time = current_time('timestamp') - strtotime($ticket->created_at);
30 $ticket->save();
31
32 if ('no' == $silently) {
33 do_action('fluent_support/ticket_closed', $ticket, $person);
34 do_action('fluent_support/ticket_closed_by_' . $person->person_type, $ticket, $person);
35 }
36
37 if (!$internalNote) {
38 $internalNote = __('Ticket has been closed', 'fluent-support');
39 }
40
41 Conversation::create([
42 'ticket_id' => $ticket->id,
43 'person_id' => $person->id,
44 'conversation_type' => 'internal_info',
45 'content' => $internalNote
46 ]);
47 }
48
49 return $ticket;
50 }
51
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)
57 {
58 if ($ticket->status == 'closed') {
59 $ticket->status = 'active';
60 $ticket->waiting_since = current_time('mysql');
61 $ticket->resolved_at = null;
62 $ticket->save();
63
64 if (!$deferHooks) {
65 $this->fireReopenHooks($ticket, $person);
66 }
67
68 Conversation::create([
69 'ticket_id' => $ticket->id,
70 'person_id' => $person->id,
71 'conversation_type' => 'internal_info',
72 'content' => __('Ticket has been reopened', 'fluent-support')
73 ]);
74 }
75
76 return $ticket;
77 }
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
92 public function onAgentChange($ticket, $person)
93 {
94 do_action('fluent_support/ticket_agent_change', $ticket, $person);
95 Conversation::create([
96 'ticket_id' => $ticket->id,
97 'person_id' => $person->id,
98 '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)
104 ]);
105
106 return $person;
107 }
108
109 /**
110 * Move the ticket's inline images and pending uploads into the ticket folder.
111 *
112 * @param array $data
113 * @param array $disabledFields
114 * @param \FluentSupport\App\Models\Ticket $ticket
115 * @param object $customer
116 * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
117 * @return \FluentSupport\App\Models\Ticket $ticket
118 * @since 1.5.7
119 */
120 public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false)
121 {
122 Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id);
123
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
133 $attachments = Attachment::whereIn('file_hash', $attachmentsHashes)
134 ->where('status', 'in-active')
135 ->whereNull('ticket_id')
136 ->whereIn('person_id', $uploaderIds)
137 ->get();
138
139 if ($attachments->isEmpty()) {
140 return $ticket;
141 }
142
143 $storageDriver = Helper::getUploadDriverKey();
144
145 if ($storageDriver != 'local') {
146 foreach ($attachments as $attachment) {
147 do_action_ref_array('fluent_support/finalize_file_upload_' . $storageDriver, [&$attachment, $ticket->id]);
148 }
149 }
150
151 foreach ($attachments as $attachment) {
152 if ($attachment->driver == 'local') {
153 $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticket->id);
154 if ($newFileInfo) {
155 $attachment->file_path = $newFileInfo['file_path'];
156 $attachment->full_url = $newFileInfo['url'];
157 }
158 }
159
160 $attachment->ticket_id = $ticket->id;
161 $attachment->person_id = $customer->id;
162 $attachment->status = 'active';
163 $attachment->save();
164 }
165
166 $ticket->load('attachments');
167 }
168
169 return $ticket;
170 }
171
172 /**
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
179 */
180 public function storeTicket($ticketData, $customer)
181 {
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
229 $agent = Helper::getAgentByUserId();
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();
312 }
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
352 $ticketData = [
353 'id' => $ticket->id,
354 'title' => $ticket->title
355 ];
356
357 do_action('fluent_support/deleting_ticket', $ticket);
358 $ticket->delete();
359 do_action('fluent_support/ticket_deleted', $agent, $ticketData);
360 }
361
362 }
363