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 +225 -131 1.10.0 → 2.4.5 View file →
@@ -3,24 +3,22 @@
3 3 namespace FluentSupport\App\Services\Tickets;
4 4
5 5 use FluentSupport\App\Models\Attachment;
6 6 use FluentSupport\App\Models\Conversation;
7 -use FluentSupport\App\Modules\PermissionManager;
7 +use FluentSupport\App\Models\MailBox;
8 +use FluentSupport\App\Models\Ticket;
8 9 use FluentSupport\App\Services\Helper;
9 10 use FluentSupport\App\Services\Includes\UploadService;
10 -use FluentSupport\App\Services\TicketHelper;
11 -use FluentSupport\App\Services\TicketQueryService;
12 11 use FluentSupport\Framework\Support\Arr;
13 12
14 13 class TicketService
15 14 {
16 15 /**
17 - * this function will set the ticket status as closed
18 - * @param $ticket
19 - * @param $person
16 + * @param \FluentSupport\App\Models\Ticket $ticket
17 + * @param \FluentSupport\App\Models\Person $person
20 18 * @param string $internalNote
21 - * @param bool $silently
22 - * @return mixed
19 + * @param string $silently 'yes' skips the close hooks.
20 + * @return \FluentSupport\App\Models\Ticket
23 21 */
24 22
25 23 public function close($ticket, $person, $internalNote = '', $silently = 'no')
26 24 {
@@ -39,9 +37,8 @@
39 37 if (!$internalNote) {
40 38 $internalNote = __('Ticket has been closed', 'fluent-support');
41 39 }
42 40
43 - //Keep track in conversation
44 41 Conversation::create([
45 42 'ticket_id' => $ticket->id,
46 43 'person_id' => $person->id,
47 44 'conversation_type' => 'internal_info',
@@ -51,24 +48,24 @@
51 48
52 49 return $ticket;
53 50 }
54 51
55 - 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)
56 57 {
57 58 if ($ticket->status == 'closed') {
58 59 $ticket->status = 'active';
59 60 $ticket->waiting_since = current_time('mysql');
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',
@@ -75,11 +72,24 @@
75 72 'content' => __('Ticket has been reopened', 'fluent-support')
76 73 ]);
77 74 }
78 75
79 - return $person;
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 ') . $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,107 +106,35 @@
94 106 return $person;
95 107 }
96 108
97 109 /**
98 - * This `getTickets` method will return the all tickets
99 - * @param array $data This is the data that will be used to filter the tickets
100 - * @param string $filterType This is the type of filter that will be used to filter the tickets
101 - * @return array $tickets
102 - */
103 - public function getTickets($data, $filterType)
104 - {
105 - $queryArgs = $this->prepareQuery($data, $filterType);
106 - $tickets = $this->getTicketsByQuery($queryArgs);
107 -
108 - foreach ($tickets as $ticket) {
109 - if (Arr::get($data, 'per_page') < 15) {
110 - if ($ticket->status != 'closed') {
111 - $ticket->live_activity = TicketHelper::getActivity($ticket->id);
112 - } else {
113 - $ticket->live_activity = [];
114 - }
115 - }
116 - }
117 -
118 - return [
119 - 'tickets' => $tickets
120 - ];
121 - }
122 -
123 - /**
124 - * This is a supporting method for getTickets method
125 - * it prepares the query arguments for tickets filtering
110 + * Move the ticket's inline images and pending uploads into the ticket folder.
111 + *
126 112 * @param array $data
127 - * @param string $filterType
128 - * @return array
129 - */
130 - private function prepareQuery($data, $filterType)
131 - {
132 - $queryArgs = [
133 - 'with' => [],
134 - 'filter_type' => $filterType,
135 - 'sort_by' => sanitize_sql_orderby(Arr::get($data, 'order_by', 'id')),
136 - 'sort_type' => Arr::get($data, 'order_type', 'DESC') == 'DESC' ? 'DESC' : 'ASC',
137 - ];
138 -
139 - if ($filterType == 'advanced') {
140 - //Get the selected query params for advanced filter
141 - $queryArgs['filters_groups_raw'] = json_decode(Arr::get($data, 'advanced_filters', '[]'), true);
142 - } else {
143 - //Selected filter type is simple
144 - $queryArgs['simple_filters'] = Arr::get($data, 'filters', []);
145 - $queryArgs['search'] = trim(Arr::get($data, 'search', ''));
146 - if ($customerId = Arr::get($data, 'customer_id')) {
147 - $queryArgs['customer_id'] = $customerId;
148 - }
149 - }
150 -
151 - return $queryArgs;
152 - }
153 -
154 - // This is a supporting method for getTickets method
155 - // it returns the tickets by query arguments
156 - private function getTicketsByQuery($queryArgs)
157 - {
158 - $ticketsModel = (new TicketQueryService($queryArgs))->getModel();
159 -
160 - $ticketsModel = $ticketsModel->with([
161 - 'customer' => function ($query) {
162 - $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
163 - }, 'agent' => function ($query) {
164 - $query->select(['first_name', 'last_name', 'id']);
165 - },
166 - 'mailbox',
167 - 'product',
168 - 'tags',
169 - 'preview_response' => function ($query) {
170 - $query->latest('id');
171 - }
172 - ]);
173 -
174 -
175 - // apply filters by access level
176 - do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$ticketsModel, false]);
177 -
178 - return $ticketsModel->paginate();
179 - }
180 -
181 - /**
182 - * This `addTicketAttachments` method is responsible for adding attachments to ticket
183 - * @param array $data
184 113 * @param array $disabledFields
185 114 * @param \FluentSupport\App\Models\Ticket $ticket
186 115 * @param object $customer
116 + * @param bool $onBehalf Filed by a remote portal for the customer; their uploads are the customer's.
187 117 * @return \FluentSupport\App\Models\Ticket $ticket
188 118 * @since 1.5.7
189 119 */
190 - public static function addTicketAttachments($data, $disabledFields, $ticket, $customer)
120 + public static function addTicketAttachments($data, $disabledFields, $ticket, $customer, $onBehalf = false)
191 121 {
192 - Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create');
122 + Helper::tempImageMoveUploadDir($ticket->id, 'ticket-create', null, $customer->id);
193 123
194 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 +
195 133 $attachments = Attachment::whereIn('file_hash', $attachmentsHashes)
196 134 ->where('status', 'in-active')
135 + ->whereNull('ticket_id')
136 + ->whereIn('person_id', $uploaderIds)
197 137 ->get();
198 138
199 139 if ($attachments->isEmpty()) {
200 140 return $ticket;
@@ -229,40 +169,194 @@
229 169 return $ticket;
230 170 }
231 171
232 172 /**
233 - * This `delete` method is responsible for deleting the ticket by ticket id
234 - * @param int $id
235 - * @return array
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
236 179 */
237 - public function delete($ticket)
180 + public function storeTicket($ticketData, $customer)
238 181 {
239 - $deletePermission = PermissionManager::currentUserCan('fst_delete_tickets');
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 +
240 229 $agent = Helper::getAgentByUserId();
241 - if (!$deletePermission && $ticket->agent_id != $agent->id) {
242 - throw new \Exception(esc_html__('You are not allowed to delete this ticket', 'fluent-support'));
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();
243 312 }
244 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 +
245 352 $ticketData = [
246 353 'id' => $ticket->id,
247 354 'title' => $ticket->title
248 355 ];
356 +
249 357 do_action('fluent_support/deleting_ticket', $ticket);
250 358 $ticket->delete();
251 359 do_action('fluent_support/ticket_deleted', $agent, $ticketData);
252 -
253 - return [
254 - 'message' => __('Ticket has been deleted successfully', 'fluent-support')
255 - ];
256 360 }
257 361
258 - public function deleteTickets($tickets)
259 - {
260 - $tickets->each(function ($ticket) {
261 - $this->delete($ticket);
262 - });
263 -
264 - return [
265 - 'message' => __(count($tickets) . ' tickets have been deleted', 'fluent-support')
266 - ];
267 - }
268 362 }