PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
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 1.5.5 All 68 releases
← All changes | app/Models/Ticket.php +478 -95 1.5.52.4.0 View file →
@@ -1,9 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Models;
4 4
5 +use Exception;
6 +use FluentSupport\App\Modules\PermissionManager;
5 7 use FluentSupport\App\Services\Helper;
8 +use FluentSupport\App\Services\TicketHelper;
9 +use FluentSupport\App\Services\TicketQueryService;
10 +use FluentSupport\App\Services\Tickets\TicketService;
6 11 use FluentSupport\Framework\Support\Arr;
7 12
8 13 class Ticket extends Model
9 14 {
@@ -8,9 +13,22 @@
8 13 class Ticket extends Model
9 14 {
10 15 protected $table = 'fs_tickets';
11 16
17 + protected $dates = ['waiting_since'];
18 +
12 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.
23 + *
24 + * @var array
25 + */
26 + protected $hidden = ['hash', 'content_hash'];
27 +
28 + protected $appends = ['display_ticket_number'];
29 +
30 + /**
13 31 * The attributes that are mass assignable.
14 32 *
15 33 * @var array
16 34 */
@@ -36,22 +54,65 @@
36 54 'response_count',
37 55 'first_response_time',
38 56 'total_close_time',
39 57 'resolved_at',
40 - 'closed_by'
58 + 'closed_by',
59 + 'created_by',
60 + 'serial_number',
61 + 'ticket_number'
41 62 ];
42 63
43 64 public static function boot()
44 65 {
66 + parent::boot();
67 +
45 68 static::creating(function ($model) {
46 - $model->slug = static::slugify($model->title);
47 - $model->hash = substr(md5(time() . wp_generate_uuid4()), 0, 8) . mt_rand(1, 99);
69 + if (empty($model->slug)) {
70 + $model->slug = static::slugify($model->title);
71 + }
72 +
73 + $model->hash = bin2hex(random_bytes(16));
74 + $model->content_hash = md5($model->content);
75 +
48 76 $model->last_customer_response = current_time('mysql');
49 - $model->content_hash = md5($model->content);
50 77 $model->created_at = current_time('mysql');
51 78 $model->updated_at = current_time('mysql');
52 79 $model->waiting_since = current_time('mysql');
80 +
53 81 });
82 +
83 + 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.
87 + if ($model->isDirty('customer_id')) {
88 + $model->hash = bin2hex(random_bytes(16));
89 + }
90 + });
91 +
92 + static::created(function ($model) {
93 + if (empty($model->serial_number) || empty($model->ticket_number)) {
94 + $model->assignTicketNumber();
95 + }
96 + });
97 +
98 + static::deleting(function ($model) {
99 + //Delete the ticket meta
100 + Meta::where('object_type', 'ticket_meta')->where('object_id', $model->id)->delete();
101 + //Delete all cc info for the ticket
102 + Meta::where('object_type', 'ticket')->where('object_id', $model->id)->delete();
103 + //Delete draft info
104 + 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 + Notification::deleteByTicketId($model->id);
107 + //delete the responses first (their attachments are cleaned up by Conversation::deleting)
108 + Conversation::deleteAll($model->id);
109 + // Delete ticket-level attachments (conversation_id IS NULL) and remove the ticket upload directory
110 + $class = __NAMESPACE__ . '\Attachment';
111 + $ticketAttachments = $class::where('ticket_id', $model->id)->whereNull('conversation_id')->get();
112 + $class::purgeAttachments($ticketAttachments, $model->id);
113 + $class::where('ticket_id', $model->id)->whereNull('conversation_id')->delete();
114 + });
54 115 }
55 116
56 117 /**
57 118 * $searchable Columns in table to search
@@ -60,9 +121,11 @@
60 121 protected $searchable = [
61 122 'content',
62 123 'title',
63 124 'slug',
64 - 'id'
125 + 'id',
126 + 'serial_number',
127 + 'ticket_number'
65 128 ];
66 129
67 130 /**
68 131 * Local scope to filter tickets by search/query string
@@ -71,10 +134,15 @@
71 134 * @return ModelQueryBuilder
72 135 */
73 136 public function scopeSearchBy($query, $search)
74 137 {
138 +
139 + if(!$search) {
140 + return $query;
141 + }
142 +
75 143 if (strpos($search, ':')) {
76 - $array = explode(':', $search);
144 + $array = explode(':', (string) $search);
77 145 $column = $array[0];
78 146 $value = $array[1];
79 147 $columns = $this->fillable;
80 148 $columns[] = 'id';
@@ -111,44 +179,44 @@
111 179 {
112 180 foreach ($search as $s) {
113 181 $operator = $s['operator'];
114 182 //If selected item for ticket either title or content
115 - if(in_array($s['property'], ['title','content' ])){
183 + if (in_array($s['property'], ['title', 'content'])) {
116 184 //If the selected condition is contains, query operator id LIKE
117 185 if ($operator == 'contains') {
118 186 $query = $query->where(function ($query) use ($s) {
119 - $query->where($s['property'], 'LIKE', "%".$s['value']."%");
187 + $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
120 188 });
121 189 } elseif ($operator == 'not_contains') {
122 190 //If the selected condition is not_contains, query operator id NOT LIKE
123 - $query = $query->where(function ($query) use ($s){
124 - $query->where($s['property'], 'NOT LIKE', '%'.$s['value'].'%');
191 + $query = $query->where(function ($query) use ($s) {
192 + $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
125 193 });
126 194 }
127 195 }
128 196
129 197 //If selected item is Ticket Conversation Content
130 - if($s['property'] == 'conversation_content'){
198 + if ($s['property'] == 'conversation_content') {
131 199 $operator = $s['operator'];
132 - if($operator == 'contains') {
200 + if ($operator == 'contains') {
133 201 $query = $query->whereHas('responses', function ($q) use ($s) {
134 - $q->where('content', 'LIKE', "%".$s['value']."%");
202 + $q->where('content', 'LIKE', "%" . $s['value'] . "%");
135 203 });
136 204
137 205 } else if ($operator == 'not_contains') {
138 206 $query = $query->whereHas('responses', function ($q) use ($s) {
139 - $q->where('content', 'NOT LIKE', "%".$s['value']."%");
207 + $q->where('content', 'NOT LIKE', "%" . $s['value'] . "%");
140 208 });
141 209 }
142 210 }
143 211
144 212 //If selected item is Ticket created or Last Response or Customer Waiting For, or Last Agent Response or Last Customer Response
145 - if(in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])){
213 + if (in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])) {
146 214 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
147 215 }
148 216
149 217 //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
150 - if(in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])){
218 + if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
151 219 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
152 220 }
153 221 }
154 222 return $query;
@@ -169,8 +237,41 @@
169 237 return $query;
170 238 }
171 239
172 240 /**
241 + * Who replied last on this ticket, derived from the already-loaded
242 + * last_agent_response / last_customer_response timestamp columns.
243 + *
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 + * @return string|null
253 + */
254 + public function getLastReplyByAttribute()
255 + {
256 + $agentAt = $this->last_agent_response;
257 + $customerAt = $this->last_customer_response;
258 +
259 + if (!$agentAt && !$customerAt) {
260 + return null;
261 + }
262 + if (!$agentAt) {
263 + return 'customer';
264 + }
265 + if (!$customerAt) {
266 + return 'agent';
267 + }
268 +
269 + // Tie (same second) resolves to 'customer' — the waiting bias used by scopeWaitingOnly.
270 + return strtotime($customerAt) >= strtotime($agentAt) ? 'customer' : 'agent';
271 + }
272 +
273 + /**
173 274 * Local scope to filter tickets by not response by agent
174 275 * @param $query
175 276 * @return mixed
176 277 */
@@ -175,10 +276,10 @@
175 276 * @return mixed
176 277 */
177 278 public function scopeWaitingOnly($query)
178 279 {
179 - $query->where(function ($q){
180 - $q->whereColumn('last_agent_response', '<' ,'last_customer_response')
280 + $query->where(function ($q) {
281 + $q->whereColumn('last_agent_response', '<', 'last_customer_response')
181 282 ->orWhereNull('last_agent_response')
182 283 ->orWhere('status', 'new');
183 284 });
184 285 return $query;
@@ -194,9 +295,9 @@
194 295 public function scopeApplyFilters($query, $filters)
195 296 {
196 297 $supportedColumns = ['product_id', 'client_priority', 'priority', 'mailbox_id'];
197 298 foreach ($filters as $filterKey => $filterValue) {
198 - if (!$filterValue && ($filterValue !== '0' || $filterValue !== 0)) {
299 + if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
199 300 continue;
200 301 }
201 302 //If filer using status
202 303 if ($filterKey == 'status_type') {
@@ -206,9 +307,14 @@
206 307 //Apply filet where status in
207 308 $query->whereIn('status', $statusArray);
208 309 }
209 310 } else if (in_array($filterKey, $supportedColumns)) {
210 - $query->where($filterKey, $filterValue);
311 + // Use whereIn for all supported columns (they all now support multi-select)
312 + if (is_array($filterValue)) {
313 + $query->whereIn($filterKey, $filterValue);
314 + } else {
315 + $query->where($filterKey, $filterValue);
316 + }
211 317 } else if ($filterKey == 'waiting_for_reply') {
212 318 if ($filterValue != 'yes') {
213 319 continue;
214 320 }
@@ -214,15 +320,73 @@
214 320 }
215 321 //Apply filter where no response by agent
216 322 $query = $this->scopeWaitingOnly($query);
217 323 } else if ($filterKey == 'agent_id') {
218 - //Apply filter where ticket is not assigned
219 - if ($filterValue == 'unassigned') {
220 - $query->whereNull($filterKey);
324 + // Handle array of agent IDs for multi-select
325 + if (is_array($filterValue)) {
326 + // Check if 'unassigned' is in the array
327 + $hasUnassigned = in_array('unassigned', $filterValue);
328 + $agentIds = array_filter($filterValue, function($v) {
329 + return $v !== 'unassigned';
330 + });
331 +
332 + if ($hasUnassigned && !empty($agentIds)) {
333 + // Include both unassigned and specific agents
334 + $query->where(function($q) use ($agentIds) {
335 + $q->whereNull('agent_id')
336 + ->orWhereIn('agent_id', $agentIds);
337 + });
338 + } elseif ($hasUnassigned) {
339 + // Only unassigned
340 + $query->whereNull('agent_id');
341 + } elseif (!empty($agentIds)) {
342 + // Only specific agents
343 + if (defined('FLUENTSUPPORTPRO')) {
344 + if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
345 + $watcherTickets = [];
346 + foreach ($agentIds as $agentId) {
347 + $watcherTickets = array_merge($watcherTickets, TicketHelper::getWatcherTicketIds($agentId));
348 + }
349 + $query->whereIn('id', array_unique($watcherTickets));
350 + } else {
351 + $query->whereIn('agent_id', $agentIds);
352 + }
353 + } else {
354 + $query->whereIn('agent_id', $agentIds);
355 + }
356 + }
221 357 } else {
222 - //Apply filter, get only assigned ticket
223 - $query->where($filterKey, $filterValue);
358 + // Single value (backward compatibility)
359 + if ($filterValue == 'unassigned') {
360 + $query->whereNull($filterKey);
361 + } else {
362 + if (defined('FLUENTSUPPORTPRO')) {
363 + if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
364 + $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
365 + $query->whereIn('id', $watcherTickets);
366 + } else {
367 + //Apply filter, get only assigned ticket
368 + $query->where($filterKey, $filterValue);
369 + }
370 + } else {
371 + $query->where($filterKey, $filterValue);
372 + }
373 + }
224 374 }
375 + } else if ($filterKey == 'agent_group') {
376 + $groupIds = is_array($filterValue) ? $filterValue : [$filterValue];
377 + $groupIds = array_filter(array_map('intval', $groupIds));
378 + if (!empty($groupIds)) {
379 + $agentIds = TagPivot::where('source_type', 'agent_group')
380 + ->whereIn('tag_id', $groupIds)
381 + ->pluck('source_id')
382 + ->toArray();
383 + if ($agentIds) {
384 + $query->whereIn('agent_id', $agentIds);
385 + } else {
386 + $query->whereRaw('1 = 0');
387 + }
388 + }
225 389 } else if ($filterKey == 'ticket_tags') {
226 390 if (!$filterValue) {
227 391 continue;
228 392 }
@@ -351,23 +515,23 @@
351 515 break;
352 516
353 517 case 'days_before':
354 518 $filter['operator'] = '<';
355 - $filter['value'] = date('Y-m-d', current_time('timestamp') - $filter['value'] * 24 * 60 * 60);
519 + $filter['value'] = gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60);
356 520 break;
357 521
358 522 case 'days_within':
359 523 $filter['operator'] = 'BETWEEN';
360 524 $filter['value'] = [
361 - date('Y-m-d', current_time('timestamp') - $filter['value'] * 24 * 60 * 60),
362 - date('Y-m-d') . ' 23:59:59'
525 + gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60),
526 + gmdate('Y-m-d') . ' 23:59:59'
363 527 ];
364 528 break;
365 529 case 'date_range':
366 530 $filter['operator'] = 'BETWEEN';
367 - if(isset($filter['value'][0]))
368 - $filter['value'][0] .= ' 00:00:00';
369 - if(isset($filter['value'][1]))
531 + if (isset($filter['value'][0]))
532 + $filter['value'][0] .= ' 00:00:00';
533 + if (isset($filter['value'][1]))
370 534 $filter['value'][1] .= ' 23:59:59';
371 535 break;
372 536 }
373 537
@@ -439,27 +603,23 @@
439 603 if (in_array($filter['property'], ['tags', 'product'])) {
440 604 $subField = $filter['property'] == 'tags' ? 'tag_id' : 'product_id';
441 605 list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter);
442 606 $query = static::buildRelationFilterQuery($filter['property'], $query, $method, $subMethod, $subField, $filter);
443 - }
444 -
445 - elseif ($filter['property'] == 'waiting_for_reply'){
446 - if (($filter['value'] == 'yes' && $filter['operator'] == '=') || ($filter['value'] == 'no' && $filter['operator'] == '!=')){
447 - $query = $query->where(function ($q){
448 - $q->whereColumn('last_agent_response', '<' ,'last_customer_response')
607 + } elseif ($filter['property'] == 'waiting_for_reply') {
608 + if (($filter['value'] == 'yes' && $filter['operator'] == 'in') || ($filter['value'] == 'no' && $filter['operator'] == 'not_in')) {
609 + $query = $query->where(function ($q) {
610 + $q->whereColumn('last_agent_response', '<', 'last_customer_response')
449 611 ->orWhereNull('last_agent_response')
450 612 ->orWhere('status', 'new');
451 613 });
452 614 } else {
453 615 $query = $query->where(function ($q) {
454 - $q->whereColumn('last_customer_response', '<' ,'last_agent_response');
616 + $q->whereColumn('last_customer_response', '<', 'last_agent_response');
455 617 });
456 618 }
457 - }
458 -
459 - else {
619 + } else {
460 620 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
461 - $query = $query->{$method}($filter['property'], (array) $filter['value']);
621 + $query = $query->{$method}($filter['property'], (array)$filter['value']);
462 622 }
463 623 return $query;
464 624 }
465 625
@@ -472,14 +632,24 @@
472 632 * @return ModelQueryBuilder
473 633 */
474 634 public function buildSearchableQuery($provider, $query, $search, $operator = 'LIKE')
475 635 {
476 - $fields = $this->searchable;
636 + switch ($provider) {
637 + case 'customer':
638 + $fields = (new Customer())->getSearchableFields();
639 + break;
640 + case 'agent':
641 + $fields = (new Agent())->getSearchableFields();
642 + break;
643 + default:
644 + $fields = $this->searchable;
645 + break;
646 + }
477 647
478 648 $query->whereHas($provider, function ($query) use ($fields, $search, $operator) {
479 649 $query->where(array_shift($fields), $operator, $search);
480 650
481 - $nameArray = explode(' ', $search);
651 + $nameArray = explode(' ', (string) $search);
482 652
483 653 if (count($nameArray) >= 2) {
484 654 $query->orWhere(function ($q) use ($nameArray, $operator) {
485 655 $firstName = array_shift($nameArray);
@@ -506,46 +676,33 @@
506 676 * @return ModelQueryBuilder
507 677 */
508 678 public function filterTicketByUser($provider, $query, $filters)
509 679 {
510 - foreach ($filters as $index=>$filter) {
511 - if ($filter['operator']=='in' || $filter['operator']=='not_in') {
680 + foreach ($filters as $filter) {
681 + if ($filter['operator'] == 'in' || $filter['operator'] == 'not_in') {
512 682 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
513 683 $query = $query->whereHas($provider, function ($q) use ($method, $filter) {
514 - $q->{$method}($filter['property'], $filter['value']);
684 + $q->{$method}($filter['property'], $filter['value']);
515 685 });
516 686 }
517 - elseif ($filter['operator'] == 'contains') {
518 - $operator = 'LIKE';
519 - $searchTerm = '%' . $filter['value'] . '%';
520 - $query = $this->buildSearchableQuery($provider, $query, $searchTerm, $operator);
521 - } elseif ($filter['operator'] == 'not_contains') {
522 - $operator = 'NOT LIKE';
523 - $searchTerm = '%' . $filter['value'] . '%';
524 - $query = $this->buildSearchableQuery($provider, $query, $searchTerm, $operator);
687 +
688 + if ($filter['operator'] == 'contains' || $filter['operator'] == 'not_contains') {
689 + $operator = $filter['operator'] == 'contains' ? 'LIKE' : 'NOT LIKE';
690 + $query->whereHas($provider, function ($q) use ($operator, $filter) {
691 + $q->where($filter['property'], $operator, '%' . $filter['value'] . '%');
692 + });
525 693 }
526 694
527 - if ($filter['operator'] == '=') {
528 - $query->whereHas($provider, function ($q) use ($index, $filter) {
529 - if ($index == 0){
530 - $q->where($filter['property'], '=', $filter['value']);
531 - }else{
532 - $q->orWhere($filter['property'], '=', $filter['value']);
533 - }
695 + if ($filter['operator'] == '=' || $filter['operator'] == '!=') {
696 + $operator = $filter['operator'];
697 + $query->whereHas($provider, function ($q) use ($operator, $filter) {
698 + $q->where($filter['property'], $operator, $filter['value']);
534 699 });
535 - } elseif ($filter['operator'] == '!=') {
536 - $query->whereHas($provider, function ($q) use ($index, $filter) {
537 - if ($index == 0){
538 - $q->where($filter['property'], '!=', $filter['value']);
539 - }else{
540 - $q->orWhere($filter['property'], '!=', $filter['value']);
541 - }
542 - });
543 -
544 700 }
545 - return $query;
546 701 }
702 + return $query;
547 703 }
704 +
548 705 /**
549 706 * One2Many: Customer has to many Click Tickets
550 707 * @return Model Collection
551 708 */
@@ -554,9 +711,10 @@
554 711 $class = __NAMESPACE__ . '\Conversation';
555 712
556 713 return $this->hasMany(
557 714 $class, 'ticket_id', 'id'
558 - );
715 + )->orderBy('created_at', 'desc')
716 + ->orderBy('id', 'desc');
559 717 }
560 718
561 719 public function preview_response()
562 720 {
@@ -575,9 +733,17 @@
575 733 $class, 'fs_tag_pivot', 'source_id', 'tag_id'
576 734 )->wherePivot('source_type', 'ticket_tag');
577 735 }
578 736
737 + public function watchers()
738 + {
739 + $class = __NAMESPACE__ . '\TagPivot';
579 740
741 + return $this->hasMany($class, 'source_id', 'id')
742 + ->where('source_type', 'ticket_watcher')
743 + ->select(['tag_id']);
744 + }
745 +
580 746 /**
581 747 * One2one: Customer has to many Click Tickets
582 748 * @return Model Collection
583 749 */
@@ -611,8 +777,17 @@
611 777 $class, 'closed_by', 'id'
612 778 );
613 779 }
614 780
781 + public function created_by_person()
782 + {
783 + $class = __NAMESPACE__ . '\Agent';
784 +
785 + return $this->belongsTo(
786 + $class, 'created_by', 'id'
787 + );
788 + }
789 +
615 790 public function product()
616 791 {
617 792 $class = __NAMESPACE__ . '\Product';
618 793
@@ -639,18 +814,80 @@
639 814 * @since v1.0.0
640 815 * @param object $ticket
641 816 */
642 817 do_action('fluent_support/deleting_ticket', $this);
643 - // delete the responses first
644 - Conversation::where('ticket_id', $this->id)->delete();
645 - // Delete the ticket meta
646 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)->delete();
647 - // Delete attachments related to this ticket
648 - Attachment::where('ticket_id', $this->id)->delete();
649 818 // Delete the ticket
650 - Ticket::where('id', $this->id)->delete();
819 + $this->delete();
651 820 }
652 821
822 + public static function getNextSerialNumber()
823 + {
824 + $businessSettings = Helper::getOption('global_business_settings', []);
825 + $minNumber = (int) ($businessSettings['min_serial_number'] ?? 1);
826 + $minNumber = (int) apply_filters('fluent_support/min_serial_number', $minNumber);
827 +
828 + try {
829 + $lastTicketNumber = self::query()->max('serial_number');
830 + } catch (\Exception $e) {
831 + $lastTicketNumber = null;
832 + }
833 +
834 + $nextNumber = ((int) $lastTicketNumber) + 1;
835 +
836 + return max($nextNumber, $minNumber);
837 + }
838 +
839 + public static function isMinimumSerialNumberEnabled()
840 + {
841 + $businessSettings = Helper::getOption('global_business_settings', []);
842 + return ($businessSettings['enable_min_serial_number'] ?? 'no') === 'yes';
843 + }
844 +
845 + public static function getTicketPrefix($ticket = null)
846 + {
847 + $businessSettings = Helper::getOption('global_business_settings', []);
848 + $prefix = self::isMinimumSerialNumberEnabled() ? trim((string) ($businessSettings['ticket_prefix'] ?? '')) : '';
849 +
850 + $productId = $ticket ? $ticket->product_id : null;
851 +
852 + return apply_filters('fluent_support/ticket_prefix', $prefix, $ticket, $productId);
853 + }
854 +
855 + public function getDisplayTicketNumberAttribute()
856 + {
857 + return $this->ticket_number ?: ($this->serial_number ?: $this->id);
858 + }
859 +
860 + public function scopeWherePublicIdentifier($query, $identifier)
861 + {
862 + return $query->where('serial_number', $identifier);
863 + }
864 +
865 + protected function assignTicketNumber()
866 + {
867 + for ($attempt = 0; $attempt < 5; $attempt++) {
868 + $nextNumber = $this->serial_number ?: (self::isMinimumSerialNumberEnabled() ? self::getNextSerialNumber() : $this->id);
869 + $ticketNumber = $this->ticket_number ?: (self::getTicketPrefix($this) . $nextNumber);
870 +
871 + try {
872 + self::where('id', $this->id)->update([
873 + 'serial_number' => $nextNumber,
874 + 'ticket_number' => $ticketNumber
875 + ]);
876 + $this->serial_number = $nextNumber;
877 + $this->ticket_number = $ticketNumber;
878 +
879 + return $nextNumber;
880 + } catch (\Exception $e) {
881 + if (stripos($e->getMessage(), 'duplicate') === false) {
882 + throw $e;
883 + }
884 + }
885 + }
886 +
887 + throw new \RuntimeException('Could not allocate a unique ticket number.');
888 + }
889 +
653 890 public static function slugify($title)
654 891 {
655 892 $slug = sanitize_title($title, 'support-ticket-' . time(), 'display');
656 893 if (Ticket::where('slug', $slug)->first()) {
@@ -712,14 +949,18 @@
712 949
713 950 if ($value) {
714 951 if (in_array($fieldType, $customRenderers) && $rendered) {
715 952 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
716 - } else if ($fieldType == 'checkbox') {
953 + } else if (in_array($fieldType, ['checkbox', 'date-range'])) {
717 954 $value = array_values(array_filter(explode('|', $value)));
718 955 }
956 +
957 + if (!is_array($value) && !is_object($value)) {
958 + $formattedData[$dataKey] = links_add_target(make_clickable($value));
959 + } else {
960 + $formattedData[$dataKey] = $value;
961 + }
719 962 }
720 -
721 - $formattedData[$dataKey] = $value;
722 963 }
723 964
724 965 return $formattedData;
725 966 }
@@ -744,14 +985,23 @@
744 985
745 986 $validData = Arr::only($data, $keys);
746 987
747 988 foreach ($validData as $dataKey => $validDatum) {
989 + if (empty($validDatum)) {
990 + Meta::where('object_type', 'ticket_meta')
991 + ->where('object_id', $this->id)
992 + ->where('key', $dataKey)
993 + ->delete();
994 + continue;
995 + }
996 +
748 997 if ($fields[$dataKey]['type'] == 'checkbox' || is_array($validDatum)) {
749 998 $validDatum = implode('|', $validDatum);
750 999 $validDatum = '|' . $validDatum . '|';
751 1000 }
752 1001
753 - $exist = Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
1002 + $exist = Meta::where('object_type', 'ticket_meta')
1003 + ->where('object_id', $this->id)
754 1004 ->where('key', $dataKey)
755 1005 ->first();
756 1006
757 1007 if ($exist) {
@@ -766,23 +1016,14 @@
766 1016 ]);
767 1017 }
768 1018 }
769 1019
770 - // maybe delete data
771 - $deletedSlugs = array_diff($keys, array_keys($validData));
772 -
773 - if ($deletedSlugs) {
774 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
775 - ->whereIn('key', $deletedSlugs)
776 - ->delete();
777 - }
778 -
779 1020 return true;
780 1021 }
781 1022
782 1023 public function getLastAgentResponse()
783 1024 {
784 - $query = \FluentSupport\App\App::db()->table('fs_conversations')
1025 + $query = \FluentSupport\App\App::db()->table('fs_conversations')
785 1026 ->select(['fs_conversations.*'])
786 1027 ->where('fs_conversations.conversation_type', 'response')
787 1028 ->where('fs_conversations.ticket_id', $this->id)
788 1029 ->where('fs_persons.person_type', '=', 'agent')
@@ -796,9 +1037,9 @@
796 1037 {
797 1038 return \FluentSupport\App\App::db()->table('fs_conversations')
798 1039 ->where('ticket_id', $this->id)
799 1040 ->where('conversation_type', 'response')
800 - ->orderBy('id', 'DESC')
1041 + ->latest('id')
801 1042 ->first();
802 1043 }
803 1044
804 1045 /**
@@ -808,8 +1049,13 @@
808 1049 */
809 1050 public function applyTags($tagIds)
810 1051 {
811 1052 $result = false;
1053 +
1054 + if (!is_array($tagIds)) {
1055 + $tagIds = array($tagIds);
1056 + }
1057 +
812 1058 foreach ($tagIds as $tagId) {
813 1059 if (!$this->hasTag($tagId)) {
814 1060 $this->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
815 1061 $result = true;
@@ -825,8 +1071,9 @@
825 1071 }
826 1072 }
827 1073 return $result;
828 1074 }
1075 +
829 1076 /**
830 1077 * This method will remove tags from ticket
831 1078 * @param $tagIds This is the array of tag ids that will be removed from the ticket
832 1079 * @return \FluentSupport\App\Models\Ticket
@@ -833,8 +1080,13 @@
833 1080 */
834 1081 public function detachTags($tagIds)
835 1082 {
836 1083 $result = false;
1084 +
1085 + if (!is_array($tagIds)) {
1086 + $tagIds = array($tagIds);
1087 + }
1088 +
837 1089 foreach ($tagIds as $tagId) {
838 1090 if ($this->hasTag($tagId)) {
839 1091 $this->tags()->detach($tagId);
840 1092
@@ -850,5 +1102,136 @@
850 1102 }
851 1103 }
852 1104 return $result;
853 1105 }
1106 +
1107 + /**
1108 + * @deprecated Use TicketService::storeTicket() instead.
1109 + */
1110 + public function createTicket($ticketData, $maybeNewCustomer = false)
1111 + {
1112 + _deprecated_function(__METHOD__, '2.0.5', 'TicketService::storeTicket()');
1113 +
1114 + if (empty($ticketData['customer_id']) && $maybeNewCustomer) {
1115 + $email = Arr::get($maybeNewCustomer, 'email');
1116 + if (!$email || !is_email($email)) {
1117 + return new \WP_Error('error', 'A valid email is required to create a ticket');
1118 + }
1119 +
1120 + $existingCustomer = Customer::where('email', $email)->first();
1121 + if ($existingCustomer) {
1122 + $ticketData['customer_id'] = $existingCustomer->id;
1123 + } else {
1124 + $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
1125 + $customerData = array_filter($customerData);
1126 + $createCustomer = Customer::create($customerData);
1127 + if (!$createCustomer) {
1128 + return new \WP_Error('error', 'Customer could not be created');
1129 + }
1130 + $ticketData['customer_id'] = $createCustomer->id;
1131 + }
1132 + }
1133 +
1134 + if (empty($ticketData['customer_id'])) {
1135 + return new \WP_Error('error', 'Ticket could not be created');
1136 + }
1137 +
1138 + $customer = Customer::findOrFail($ticketData['customer_id']);
1139 +
1140 + return (new TicketService())->storeTicket($ticketData, $customer);
1141 + }
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 +
1151 + public static function countTicketByMailBoxId($mailbox_id)
1152 + {
1153 + return self::where('mailbox_id', $mailbox_id)->count();
1154 + }
1155 +
1156 + public static function syncMailBoxId($mailbox_id, $fallback_id)
1157 + {
1158 + return self::where('mailbox_id', $mailbox_id)
1159 + ->update([
1160 + 'mailbox_id' => $fallback_id
1161 + ]);
1162 + }
1163 +
1164 + public static function getTicketsQuery()
1165 + {
1166 + return self::with([
1167 + 'customer' => function ($query) {
1168 + $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
1169 + }, 'agent' => function ($query) {
1170 + $query->select(['first_name', 'last_name', 'id']);
1171 + },
1172 + 'product',
1173 + 'tags',
1174 + 'preview_response' => function ($query) {
1175 + $query->latest('id');
1176 + }
1177 + ]);
1178 + }
1179 +
1180 + public function getSettingsValue($valueKey = false, $default = false)
1181 + {
1182 + $exist = Meta::where('object_type', 'ticket')
1183 + ->where('key', 'settings')
1184 + ->where('object_id', $this->id)
1185 + ->first();
1186 +
1187 + if ($exist) {
1188 + $value = Helper::safeUnserialize($exist->value);
1189 + if ($valueKey) {
1190 + if (!is_array($value)) {
1191 + return $default;
1192 + }
1193 + return Arr::get($value, $valueKey, $default);
1194 + }
1195 + return $value;
1196 + }
1197 +
1198 + return $default;
1199 + }
1200 +
1201 + public function updateSettingsValue($valueKey, $value)
1202 + {
1203 + $exist = Meta::where('object_type', 'ticket')
1204 + ->where('key', 'settings')
1205 + ->where('object_id', $this->id)
1206 + ->first();
1207 +
1208 + if ($exist) {
1209 + $existingValue = Helper::safeUnserialize($exist->value);
1210 +
1211 + if (!is_array($existingValue)) {
1212 + $existingValue = [];
1213 + }
1214 +
1215 + $existingValue[$valueKey] = $value;
1216 +
1217 + $exist->value = maybe_serialize($existingValue);
1218 + $exist->save();
1219 + return $this;
1220 + }
1221 +
1222 + $settings = [
1223 + 'object_type' => 'ticket',
1224 + 'key' => 'settings',
1225 + 'object_id' => $this->id,
1226 + 'value' => maybe_serialize([
1227 + $valueKey => $value
1228 + ])
1229 + ];
1230 +
1231 + Meta::create($settings);
1232 +
1233 + return $this;
1234 +
1235 + }
1236 +
854 1237 }