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 +164 -61 2.2.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,9 +298,35 @@
219 298 return $query;
220 299 }
221 300
222 301 /**
223 - * Local scope to filter tickets by not response by agent
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.
305 + *
306 + * @return string|null
307 + */
308 + public function getLastReplyByAttribute()
309 + {
310 + $agentAt = $this->last_agent_response;
311 + $customerAt = $this->last_customer_response;
312 +
313 + if (!$agentAt && !$customerAt) {
314 + return null;
315 + }
316 + if (!$agentAt) {
317 + return 'customer';
318 + }
319 + if (!$customerAt) {
320 + return 'agent';
321 + }
322 +
323 + // A tie resolves to 'customer'. scopeWaitingOnly() uses a strict <, so SQL treats a tie as answered.
324 + return strtotime($customerAt) >= strtotime($agentAt) ? 'customer' : 'agent';
325 + }
326 +
327 + /**
328 + * Tickets awaiting an agent reply.
224 329 * @param $query
225 330 * @return mixed
226 331 */
227 332 public function scopeWaitingOnly($query)
@@ -234,10 +339,8 @@
234 339 return $query;
235 340 }
236 341
237 342 /**
238 - * scopeApplyFilters method will filet ticket based on the selected filters
239 - * This method will get filter option as parameter, loop through and apply conditions in query
240 343 * @param $query
241 344 * @param $filters
242 345 * @return ModelQueryBuilder
243 346 */
@@ -247,18 +350,14 @@
247 350 foreach ($filters as $filterKey => $filterValue) {
248 351 if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
249 352 continue;
250 353 }
251 - //If filer using status
252 354 if ($filterKey == 'status_type') {
253 - //Get list of ticket status
254 355 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
255 356 if ($statusArray) {
256 - //Apply filet where status in
257 357 $query->whereIn('status', $statusArray);
258 358 }
259 359 } else if (in_array($filterKey, $supportedColumns)) {
260 - // Use whereIn for all supported columns (they all now support multi-select)
261 360 if (is_array($filterValue)) {
262 361 $query->whereIn($filterKey, $filterValue);
263 362 } else {
264 363 $query->where($filterKey, $filterValue);
@@ -266,14 +365,11 @@
266 365 } else if ($filterKey == 'waiting_for_reply') {
267 366 if ($filterValue != 'yes') {
268 367 continue;
269 368 }
270 - //Apply filter where no response by agent
271 369 $query = $this->scopeWaitingOnly($query);
272 370 } else if ($filterKey == 'agent_id') {
273 - // Handle array of agent IDs for multi-select
274 371 if (is_array($filterValue)) {
275 - // Check if 'unassigned' is in the array
276 372 $hasUnassigned = in_array('unassigned', $filterValue);
277 373 $agentIds = array_filter($filterValue, function($v) {
278 374 return $v !== 'unassigned';
279 375 });
@@ -278,18 +374,15 @@
278 374 return $v !== 'unassigned';
279 375 });
280 376
281 377 if ($hasUnassigned && !empty($agentIds)) {
282 - // Include both unassigned and specific agents
283 378 $query->where(function($q) use ($agentIds) {
284 379 $q->whereNull('agent_id')
285 380 ->orWhereIn('agent_id', $agentIds);
286 381 });
287 382 } elseif ($hasUnassigned) {
288 - // Only unassigned
289 383 $query->whereNull('agent_id');
290 384 } elseif (!empty($agentIds)) {
291 - // Only specific agents
292 385 if (defined('FLUENTSUPPORTPRO')) {
293 386 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
294 387 $watcherTickets = [];
295 388 foreach ($agentIds as $agentId) {
@@ -303,9 +396,9 @@
303 396 $query->whereIn('agent_id', $agentIds);
304 397 }
305 398 }
306 399 } else {
307 - // Single value (backward compatibility)
400 + // Single value (legacy)
308 401 if ($filterValue == 'unassigned') {
309 402 $query->whereNull($filterKey);
310 403 } else {
311 404 if (defined('FLUENTSUPPORTPRO')) {
@@ -312,9 +405,8 @@
312 405 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
313 406 $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
314 407 $query->whereIn('id', $watcherTickets);
315 408 } else {
316 - //Apply filter, get only assigned ticket
317 409 $query->where($filterKey, $filterValue);
318 410 }
319 411 } else {
320 412 $query->where($filterKey, $filterValue);
@@ -338,9 +430,8 @@
338 430 } else if ($filterKey == 'ticket_tags') {
339 431 if (!$filterValue) {
340 432 continue;
341 433 }
342 - //Apply filter where ticket only has this tag id
343 434 $query->whereHas('tags', function ($q) use ($filterValue) {
344 435 $q->whereIn('tag_id', $filterValue);
345 436 });
346 437 }
@@ -349,9 +440,8 @@
349 440 return $query;
350 441 }
351 442
352 443 /**
353 - * Local scope to filter tickets by agent id
354 444 * @param ModelQueryBuilder $query
355 445 * @param int $agentId
356 446 * @return ModelQueryBuilder
357 447 */
@@ -364,9 +454,8 @@
364 454 return $query;
365 455 }
366 456
367 457 /**
368 - * Local scope to filter subscribers by search/query string
369 458 * @param ModelQueryBuilder $query
370 459 * @param int $customerId
371 460 * @return ModelQueryBuilder
372 461 */
@@ -377,9 +466,8 @@
377 466 return $query;
378 467 }
379 468
380 469 /**
381 - * Local scope to filter subscribers by search/query string
382 470 * @param ModelQueryBuilder $query
383 471 * @param int $productId
384 472 * @return ModelQueryBuilder
385 473 */
@@ -392,9 +480,8 @@
392 480 return $query;
393 481 }
394 482
395 483 /**
396 - * Local scope to filter subscribers by search/query string
397 484 * @param ModelQueryBuilder $query
398 485 * @param array $priorities
399 486 * @return ModelQueryBuilder
400 487 */
@@ -507,9 +594,8 @@
507 594 return $query;
508 595 }
509 596
510 597 /**
511 - * Relation builder
512 598 * @param $relation
513 599 * @param $query
514 600 * @param $method
515 601 * @param $subMethod
@@ -540,9 +626,8 @@
540 626 return $query;
541 627 }
542 628
543 629 /**
544 - * get tickets by advanced filter segment data
545 630 * @param $query
546 631 * @param $filter
547 632 * @return ModelQueryBuilder
548 633 */
@@ -572,9 +657,8 @@
572 657 return $query;
573 658 }
574 659
575 660 /**
576 - * method to search by properties
577 661 * @param $provider
578 662 * @param $query
579 663 * @param $search
580 664 * @param string $operator
@@ -617,9 +701,9 @@
617 701 return $query;
618 702 }
619 703
620 704 /**
621 - * Filter by ticket general properties like customer name, agent name etc
705 + * Filter by fields on the related customer or agent.
622 706 * @param $provider
623 707 * @param $query
624 708 * @param $filters
625 709 * @return ModelQueryBuilder
@@ -651,9 +735,10 @@
651 735 return $query;
652 736 }
653 737
654 738 /**
655 - * One2Many: Customer has to many Click Tickets
739 + * Newest first.
740 + *
656 741 * @return Model Collection
657 742 */
658 743 public function responses()
659 744 {
@@ -664,8 +749,39 @@
664 749 )->orderBy('created_at', 'desc')
665 750 ->orderBy('id', 'desc');
666 751 }
667 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 +
668 784 public function preview_response()
669 785 {
670 786 $class = __NAMESPACE__ . '\Conversation';
671 787
@@ -692,9 +808,8 @@
692 808 ->select(['tag_id']);
693 809 }
694 810
695 811 /**
696 - * One2one: Customer has to many Click Tickets
697 812 * @return Model Collection
698 813 */
699 814 public function customer()
700 815 {
@@ -705,9 +820,8 @@
705 820 );
706 821 }
707 822
708 823 /**
709 - * One2one: Customer has to many Click Tickets
710 824 * @return Model Collection
711 825 */
712 826 public function agent()
713 827 {
@@ -763,9 +877,8 @@
763 877 * @since v1.0.0
764 878 * @param object $ticket
765 879 */
766 880 do_action('fluent_support/deleting_ticket', $this);
767 - // Delete the ticket
768 881 $this->delete();
769 882 }
770 883
771 884 public static function getNextSerialNumber()
@@ -898,9 +1011,9 @@
898 1011
899 1012 if ($value) {
900 1013 if (in_array($fieldType, $customRenderers) && $rendered) {
901 1014 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
902 - } else if ($fieldType == 'checkbox') {
1015 + } else if (in_array($fieldType, ['checkbox', 'date-range'])) {
903 1016 $value = array_values(array_filter(explode('|', $value)));
904 1017 }
905 1018
906 1019 if (!is_array($value) && !is_object($value)) {
@@ -914,9 +1027,9 @@
914 1027 return $formattedData;
915 1028 }
916 1029
917 1030 /**
918 - * @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.
919 1032 * @return bool
920 1033 */
921 1034 public function syncCustomFields($data)
922 1035 {
@@ -991,11 +1104,10 @@
991 1104 ->first();
992 1105 }
993 1106
994 1107 /**
995 - * This method will assign tags to the ticket
996 - * @param $tagIds This is the array of tag ids that will be assigned to the ticket
997 - * @return \FluentSupport\App\Models\Ticket
1108 + * @param int|array $tagIds
1109 + * @return bool True if any tag was added.
998 1110 */
999 1111 public function applyTags($tagIds)
1000 1112 {
1001 1113 $result = false;
@@ -1022,11 +1134,10 @@
1022 1134 return $result;
1023 1135 }
1024 1136
1025 1137 /**
1026 - * This method will remove tags from ticket
1027 - * @param $tagIds This is the array of tag ids that will be removed from the ticket
1028 - * @return \FluentSupport\App\Models\Ticket
1138 + * @param int|array $tagIds
1139 + * @return bool True if any tag was removed.
1029 1140 */
1030 1141 public function detachTags($tagIds)
1031 1142 {
1032 1143 $result = false;
@@ -1087,16 +1198,8 @@
1087 1198 $customer = Customer::findOrFail($ticketData['customer_id']);
1088 1199
1089 1200 return (new TicketService())->storeTicket($ticketData, $customer);
1090 1201 }
1091 -
1092 - /**
1093 - * This `createResponse` will create a response for a ticket
1094 - * @param array $data
1095 - * @param int $ticketId
1096 - * @return array
1097 - * @throws Exception
1098 - */
1099 1202
1100 1203 public static function countTicketByMailBoxId($mailbox_id)
1101 1204 {
1102 1205 return self::where('mailbox_id', $mailbox_id)->count();