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/ResponseService.php +265 -34 1.4.0 → 2.4.5 View file →
@@ -1,48 +1,80 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Services\Tickets;
4 4
5 +use FluentSupport\App\Models\Agent;
5 6 use FluentSupport\App\Models\Attachment;
6 7 use FluentSupport\App\Models\Conversation;
8 +use FluentSupport\App\Services\Helper;
9 +use FluentSupport\App\Services\Includes\UploadService;
7 10 use FluentSupport\Framework\Support\Arr;
8 11
9 12 class ResponseService
10 13 {
11 - public function createResponse($data, $person, $ticket)
14 + /**
15 + * Add an agent or customer response to a ticket.
16 + *
17 + * @param array $data
18 + * @param \FluentSupport\App\Models\Person $person
19 + * @param \FluentSupport\App\Models\Ticket $ticket
20 + * @param bool $silently Skip the assignment/response/close hooks.
21 + * @return array|false
22 + */
23 + public function createResponse($data, $person, $ticket, $silently = false)
12 24 {
13 - if(empty($data['content'])) {
25 + if (empty($data['content'])) {
14 26 return false;
15 27 }
16 28
29 + $cc_emails = Arr::get($data, 'cc_emails', []);
30 +
31 + $informationalReply = Arr::get($data, 'informational_reply', false);
32 + if ($person->person_type == 'agent') {
33 + $data['content'] = apply_filters('fluent_support/parse_smartcode_data', $data['content'], [
34 + 'customer' => $ticket->customer,
35 + 'agent' => $person
36 + ]);
37 + }
38 +
17 39 $convoType = Arr::get($data, 'conversation_type', 'response');
18 40
41 + // Input is already unslashed (or was never slashed, for API/MCP callers).
42 + // Don't unslash again or literal backslashes are lost.
43 + $content = wp_kses_post($data['content']);
44 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
45 + $content = apply_filters('fluent_support/response_content_before_use_anywhere', $content);
19 46 $response = [
20 47 'person_id' => $person->id,
21 48 'ticket_id' => $ticket->id,
22 49 'conversation_type' => $convoType,
23 - 'content' => wp_unslash(wp_kses_post($data['content'])),
24 - 'source' => Arr::get($data, 'source', 'web')
50 + 'content' => $content,
51 + 'source' => Arr::get($data, 'source', 'web'),
52 + 'content_hash' => md5($content)
25 53 ];
26 54
27 - $response = apply_filters('fluent_support/new_'.$person->person_type.'_' . $convoType, $response, $ticket, $person);
55 + if (!empty($data['message_id'])) {
56 + $response['message_id'] = sanitize_text_field($data['message_id']);
57 + }
28 58
59 + $response = apply_filters('fluent_support/new_' . $person->person_type . '_' . $convoType, $response, $ticket, $person);
60 +
29 61 $createdResponse = \FluentSupport\App\Models\Conversation::create($response);
30 - $createdResponse->load('person');
31 62
32 - if ($attachments = Arr::get($data, 'attachments', [])) {
33 - Attachment::where('ticket_id', $ticket->id)
34 - ->whereIn('file_hash', $attachments)
35 - ->update([
36 - 'conversation_id' => $createdResponse->id,
37 - 'status' => 'active'
38 - ]);
39 - $createdResponse->load('attachments');
63 + if (!empty($cc_emails)) {
64 + $createdResponse->updateSettingsValue('cc_email', $cc_emails);
65 +
66 + // Keep a running list of every CC'd address on the ticket.
67 + $existingCcEmails = $ticket->getSettingsValue('all_cc_email', []);
68 + $newData = array_merge($existingCcEmails, $cc_emails);
69 + $ticket->updateSettingsValue('all_cc_email', array_unique($newData));
40 70 }
41 71
72 + $createdResponse->load('person');
73 +
42 74 if ($person->person_type == 'agent' && $ticket->status == 'new' && $convoType == 'response') {
43 75 $ticket->status = 'active';
44 - if($ticket->created_at) {
76 + if ($ticket->created_at) {
45 77 $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
46 78 } else {
47 79 $ticket->first_response_time = 300;
48 80 }
@@ -48,11 +80,12 @@
48 80 }
49 81 }
50 82
51 83 $agentAdded = false;
84 + $previousAgentId = (int) $ticket->agent_id;
52 85 $updateData = [];
53 86
54 - if($person->person_type == 'agent') {
87 + if ($person->person_type == 'agent') {
55 88 if (!$ticket->agent_id && $convoType == 'response') {
56 89 $ticket->agent_id = $person->id;
57 90 $agentAdded = true;
58 91 $updateData = [
@@ -59,15 +92,17 @@
59 92 'agent_id' => $person->id
60 93 ];
61 94 }
62 95
63 - if($convoType == 'response') {
64 - $ticket->last_agent_response = current_time('mysql');
65 - $ticket->waiting_since = current_time('mysql');
96 + if ($convoType == 'response') {
97 + if ($resetWaitingSince && !$informationalReply) {
98 + $ticket->last_agent_response = current_time('mysql');
99 + $ticket->waiting_since = current_time('mysql');
100 + }
66 101 }
67 102 } else {
68 103
69 - if($ticket->last_agent_response && strtotime($ticket->last_agent_response) > strtotime($ticket->last_customer_response)) {
104 + if ($ticket->last_agent_response && strtotime($ticket->last_agent_response) > strtotime($ticket->last_customer_response)) {
70 105 $ticket->waiting_since = current_time('mysql');
71 106 }
72 107
73 108 $ticket->last_customer_response = current_time('mysql');
@@ -72,9 +107,11 @@
72 107
73 108 $ticket->last_customer_response = current_time('mysql');
74 109 }
75 110
76 - $ticket->response_count += 1;
111 + if ($convoType == 'response') {
112 + $ticket->response_count += 1;
113 + }
77 114
78 115 $closed = false;
79 116 if (Arr::get($data, 'close_ticket') == 'yes' && $ticket->status != 'closed') {
80 117 $ticket->status = 'closed';
@@ -81,35 +118,229 @@
81 118 $ticket->resolved_at = current_time('mysql');
82 119 $ticket->closed_by = $person->id;
83 120 $ticket->total_close_time = current_time('timestamp') - strtotime($ticket->created_at);
84 121 $closed = true;
85 - Conversation::create([
86 - 'ticket_id' => $ticket->id,
87 - 'person_id' => $person->id,
88 - 'conversation_type' => 'internal_info',
89 - 'content' => __('Ticket has been closed', 'fluent-support')
90 - ]);
122 +
123 + $internalNote = __('Ticket has been closed', 'fluent-support');
124 +
125 + $internalNote = apply_filters('fluent_support/ticket_close_internal_note', $internalNote, $ticket);
126 +
127 + if ($internalNote) {
128 + Conversation::create([
129 + 'ticket_id' => $ticket->id,
130 + 'person_id' => $person->id,
131 + 'conversation_type' => 'internal_info',
132 + 'content' => $internalNote
133 + ]);
134 + }
91 135 }
92 136
93 137 $ticket->save();
94 138
139 + Helper::tempImageMoveUploadDir($ticket->id, 'conversation', $createdResponse->id, $person->id);
140 +
141 + // Finalize files uploaded ahead of the response (status in-active).
142 + if ($attachmentHashes = Arr::get($data, 'attachments', [])) {
143 + $attachments = Attachment::where('ticket_id', $ticket->id)
144 + ->whereIn('file_hash', $attachmentHashes)
145 + ->where('status', 'in-active')
146 + ->get();
147 +
148 + if (!$attachments->isEmpty()) {
149 + $storageDriver = Helper::getUploadDriverKey();
150 + foreach ($attachments as $attachment) {
151 + if ($storageDriver != 'local') {
152 + do_action_ref_array('fluent_support/finalize_file_upload_' . $storageDriver, [&$attachment, $ticket->id]);
153 + }
154 +
155 + if ($attachment->driver == 'local') {
156 + $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticket->id);
157 + if ($newFileInfo && !empty($newFileInfo['file_path'])) {
158 + $attachment->file_path = $newFileInfo['file_path'];
159 + $attachment->full_url = $newFileInfo['url'];
160 + }
161 + }
162 +
163 + $attachment->ticket_id = $ticket->id;
164 + $attachment->person_id = $createdResponse->person_id;
165 + $attachment->conversation_id = $createdResponse->id;
166 + $attachment->status = 'active';
167 + $attachment->save();
168 + }
169 + $createdResponse->load('attachments');
170 + }
171 + }
172 +
173 + if ($silently) {
174 + // The caller fires the hooks itself (fireResponseHooks()), e.g. after
175 + // its transaction commits; 'closed' / 'agent_added' tell it what happened.
176 + return [
177 + 'response' => $createdResponse,
178 + 'ticket' => $ticket,
179 + 'update_data' => $updateData,
180 + 'closed' => $closed,
181 + 'agent_added' => $agentAdded,
182 + 'previous_agent_id' => $previousAgentId,
183 + ];
184 + }
185 +
186 + $updateData = $this->fireResponseHooks($createdResponse, $ticket, $person, $closed, $agentAdded, $previousAgentId, $updateData);
187 +
188 + return [
189 + 'response' => $createdResponse,
190 + 'ticket' => $ticket,
191 + 'update_data' => $updateData
192 + ];
193 + }
194 +
195 + /**
196 + * Fire the hooks createResponse() fires after a response is stored.
197 + *
198 + * Split out so a caller that wrapped createResponse(..., $silently = true) in
199 + * a transaction can fire them once the rows are committed: the customer
200 + * email must not leave for a reply that is about to be rolled back.
201 + *
202 + * @param Conversation $createdResponse
203 + * @param \FluentSupport\App\Models\Ticket $ticket
204 + * @param \FluentSupport\App\Models\Person $person
205 + * @param bool $closed The response closed the ticket.
206 + * @param bool $agentAdded The response assigned the ticket to $person.
207 + * @param int $previousAgentId
208 + * @param array $updateData
209 + * @return array $updateData, with 'agent' when the assignment hook fired.
210 + */
211 + public function fireResponseHooks($createdResponse, $ticket, $person, $closed, $agentAdded = false, $previousAgentId = 0, $updateData = [])
212 + {
213 + $convoType = $createdResponse->conversation_type;
214 +
95 215 if ($agentAdded) {
216 + $assigner = Helper::getCurrentAgent();
96 217 $ticket->load('agent');
97 - do_action('fluent_support/agent_assigned_to_ticket', $person, $ticket);
218 +
219 + /*
220 + * Fires when a ticket is assigned to an agent.
221 + *
222 + * @since v1.0.0
223 + * @param object $person
224 + * @param object $ticket
225 + * @param object $assigner
226 + * @param int $previousAgentId
227 + */
228 + do_action('fluent_support/agent_assigned_to_ticket', $person, $ticket, $assigner, $previousAgentId);
98 229 $updateData['agent'] = $ticket->agent;
99 230 }
100 231
101 - do_action('fluent_support/' . $convoType . '_added_by_'.$person->person_type, $createdResponse, $ticket, $person);
232 + /*
233 + * Fires after a response or note is added.
234 + *
235 + * @since v1.0.0
236 + * @param string $convoType
237 + * @param string $personType
238 + * @param object $createdResponse
239 + * @param object $ticket
240 + * @param object $person
241 + */
242 + do_action('fluent_support/' . $convoType . '_added_by_' . $person->person_type, $createdResponse, $ticket, $person);
102 243
103 244
104 245 if ($closed) {
246 + /*
247 + * Fires when a ticket is closed.
248 + *
249 + * @since v1.0.0
250 + * @param object $ticket
251 + * @param object $person
252 + */
105 253 do_action('fluent_support/ticket_closed', $ticket, $person);
254 +
255 + /*
256 + * Fires when a ticket is closed, per closer type.
257 + *
258 + * @since v1.0.0
259 + * @param string $personType
260 + * @param object $ticket
261 + * @param object $person
262 + */
106 263 do_action('fluent_support/ticket_closed_by_' . $person->person_type, $ticket, $person);
107 264 }
108 265
109 - return [
110 - 'response' => $createdResponse,
111 - 'ticket' => $ticket,
112 - 'update_data' => $updateData
113 - ];
266 + return $updateData;
267 + }
268 +
269 + /**
270 + * Publish a draft reply: it becomes a sent response and the customer is notified.
271 + * Shared by the admin "Approve" action and MCP's publish-draft-reply.
272 + *
273 + * @param \FluentSupport\App\Models\Ticket $ticket
274 + * @param \FluentSupport\App\Models\Conversation $response The draft.
275 + * @param \FluentSupport\App\Models\Person $person The agent publishing it.
276 + * @param string $content Final content, already sanitized.
277 + * @return \FluentSupport\App\Models\Conversation
278 + */
279 + public function publishDraft($ticket, $response, $person, $content)
280 + {
281 + $resetWaitingSince = apply_filters('fluent_support/reset_waiting_since', true, $content);
282 +
283 + $response->content = $content;
284 + $response->conversation_type = 'response';
285 + $response->created_at = current_time('mysql');
286 + $response->save();
287 +
288 + if ($person->person_type == 'agent' && $ticket->status == 'new') {
289 + $ticket->status = 'active';
290 + if ($ticket->created_at) {
291 + $ticket->first_response_time = strtotime(current_time('mysql')) - strtotime($ticket->created_at);
292 + } else {
293 + $ticket->first_response_time = 300;
294 + }
295 + }
296 +
297 + if ($resetWaitingSince) {
298 + $ticket->last_agent_response = current_time('mysql');
299 + $ticket->waiting_since = current_time('mysql');
300 + }
301 +
302 + $ticket->response_count += 1;
303 + $ticket->save();
304 +
305 + $author = $response->person_id ? Agent::find($response->person_id) : null;
306 + $sender = $author ?: $person;
307 +
308 + do_action('fluent_support/response_added_by_' . $sender->person_type, $response, $ticket, $sender);
309 +
310 + return $response;
311 + }
312 +
313 + /**
314 + * @deprecated 2.4.1 Replies no longer carry the signature. Email templates add it
315 + * with {{agent.signature}}, so the template decides the sign-off.
316 + */
317 + public function maybeAppendSignature($content, $person)
318 + {
319 + $signature = self::getAppendedSignature($person);
320 +
321 + if ($signature === '') {
322 + return $content;
323 + }
324 +
325 + return $content . '<div class="fs_agent_signature">' . $signature . '</div>';
326 + }
327 +
328 + /**
329 + * The signature appended to this agent's replies, or '' when none is.
330 + *
331 + * @param \FluentSupport\App\Models\Person $person
332 + * @return string HTML
333 + */
334 + public static function getAppendedSignature($person)
335 + {
336 + if (!$person || $person->getMeta('agent_signature_enabled', 'no') !== 'yes') {
337 + return '';
338 + }
339 +
340 + $signature = (string) $person->getMeta('agent_signature', '');
341 + $hasText = !empty(trim(strip_tags($signature)));
342 + $hasImage = (bool) preg_match('/<img[\s>]/i', $signature);
343 +
344 + return ($hasText || $hasImage) ? $signature : '';
114 345 }
115 346 }