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/Models/Ticket.php +129 -77 2.4.0 → 2.4.5 View file →
@@ -16,11 +16,10 @@
16 16
17 17 protected $dates = ['waiting_since'];
18 18
19 19 /**
20 - * The ticket hash is a bearer credential for the signed public ticket view,
21 - * so it must never be serialized into an API response. PHP property access
22 - * is unaffected, which is what Helper::getTicketViewSignedUrl() relies on.
20 + * The hash is a bearer credential for the public ticket view, so keep it
21 + * out of API responses. Property access still works for signed URLs.
23 22 *
24 23 * @var array
25 24 */
26 25 protected $hidden = ['hash', 'content_hash'];
@@ -80,11 +79,10 @@
80 79
81 80 });
82 81
83 82 static::updating(function ($model) {
84 - // A hash handed out for one customer must stop working the moment
85 - // the ticket belongs to somebody else, otherwise every link already
86 - // emailed for it keeps authorizing read, reply, close and reopen.
83 + // Rotate the hash on customer change so links already emailed to
84 + // the previous customer stop working.
87 85 if ($model->isDirty('customer_id')) {
88 86 $model->hash = bin2hex(random_bytes(16));
89 87 }
90 88 });
@@ -95,19 +93,17 @@
95 93 }
96 94 });
97 95
98 96 static::deleting(function ($model) {
99 - //Delete the ticket meta
100 97 Meta::where('object_type', 'ticket_meta')->where('object_id', $model->id)->delete();
101 - //Delete all cc info for the ticket
98 + // CC info
102 99 Meta::where('object_type', 'ticket')->where('object_id', $model->id)->delete();
103 - //Delete draft info
100 + // Drafts
104 101 Meta::where('object_type', '_fs_auto_draft')->where('object_id', $model->id)->delete();
105 - //Delete internal notifications and notification recipient rows for the ticket
106 102 Notification::deleteByTicketId($model->id);
107 - //delete the responses first (their attachments are cleaned up by Conversation::deleting)
103 + // Response attachments are cleaned up by Conversation::deleting
108 104 Conversation::deleteAll($model->id);
109 - // Delete ticket-level attachments (conversation_id IS NULL) and remove the ticket upload directory
105 + // Ticket-level attachments, plus the ticket upload directory
110 106 $class = __NAMESPACE__ . '\Attachment';
111 107 $ticketAttachments = $class::where('ticket_id', $model->id)->whereNull('conversation_id')->get();
112 108 $class::purgeAttachments($ticketAttachments, $model->id);
113 109 $class::where('ticket_id', $model->id)->whereNull('conversation_id')->delete();
@@ -114,9 +110,8 @@
114 110 });
115 111 }
116 112
117 113 /**
118 - * $searchable Columns in table to search
119 114 * @var array
120 115 */
121 116 protected $searchable = [
122 117 'content',
@@ -127,9 +122,10 @@
127 122 'ticket_number'
128 123 ];
129 124
130 125 /**
131 - * Local scope to filter tickets by search/query string
126 + * Supports `column:value` to match a single fillable column.
127 + *
132 128 * @param ModelQueryBuilder $query
133 129 * @param string $search
134 130 * @return ModelQueryBuilder
135 131 */
@@ -168,11 +164,10 @@
168 164 return $query;
169 165 }
170 166
171 167 /**
172 - * Local scope to filter tickets by different filtering condition
173 168 * @param ModelQueryBuilder $query
174 - * @param mixed $search
169 + * @param array $search
175 170 * @return ModelQueryBuilder
176 171 */
177 172
178 173 public function doSearchForAdvancedFilter($query, $search)
@@ -178,17 +173,14 @@
178 173 public function doSearchForAdvancedFilter($query, $search)
179 174 {
180 175 foreach ($search as $s) {
181 176 $operator = $s['operator'];
182 - //If selected item for ticket either title or content
183 177 if (in_array($s['property'], ['title', 'content'])) {
184 - //If the selected condition is contains, query operator id LIKE
185 178 if ($operator == 'contains') {
186 179 $query = $query->where(function ($query) use ($s) {
187 180 $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
188 181 });
189 182 } elseif ($operator == 'not_contains') {
190 - //If the selected condition is not_contains, query operator id NOT LIKE
191 183 $query = $query->where(function ($query) use ($s) {
192 184 $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
193 185 });
194 186 }
@@ -193,9 +185,8 @@
193 185 });
194 186 }
195 187 }
196 188
197 - //If selected item is Ticket Conversation Content
198 189 if ($s['property'] == 'conversation_content') {
199 190 $operator = $s['operator'];
200 191 if ($operator == 'contains') {
201 192 $query = $query->whereHas('responses', function ($q) use ($s) {
@@ -208,14 +199,16 @@
208 199 });
209 200 }
210 201 }
211 202
212 - //If selected item is Ticket created or Last Response or Customer Waiting For, or Last Agent Response or Last Customer Response
203 + if ($s['property'] == 'source') {
204 + $query = static::buildSourceFilterQuery($query, $s);
205 + }
206 +
213 207 if (in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])) {
214 208 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
215 209 }
216 210
217 - //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
218 211 if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
219 212 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
220 213 }
221 214 }
@@ -221,10 +214,78 @@
221 214 }
222 215 return $query;
223 216 }
224 217
218 + /** Advanced-filter value for tickets with no recorded source (older tickets). */
219 + const SOURCE_NONE = '__none__';
220 +
225 221 /**
226 - * Local scope to filter subscribers by search/query string
222 + * Where tickets come from, for the advanced filter's Source field.
223 + *
224 + * @return array<string,string> source => label
225 + */
226 + public static function sourceFilterOptions()
227 + {
228 + return apply_filters('fluent_support/ticket_source_filter_options', [
229 + 'web' => __('Web', 'fluent-support'),
230 + 'email' => __('Email', 'fluent-support'),
231 + self::SOURCE_AGENT_INITIATED => __('Agent outreach', 'fluent-support'),
232 + 'mcp' => __('MCP (AI agents)', 'fluent-support'),
233 + 'telegram' => __('Telegram', 'fluent-support'),
234 + 'slack' => __('Slack', 'fluent-support'),
235 + 'whatsapp' => __('WhatsApp', 'fluent-support'),
236 + 'awesome-support' => __('Imported: Awesome Support', 'fluent-support'),
237 + 'freshdesk' => __('Imported: Freshdesk', 'fluent-support'),
238 + 'helpscout' => __('Imported: Help Scout', 'fluent-support'),
239 + 'js-helpdesk' => __('Imported: JS Help Desk', 'fluent-support'),
240 + 'support-candy' => __('Imported: SupportCandy', 'fluent-support'),
241 + 'zendesk' => __('Imported: Zendesk', 'fluent-support'),
242 + self::SOURCE_NONE => __('No source', 'fluent-support'),
243 + ]);
244 + }
245 +
246 + /**
247 + * Source "in" / "not in". SOURCE_NONE stands for NULL or empty, which SQL's
248 + * IN and NOT IN would otherwise skip.
249 + *
250 + * @param \FluentSupport\Framework\Database\Orm\Builder $query
251 + * @param array $filter
252 + * @return \FluentSupport\Framework\Database\Orm\Builder
253 + */
254 + public static function buildSourceFilterQuery($query, $filter)
255 + {
256 + $values = array_values(array_filter(array_map('sanitize_text_field', (array) Arr::get($filter, 'value', [])), 'strlen'));
257 + if (!$values) {
258 + return $query;
259 + }
260 +
261 + $includeNone = in_array(self::SOURCE_NONE, $values, true);
262 + $sources = array_values(array_diff($values, [self::SOURCE_NONE]));
263 +
264 + if (Arr::get($filter, 'operator') === 'not_in') {
265 + return $query->where(function ($q) use ($sources, $includeNone) {
266 + if ($includeNone) {
267 + $q->whereNotNull('source')->where('source', '!=', '');
268 + if ($sources) {
269 + $q->whereNotIn('source', $sources);
270 + }
271 + } else {
272 + $q->whereNotIn('source', $sources)->orWhereNull('source');
273 + }
274 + });
275 + }
276 +
277 + return $query->where(function ($q) use ($sources, $includeNone) {
278 + if ($sources) {
279 + $q->whereIn('source', $sources);
280 + }
281 + if ($includeNone) {
282 + $q->orWhereNull('source')->orWhere('source', '');
283 + }
284 + });
285 + }
286 +
287 + /**
227 288 * @param ModelQueryBuilder $query
228 289 * @param array $statuses
229 290 * @return ModelQueryBuilder
230 291 */
@@ -237,19 +298,12 @@
237 298 return $query;
238 299 }
239 300
240 301 /**
241 - * Who replied last on this ticket, derived from the already-loaded
242 - * last_agent_response / last_customer_response timestamp columns.
302 + * Who replied last: 'agent', 'customer', or null when neither timestamp is
303 + * set. PHP counterpart of the timestamp check in scopeWaitingOnly(). A new
304 + * ticket resolves to 'customer' because boot() seeds last_customer_response.
243 305 *
244 - * Returns 'agent', 'customer', or null. This is the per-ticket value behind
245 - * the `waiting_for_reply` filter and mirrors the timestamp comparison in
246 - * scopeWaitingOnly() (which lives in SQL, so it can't share this PHP code).
247 - *
248 - * Note: boot() seeds last_customer_response on creation, so a brand-new
249 - * ticket with no agent reply correctly resolves to 'customer' (awaiting an
250 - * agent). null is reserved for the rare case where neither timestamp is set.
251 - *
252 306 * @return string|null
253 307 */
254 308 public function getLastReplyByAttribute()
255 309 {
@@ -265,14 +319,14 @@
265 319 if (!$customerAt) {
266 320 return 'agent';
267 321 }
268 322
269 - // Tie (same second) resolves to 'customer' — the waiting bias used by scopeWaitingOnly.
323 + // A tie resolves to 'customer'. scopeWaitingOnly() uses a strict <, so SQL treats a tie as answered.
270 324 return strtotime($customerAt) >= strtotime($agentAt) ? 'customer' : 'agent';
271 325 }
272 326
273 327 /**
274 - * Local scope to filter tickets by not response by agent
328 + * Tickets awaiting an agent reply.
275 329 * @param $query
276 330 * @return mixed
277 331 */
278 332 public function scopeWaitingOnly($query)
@@ -285,10 +339,8 @@
285 339 return $query;
286 340 }
287 341
288 342 /**
289 - * scopeApplyFilters method will filet ticket based on the selected filters
290 - * This method will get filter option as parameter, loop through and apply conditions in query
291 343 * @param $query
292 344 * @param $filters
293 345 * @return ModelQueryBuilder
294 346 */
@@ -298,18 +350,14 @@
298 350 foreach ($filters as $filterKey => $filterValue) {
299 351 if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
300 352 continue;
301 353 }
302 - //If filer using status
303 354 if ($filterKey == 'status_type') {
304 - //Get list of ticket status
305 355 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
306 356 if ($statusArray) {
307 - //Apply filet where status in
308 357 $query->whereIn('status', $statusArray);
309 358 }
310 359 } else if (in_array($filterKey, $supportedColumns)) {
311 - // Use whereIn for all supported columns (they all now support multi-select)
312 360 if (is_array($filterValue)) {
313 361 $query->whereIn($filterKey, $filterValue);
314 362 } else {
315 363 $query->where($filterKey, $filterValue);
@@ -317,14 +365,11 @@
317 365 } else if ($filterKey == 'waiting_for_reply') {
318 366 if ($filterValue != 'yes') {
319 367 continue;
320 368 }
321 - //Apply filter where no response by agent
322 369 $query = $this->scopeWaitingOnly($query);
323 370 } else if ($filterKey == 'agent_id') {
324 - // Handle array of agent IDs for multi-select
325 371 if (is_array($filterValue)) {
326 - // Check if 'unassigned' is in the array
327 372 $hasUnassigned = in_array('unassigned', $filterValue);
328 373 $agentIds = array_filter($filterValue, function($v) {
329 374 return $v !== 'unassigned';
330 375 });
@@ -329,18 +374,15 @@
329 374 return $v !== 'unassigned';
330 375 });
331 376
332 377 if ($hasUnassigned && !empty($agentIds)) {
333 - // Include both unassigned and specific agents
334 378 $query->where(function($q) use ($agentIds) {
335 379 $q->whereNull('agent_id')
336 380 ->orWhereIn('agent_id', $agentIds);
337 381 });
338 382 } elseif ($hasUnassigned) {
339 - // Only unassigned
340 383 $query->whereNull('agent_id');
341 384 } elseif (!empty($agentIds)) {
342 - // Only specific agents
343 385 if (defined('FLUENTSUPPORTPRO')) {
344 386 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
345 387 $watcherTickets = [];
346 388 foreach ($agentIds as $agentId) {
@@ -354,9 +396,9 @@
354 396 $query->whereIn('agent_id', $agentIds);
355 397 }
356 398 }
357 399 } else {
358 - // Single value (backward compatibility)
400 + // Single value (legacy)
359 401 if ($filterValue == 'unassigned') {
360 402 $query->whereNull($filterKey);
361 403 } else {
362 404 if (defined('FLUENTSUPPORTPRO')) {
@@ -363,9 +405,8 @@
363 405 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
364 406 $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
365 407 $query->whereIn('id', $watcherTickets);
366 408 } else {
367 - //Apply filter, get only assigned ticket
368 409 $query->where($filterKey, $filterValue);
369 410 }
370 411 } else {
371 412 $query->where($filterKey, $filterValue);
@@ -389,9 +430,8 @@
389 430 } else if ($filterKey == 'ticket_tags') {
390 431 if (!$filterValue) {
391 432 continue;
392 433 }
393 - //Apply filter where ticket only has this tag id
394 434 $query->whereHas('tags', function ($q) use ($filterValue) {
395 435 $q->whereIn('tag_id', $filterValue);
396 436 });
397 437 }
@@ -400,9 +440,8 @@
400 440 return $query;
401 441 }
402 442
403 443 /**
404 - * Local scope to filter tickets by agent id
405 444 * @param ModelQueryBuilder $query
406 445 * @param int $agentId
407 446 * @return ModelQueryBuilder
408 447 */
@@ -415,9 +454,8 @@
415 454 return $query;
416 455 }
417 456
418 457 /**
419 - * Local scope to filter subscribers by search/query string
420 458 * @param ModelQueryBuilder $query
421 459 * @param int $customerId
422 460 * @return ModelQueryBuilder
423 461 */
@@ -428,9 +466,8 @@
428 466 return $query;
429 467 }
430 468
431 469 /**
432 - * Local scope to filter subscribers by search/query string
433 470 * @param ModelQueryBuilder $query
434 471 * @param int $productId
435 472 * @return ModelQueryBuilder
436 473 */
@@ -443,9 +480,8 @@
443 480 return $query;
444 481 }
445 482
446 483 /**
447 - * Local scope to filter subscribers by search/query string
448 484 * @param ModelQueryBuilder $query
449 485 * @param array $priorities
450 486 * @return ModelQueryBuilder
451 487 */
@@ -558,9 +594,8 @@
558 594 return $query;
559 595 }
560 596
561 597 /**
562 - * Relation builder
563 598 * @param $relation
564 599 * @param $query
565 600 * @param $method
566 601 * @param $subMethod
@@ -591,9 +626,8 @@
591 626 return $query;
592 627 }
593 628
594 629 /**
595 - * get tickets by advanced filter segment data
596 630 * @param $query
597 631 * @param $filter
598 632 * @return ModelQueryBuilder
599 633 */
@@ -623,9 +657,8 @@
623 657 return $query;
624 658 }
625 659
626 660 /**
627 - * method to search by properties
628 661 * @param $provider
629 662 * @param $query
630 663 * @param $search
631 664 * @param string $operator
@@ -668,9 +701,9 @@
668 701 return $query;
669 702 }
670 703
671 704 /**
672 - * Filter by ticket general properties like customer name, agent name etc
705 + * Filter by fields on the related customer or agent.
673 706 * @param $provider
674 707 * @param $query
675 708 * @param $filters
676 709 * @return ModelQueryBuilder
@@ -702,9 +735,10 @@
702 735 return $query;
703 736 }
704 737
705 738 /**
706 - * One2Many: Customer has to many Click Tickets
739 + * Newest first.
740 + *
707 741 * @return Model Collection
708 742 */
709 743 public function responses()
710 744 {
@@ -715,8 +749,39 @@
715 749 )->orderBy('created_at', 'desc')
716 750 ->orderBy('id', 'desc');
717 751 }
718 752
753 + /** source value for a ticket an agent opened to reach out to the customer. */
754 + const SOURCE_AGENT_INITIATED = 'agent_initiated';
755 +
756 + /**
757 + * Whether an agent opened this ticket to reach out. The message is then the
758 + * agent's first response, and the ticket body is only a placeholder.
759 + *
760 + * @return bool
761 + */
762 + public function isAgentInitiated()
763 + {
764 + if ($this->source === self::SOURCE_AGENT_INITIATED) {
765 + return true;
766 + }
767 +
768 + // Before 2.4.1 only the "initialized this ticket" placeholder body marked it.
769 + return $this->created_by && self::hasLegacyAgentInitiatedMarker($this->content);
770 + }
771 +
772 + /**
773 + * @param string $content
774 + * @return bool
775 + */
776 + public static function hasLegacyAgentInitiatedMarker($content)
777 + {
778 + $content = (string) $content;
779 +
780 + return strpos($content, ' initialized this ticket') !== false
781 + || strpos($content, __(' initialized this ticket', 'fluent-support')) !== false;
782 + }
783 +
719 784 public function preview_response()
720 785 {
721 786 $class = __NAMESPACE__ . '\Conversation';
722 787
@@ -743,9 +808,8 @@
743 808 ->select(['tag_id']);
744 809 }
745 810
746 811 /**
747 - * One2one: Customer has to many Click Tickets
748 812 * @return Model Collection
749 813 */
750 814 public function customer()
751 815 {
@@ -756,9 +820,8 @@
756 820 );
757 821 }
758 822
759 823 /**
760 - * One2one: Customer has to many Click Tickets
761 824 * @return Model Collection
762 825 */
763 826 public function agent()
764 827 {
@@ -814,9 +877,8 @@
814 877 * @since v1.0.0
815 878 * @param object $ticket
816 879 */
817 880 do_action('fluent_support/deleting_ticket', $this);
818 - // Delete the ticket
819 881 $this->delete();
820 882 }
821 883
822 884 public static function getNextSerialNumber()
@@ -965,9 +1027,9 @@
965 1027 return $formattedData;
966 1028 }
967 1029
968 1030 /**
969 - * @param $data This is the data that will be saved to the ticket_meta for custom fields
1031 + * @param array $data Custom field values keyed by field key.
970 1032 * @return bool
971 1033 */
972 1034 public function syncCustomFields($data)
973 1035 {
@@ -1042,11 +1104,10 @@
1042 1104 ->first();
1043 1105 }
1044 1106
1045 1107 /**
1046 - * This method will assign tags to the ticket
1047 - * @param $tagIds This is the array of tag ids that will be assigned to the ticket
1048 - * @return \FluentSupport\App\Models\Ticket
1108 + * @param int|array $tagIds
1109 + * @return bool True if any tag was added.
1049 1110 */
1050 1111 public function applyTags($tagIds)
1051 1112 {
1052 1113 $result = false;
@@ -1073,11 +1134,10 @@
1073 1134 return $result;
1074 1135 }
1075 1136
1076 1137 /**
1077 - * This method will remove tags from ticket
1078 - * @param $tagIds This is the array of tag ids that will be removed from the ticket
1079 - * @return \FluentSupport\App\Models\Ticket
1138 + * @param int|array $tagIds
1139 + * @return bool True if any tag was removed.
1080 1140 */
1081 1141 public function detachTags($tagIds)
1082 1142 {
1083 1143 $result = false;
@@ -1138,16 +1198,8 @@
1138 1198 $customer = Customer::findOrFail($ticketData['customer_id']);
1139 1199
1140 1200 return (new TicketService())->storeTicket($ticketData, $customer);
1141 1201 }
1142 -
1143 - /**
1144 - * This `createResponse` will create a response for a ticket
1145 - * @param array $data
1146 - * @param int $ticketId
1147 - * @return array
1148 - * @throws Exception
1149 - */
1150 1202
1151 1203 public static function countTicketByMailBoxId($mailbox_id)
1152 1204 {
1153 1205 return self::where('mailbox_id', $mailbox_id)->count();