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 +142 -72 2.3.1 → 2.4.5 View file →
@@ -15,8 +15,16 @@
15 15 protected $table = 'fs_tickets';
16 16
17 17 protected $dates = ['waiting_since'];
18 18
19 + /**
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.
22 + *
23 + * @var array
24 + */
25 + protected $hidden = ['hash', 'content_hash'];
26 +
19 27 protected $appends = ['display_ticket_number'];
20 28
21 29 /**
22 30 * The attributes that are mass assignable.
@@ -70,8 +78,16 @@
70 78 $model->waiting_since = current_time('mysql');
71 79
72 80 });
73 81
82 + static::updating(function ($model) {
83 + // Rotate the hash on customer change so links already emailed to
84 + // the previous customer stop working.
85 + if ($model->isDirty('customer_id')) {
86 + $model->hash = bin2hex(random_bytes(16));
87 + }
88 + });
89 +
74 90 static::created(function ($model) {
75 91 if (empty($model->serial_number) || empty($model->ticket_number)) {
76 92 $model->assignTicketNumber();
77 93 }
@@ -77,19 +93,17 @@
77 93 }
78 94 });
79 95
80 96 static::deleting(function ($model) {
81 - //Delete the ticket meta
82 97 Meta::where('object_type', 'ticket_meta')->where('object_id', $model->id)->delete();
83 - //Delete all cc info for the ticket
98 + // CC info
84 99 Meta::where('object_type', 'ticket')->where('object_id', $model->id)->delete();
85 - //Delete draft info
100 + // Drafts
86 101 Meta::where('object_type', '_fs_auto_draft')->where('object_id', $model->id)->delete();
87 - //Delete internal notifications and notification recipient rows for the ticket
88 102 Notification::deleteByTicketId($model->id);
89 - //delete the responses first (their attachments are cleaned up by Conversation::deleting)
103 + // Response attachments are cleaned up by Conversation::deleting
90 104 Conversation::deleteAll($model->id);
91 - // Delete ticket-level attachments (conversation_id IS NULL) and remove the ticket upload directory
105 + // Ticket-level attachments, plus the ticket upload directory
92 106 $class = __NAMESPACE__ . '\Attachment';
93 107 $ticketAttachments = $class::where('ticket_id', $model->id)->whereNull('conversation_id')->get();
94 108 $class::purgeAttachments($ticketAttachments, $model->id);
95 109 $class::where('ticket_id', $model->id)->whereNull('conversation_id')->delete();
@@ -96,9 +110,8 @@
96 110 });
97 111 }
98 112
99 113 /**
100 - * $searchable Columns in table to search
101 114 * @var array
102 115 */
103 116 protected $searchable = [
104 117 'content',
@@ -109,9 +122,10 @@
109 122 'ticket_number'
110 123 ];
111 124
112 125 /**
113 - * Local scope to filter tickets by search/query string
126 + * Supports `column:value` to match a single fillable column.
127 + *
114 128 * @param ModelQueryBuilder $query
115 129 * @param string $search
116 130 * @return ModelQueryBuilder
117 131 */
@@ -150,11 +164,10 @@
150 164 return $query;
151 165 }
152 166
153 167 /**
154 - * Local scope to filter tickets by different filtering condition
155 168 * @param ModelQueryBuilder $query
156 - * @param mixed $search
169 + * @param array $search
157 170 * @return ModelQueryBuilder
158 171 */
159 172
160 173 public function doSearchForAdvancedFilter($query, $search)
@@ -160,17 +173,14 @@
160 173 public function doSearchForAdvancedFilter($query, $search)
161 174 {
162 175 foreach ($search as $s) {
163 176 $operator = $s['operator'];
164 - //If selected item for ticket either title or content
165 177 if (in_array($s['property'], ['title', 'content'])) {
166 - //If the selected condition is contains, query operator id LIKE
167 178 if ($operator == 'contains') {
168 179 $query = $query->where(function ($query) use ($s) {
169 180 $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
170 181 });
171 182 } elseif ($operator == 'not_contains') {
172 - //If the selected condition is not_contains, query operator id NOT LIKE
173 183 $query = $query->where(function ($query) use ($s) {
174 184 $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
175 185 });
176 186 }
@@ -175,9 +185,8 @@
175 185 });
176 186 }
177 187 }
178 188
179 - //If selected item is Ticket Conversation Content
180 189 if ($s['property'] == 'conversation_content') {
181 190 $operator = $s['operator'];
182 191 if ($operator == 'contains') {
183 192 $query = $query->whereHas('responses', function ($q) use ($s) {
@@ -190,14 +199,16 @@
190 199 });
191 200 }
192 201 }
193 202
194 - //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 +
195 207 if (in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])) {
196 208 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
197 209 }
198 210
199 - //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
200 211 if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
201 212 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
202 213 }
203 214 }
@@ -203,10 +214,78 @@
203 214 }
204 215 return $query;
205 216 }
206 217
218 + /** Advanced-filter value for tickets with no recorded source (older tickets). */
219 + const SOURCE_NONE = '__none__';
220 +
207 221 /**
208 - * 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 + /**
209 288 * @param ModelQueryBuilder $query
210 289 * @param array $statuses
211 290 * @return ModelQueryBuilder
212 291 */
@@ -219,19 +298,12 @@
219 298 return $query;
220 299 }
221 300
222 301 /**
223 - * Who replied last on this ticket, derived from the already-loaded
224 - * 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.
225 305 *
226 - * Returns 'agent', 'customer', or null. This is the per-ticket value behind
227 - * the `waiting_for_reply` filter and mirrors the timestamp comparison in
228 - * scopeWaitingOnly() (which lives in SQL, so it can't share this PHP code).
229 - *
230 - * Note: boot() seeds last_customer_response on creation, so a brand-new
231 - * ticket with no agent reply correctly resolves to 'customer' (awaiting an
232 - * agent). null is reserved for the rare case where neither timestamp is set.
233 - *
234 306 * @return string|null
235 307 */
236 308 public function getLastReplyByAttribute()
237 309 {
@@ -247,14 +319,14 @@
247 319 if (!$customerAt) {
248 320 return 'agent';
249 321 }
250 322
251 - // 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.
252 324 return strtotime($customerAt) >= strtotime($agentAt) ? 'customer' : 'agent';
253 325 }
254 326
255 327 /**
256 - * Local scope to filter tickets by not response by agent
328 + * Tickets awaiting an agent reply.
257 329 * @param $query
258 330 * @return mixed
259 331 */
260 332 public function scopeWaitingOnly($query)
@@ -267,10 +339,8 @@
267 339 return $query;
268 340 }
269 341
270 342 /**
271 - * scopeApplyFilters method will filet ticket based on the selected filters
272 - * This method will get filter option as parameter, loop through and apply conditions in query
273 343 * @param $query
274 344 * @param $filters
275 345 * @return ModelQueryBuilder
276 346 */
@@ -280,18 +350,14 @@
280 350 foreach ($filters as $filterKey => $filterValue) {
281 351 if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
282 352 continue;
283 353 }
284 - //If filer using status
285 354 if ($filterKey == 'status_type') {
286 - //Get list of ticket status
287 355 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
288 356 if ($statusArray) {
289 - //Apply filet where status in
290 357 $query->whereIn('status', $statusArray);
291 358 }
292 359 } else if (in_array($filterKey, $supportedColumns)) {
293 - // Use whereIn for all supported columns (they all now support multi-select)
294 360 if (is_array($filterValue)) {
295 361 $query->whereIn($filterKey, $filterValue);
296 362 } else {
297 363 $query->where($filterKey, $filterValue);
@@ -299,14 +365,11 @@
299 365 } else if ($filterKey == 'waiting_for_reply') {
300 366 if ($filterValue != 'yes') {
301 367 continue;
302 368 }
303 - //Apply filter where no response by agent
304 369 $query = $this->scopeWaitingOnly($query);
305 370 } else if ($filterKey == 'agent_id') {
306 - // Handle array of agent IDs for multi-select
307 371 if (is_array($filterValue)) {
308 - // Check if 'unassigned' is in the array
309 372 $hasUnassigned = in_array('unassigned', $filterValue);
310 373 $agentIds = array_filter($filterValue, function($v) {
311 374 return $v !== 'unassigned';
312 375 });
@@ -311,18 +374,15 @@
311 374 return $v !== 'unassigned';
312 375 });
313 376
314 377 if ($hasUnassigned && !empty($agentIds)) {
315 - // Include both unassigned and specific agents
316 378 $query->where(function($q) use ($agentIds) {
317 379 $q->whereNull('agent_id')
318 380 ->orWhereIn('agent_id', $agentIds);
319 381 });
320 382 } elseif ($hasUnassigned) {
321 - // Only unassigned
322 383 $query->whereNull('agent_id');
323 384 } elseif (!empty($agentIds)) {
324 - // Only specific agents
325 385 if (defined('FLUENTSUPPORTPRO')) {
326 386 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
327 387 $watcherTickets = [];
328 388 foreach ($agentIds as $agentId) {
@@ -336,9 +396,9 @@
336 396 $query->whereIn('agent_id', $agentIds);
337 397 }
338 398 }
339 399 } else {
340 - // Single value (backward compatibility)
400 + // Single value (legacy)
341 401 if ($filterValue == 'unassigned') {
342 402 $query->whereNull($filterKey);
343 403 } else {
344 404 if (defined('FLUENTSUPPORTPRO')) {
@@ -345,9 +405,8 @@
345 405 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
346 406 $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
347 407 $query->whereIn('id', $watcherTickets);
348 408 } else {
349 - //Apply filter, get only assigned ticket
350 409 $query->where($filterKey, $filterValue);
351 410 }
352 411 } else {
353 412 $query->where($filterKey, $filterValue);
@@ -371,9 +430,8 @@
371 430 } else if ($filterKey == 'ticket_tags') {
372 431 if (!$filterValue) {
373 432 continue;
374 433 }
375 - //Apply filter where ticket only has this tag id
376 434 $query->whereHas('tags', function ($q) use ($filterValue) {
377 435 $q->whereIn('tag_id', $filterValue);
378 436 });
379 437 }
@@ -382,9 +440,8 @@
382 440 return $query;
383 441 }
384 442
385 443 /**
386 - * Local scope to filter tickets by agent id
387 444 * @param ModelQueryBuilder $query
388 445 * @param int $agentId
389 446 * @return ModelQueryBuilder
390 447 */
@@ -397,9 +454,8 @@
397 454 return $query;
398 455 }
399 456
400 457 /**
401 - * Local scope to filter subscribers by search/query string
402 458 * @param ModelQueryBuilder $query
403 459 * @param int $customerId
404 460 * @return ModelQueryBuilder
405 461 */
@@ -410,9 +466,8 @@
410 466 return $query;
411 467 }
412 468
413 469 /**
414 - * Local scope to filter subscribers by search/query string
415 470 * @param ModelQueryBuilder $query
416 471 * @param int $productId
417 472 * @return ModelQueryBuilder
418 473 */
@@ -425,9 +480,8 @@
425 480 return $query;
426 481 }
427 482
428 483 /**
429 - * Local scope to filter subscribers by search/query string
430 484 * @param ModelQueryBuilder $query
431 485 * @param array $priorities
432 486 * @return ModelQueryBuilder
433 487 */
@@ -540,9 +594,8 @@
540 594 return $query;
541 595 }
542 596
543 597 /**
544 - * Relation builder
545 598 * @param $relation
546 599 * @param $query
547 600 * @param $method
548 601 * @param $subMethod
@@ -573,9 +626,8 @@
573 626 return $query;
574 627 }
575 628
576 629 /**
577 - * get tickets by advanced filter segment data
578 630 * @param $query
579 631 * @param $filter
580 632 * @return ModelQueryBuilder
581 633 */
@@ -605,9 +657,8 @@
605 657 return $query;
606 658 }
607 659
608 660 /**
609 - * method to search by properties
610 661 * @param $provider
611 662 * @param $query
612 663 * @param $search
613 664 * @param string $operator
@@ -650,9 +701,9 @@
650 701 return $query;
651 702 }
652 703
653 704 /**
654 - * Filter by ticket general properties like customer name, agent name etc
705 + * Filter by fields on the related customer or agent.
655 706 * @param $provider
656 707 * @param $query
657 708 * @param $filters
658 709 * @return ModelQueryBuilder
@@ -684,9 +735,10 @@
684 735 return $query;
685 736 }
686 737
687 738 /**
688 - * One2Many: Customer has to many Click Tickets
739 + * Newest first.
740 + *
689 741 * @return Model Collection
690 742 */
691 743 public function responses()
692 744 {
@@ -697,8 +749,39 @@
697 749 )->orderBy('created_at', 'desc')
698 750 ->orderBy('id', 'desc');
699 751 }
700 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 +
701 784 public function preview_response()
702 785 {
703 786 $class = __NAMESPACE__ . '\Conversation';
704 787
@@ -725,9 +808,8 @@
725 808 ->select(['tag_id']);
726 809 }
727 810
728 811 /**
729 - * One2one: Customer has to many Click Tickets
730 812 * @return Model Collection
731 813 */
732 814 public function customer()
733 815 {
@@ -738,9 +820,8 @@
738 820 );
739 821 }
740 822
741 823 /**
742 - * One2one: Customer has to many Click Tickets
743 824 * @return Model Collection
744 825 */
745 826 public function agent()
746 827 {
@@ -796,9 +877,8 @@
796 877 * @since v1.0.0
797 878 * @param object $ticket
798 879 */
799 880 do_action('fluent_support/deleting_ticket', $this);
800 - // Delete the ticket
801 881 $this->delete();
802 882 }
803 883
804 884 public static function getNextSerialNumber()
@@ -931,9 +1011,9 @@
931 1011
932 1012 if ($value) {
933 1013 if (in_array($fieldType, $customRenderers) && $rendered) {
934 1014 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
935 - } else if ($fieldType == 'checkbox') {
1015 + } else if (in_array($fieldType, ['checkbox', 'date-range'])) {
936 1016 $value = array_values(array_filter(explode('|', $value)));
937 1017 }
938 1018
939 1019 if (!is_array($value) && !is_object($value)) {
@@ -947,9 +1027,9 @@
947 1027 return $formattedData;
948 1028 }
949 1029
950 1030 /**
951 - * @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.
952 1032 * @return bool
953 1033 */
954 1034 public function syncCustomFields($data)
955 1035 {
@@ -1024,11 +1104,10 @@
1024 1104 ->first();
1025 1105 }
1026 1106
1027 1107 /**
1028 - * This method will assign tags to the ticket
1029 - * @param $tagIds This is the array of tag ids that will be assigned to the ticket
1030 - * @return \FluentSupport\App\Models\Ticket
1108 + * @param int|array $tagIds
1109 + * @return bool True if any tag was added.
1031 1110 */
1032 1111 public function applyTags($tagIds)
1033 1112 {
1034 1113 $result = false;
@@ -1055,11 +1134,10 @@
1055 1134 return $result;
1056 1135 }
1057 1136
1058 1137 /**
1059 - * This method will remove tags from ticket
1060 - * @param $tagIds This is the array of tag ids that will be removed from the ticket
1061 - * @return \FluentSupport\App\Models\Ticket
1138 + * @param int|array $tagIds
1139 + * @return bool True if any tag was removed.
1062 1140 */
1063 1141 public function detachTags($tagIds)
1064 1142 {
1065 1143 $result = false;
@@ -1120,16 +1198,8 @@
1120 1198 $customer = Customer::findOrFail($ticketData['customer_id']);
1121 1199
1122 1200 return (new TicketService())->storeTicket($ticketData, $customer);
1123 1201 }
1124 -
1125 - /**
1126 - * This `createResponse` will create a response for a ticket
1127 - * @param array $data
1128 - * @param int $ticketId
1129 - * @return array
1130 - * @throws Exception
1131 - */
1132 1202
1133 1203 public static function countTicketByMailBoxId($mailbox_id)
1134 1204 {
1135 1205 return self::where('mailbox_id', $mailbox_id)->count();