PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.2
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 1.5.6 All 67 releases
fluent-support / app / Models / Ticket.php

Ticket.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.3.2, at app/Models/Ticket.php

1,220 lines 39.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Models;
4
5 use Exception;
6 use FluentSupport\App\Modules\PermissionManager;
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;
11 use FluentSupport\Framework\Support\Arr;
12
13 class Ticket extends Model
14 {
15 protected $table = 'fs_tickets';
16
17 protected $dates = ['waiting_since'];
18
19 protected $appends = ['display_ticket_number'];
20
21 /**
22 * The attributes that are mass assignable.
23 *
24 * @var array
25 */
26 protected $fillable = [
27 'customer_id',
28 'agent_id',
29 'product_id',
30 'mailbox_id',
31 'product_source',
32 'privacy',
33 'priority',
34 'client_priority',
35 'status',
36 'title',
37 'slug',
38 'hash',
39 'source',
40 'message_id',
41 'content',
42 'last_agent_response',
43 'last_customer_response',
44 'waiting_since',
45 'response_count',
46 'first_response_time',
47 'total_close_time',
48 'resolved_at',
49 'closed_by',
50 'created_by',
51 'serial_number',
52 'ticket_number'
53 ];
54
55 public static function boot()
56 {
57 parent::boot();
58
59 static::creating(function ($model) {
60 if (empty($model->slug)) {
61 $model->slug = static::slugify($model->title);
62 }
63
64 $model->hash = bin2hex(random_bytes(16));
65 $model->content_hash = md5($model->content);
66
67 $model->last_customer_response = current_time('mysql');
68 $model->created_at = current_time('mysql');
69 $model->updated_at = current_time('mysql');
70 $model->waiting_since = current_time('mysql');
71
72 });
73
74 static::created(function ($model) {
75 if (empty($model->serial_number) || empty($model->ticket_number)) {
76 $model->assignTicketNumber();
77 }
78 });
79
80 static::deleting(function ($model) {
81 //Delete the ticket meta
82 Meta::where('object_type', 'ticket_meta')->where('object_id', $model->id)->delete();
83 //Delete all cc info for the ticket
84 Meta::where('object_type', 'ticket')->where('object_id', $model->id)->delete();
85 //Delete draft info
86 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 Notification::deleteByTicketId($model->id);
89 //delete the responses first (their attachments are cleaned up by Conversation::deleting)
90 Conversation::deleteAll($model->id);
91 // Delete ticket-level attachments (conversation_id IS NULL) and remove the ticket upload directory
92 $class = __NAMESPACE__ . '\Attachment';
93 $ticketAttachments = $class::where('ticket_id', $model->id)->whereNull('conversation_id')->get();
94 $class::purgeAttachments($ticketAttachments, $model->id);
95 $class::where('ticket_id', $model->id)->whereNull('conversation_id')->delete();
96 });
97 }
98
99 /**
100 * $searchable Columns in table to search
101 * @var array
102 */
103 protected $searchable = [
104 'content',
105 'title',
106 'slug',
107 'id',
108 'serial_number',
109 'ticket_number'
110 ];
111
112 /**
113 * Local scope to filter tickets by search/query string
114 * @param ModelQueryBuilder $query
115 * @param string $search
116 * @return ModelQueryBuilder
117 */
118 public function scopeSearchBy($query, $search)
119 {
120
121 if(!$search) {
122 return $query;
123 }
124
125 if (strpos($search, ':')) {
126 $array = explode(':', (string) $search);
127 $column = $array[0];
128 $value = $array[1];
129 $columns = $this->fillable;
130 $columns[] = 'id';
131
132 if (in_array($column, $columns) && $value) {
133 if (is_numeric($value)) {
134 $query->where($column, $value);
135 } else {
136 $query->where($column, 'LIKE', "%$value%");
137 }
138 return $query;
139 }
140 }
141
142 $fields = $this->searchable;
143 $query->where(function ($query) use ($fields, $search) {
144 $query->where(array_shift($fields), 'LIKE', "%$search%");
145 foreach ($fields as $field) {
146 $query->orWhere($field, 'LIKE', "%$search%");
147 }
148 });
149
150 return $query;
151 }
152
153 /**
154 * Local scope to filter tickets by different filtering condition
155 * @param ModelQueryBuilder $query
156 * @param mixed $search
157 * @return ModelQueryBuilder
158 */
159
160 public function doSearchForAdvancedFilter($query, $search)
161 {
162 foreach ($search as $s) {
163 $operator = $s['operator'];
164 //If selected item for ticket either title or content
165 if (in_array($s['property'], ['title', 'content'])) {
166 //If the selected condition is contains, query operator id LIKE
167 if ($operator == 'contains') {
168 $query = $query->where(function ($query) use ($s) {
169 $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
170 });
171 } elseif ($operator == 'not_contains') {
172 //If the selected condition is not_contains, query operator id NOT LIKE
173 $query = $query->where(function ($query) use ($s) {
174 $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
175 });
176 }
177 }
178
179 //If selected item is Ticket Conversation Content
180 if ($s['property'] == 'conversation_content') {
181 $operator = $s['operator'];
182 if ($operator == 'contains') {
183 $query = $query->whereHas('responses', function ($q) use ($s) {
184 $q->where('content', 'LIKE', "%" . $s['value'] . "%");
185 });
186
187 } else if ($operator == 'not_contains') {
188 $query = $query->whereHas('responses', function ($q) use ($s) {
189 $q->where('content', 'NOT LIKE', "%" . $s['value'] . "%");
190 });
191 }
192 }
193
194 //If selected item is Ticket created or Last Response or Customer Waiting For, or Last Agent Response or Last Customer Response
195 if (in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])) {
196 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
197 }
198
199 //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
200 if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
201 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
202 }
203 }
204 return $query;
205 }
206
207 /**
208 * Local scope to filter subscribers by search/query string
209 * @param ModelQueryBuilder $query
210 * @param array $statuses
211 * @return ModelQueryBuilder
212 */
213 public function scopeFilterByStatues($query, $statuses)
214 {
215 if ($statuses) {
216 $query->whereIn('status', $statuses);
217 }
218
219 return $query;
220 }
221
222 /**
223 * Who replied last on this ticket, derived from the already-loaded
224 * last_agent_response / last_customer_response timestamp columns.
225 *
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 * @return string|null
235 */
236 public function getLastReplyByAttribute()
237 {
238 $agentAt = $this->last_agent_response;
239 $customerAt = $this->last_customer_response;
240
241 if (!$agentAt && !$customerAt) {
242 return null;
243 }
244 if (!$agentAt) {
245 return 'customer';
246 }
247 if (!$customerAt) {
248 return 'agent';
249 }
250
251 // Tie (same second) resolves to 'customer' — the waiting bias used by scopeWaitingOnly.
252 return strtotime($customerAt) >= strtotime($agentAt) ? 'customer' : 'agent';
253 }
254
255 /**
256 * Local scope to filter tickets by not response by agent
257 * @param $query
258 * @return mixed
259 */
260 public function scopeWaitingOnly($query)
261 {
262 $query->where(function ($q) {
263 $q->whereColumn('last_agent_response', '<', 'last_customer_response')
264 ->orWhereNull('last_agent_response')
265 ->orWhere('status', 'new');
266 });
267 return $query;
268 }
269
270 /**
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 * @param $query
274 * @param $filters
275 * @return ModelQueryBuilder
276 */
277 public function scopeApplyFilters($query, $filters)
278 {
279 $supportedColumns = ['product_id', 'client_priority', 'priority', 'mailbox_id'];
280 foreach ($filters as $filterKey => $filterValue) {
281 if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
282 continue;
283 }
284 //If filer using status
285 if ($filterKey == 'status_type') {
286 //Get list of ticket status
287 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
288 if ($statusArray) {
289 //Apply filet where status in
290 $query->whereIn('status', $statusArray);
291 }
292 } else if (in_array($filterKey, $supportedColumns)) {
293 // Use whereIn for all supported columns (they all now support multi-select)
294 if (is_array($filterValue)) {
295 $query->whereIn($filterKey, $filterValue);
296 } else {
297 $query->where($filterKey, $filterValue);
298 }
299 } else if ($filterKey == 'waiting_for_reply') {
300 if ($filterValue != 'yes') {
301 continue;
302 }
303 //Apply filter where no response by agent
304 $query = $this->scopeWaitingOnly($query);
305 } else if ($filterKey == 'agent_id') {
306 // Handle array of agent IDs for multi-select
307 if (is_array($filterValue)) {
308 // Check if 'unassigned' is in the array
309 $hasUnassigned = in_array('unassigned', $filterValue);
310 $agentIds = array_filter($filterValue, function($v) {
311 return $v !== 'unassigned';
312 });
313
314 if ($hasUnassigned && !empty($agentIds)) {
315 // Include both unassigned and specific agents
316 $query->where(function($q) use ($agentIds) {
317 $q->whereNull('agent_id')
318 ->orWhereIn('agent_id', $agentIds);
319 });
320 } elseif ($hasUnassigned) {
321 // Only unassigned
322 $query->whereNull('agent_id');
323 } elseif (!empty($agentIds)) {
324 // Only specific agents
325 if (defined('FLUENTSUPPORTPRO')) {
326 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
327 $watcherTickets = [];
328 foreach ($agentIds as $agentId) {
329 $watcherTickets = array_merge($watcherTickets, TicketHelper::getWatcherTicketIds($agentId));
330 }
331 $query->whereIn('id', array_unique($watcherTickets));
332 } else {
333 $query->whereIn('agent_id', $agentIds);
334 }
335 } else {
336 $query->whereIn('agent_id', $agentIds);
337 }
338 }
339 } else {
340 // Single value (backward compatibility)
341 if ($filterValue == 'unassigned') {
342 $query->whereNull($filterKey);
343 } else {
344 if (defined('FLUENTSUPPORTPRO')) {
345 if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
346 $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
347 $query->whereIn('id', $watcherTickets);
348 } else {
349 //Apply filter, get only assigned ticket
350 $query->where($filterKey, $filterValue);
351 }
352 } else {
353 $query->where($filterKey, $filterValue);
354 }
355 }
356 }
357 } else if ($filterKey == 'agent_group') {
358 $groupIds = is_array($filterValue) ? $filterValue : [$filterValue];
359 $groupIds = array_filter(array_map('intval', $groupIds));
360 if (!empty($groupIds)) {
361 $agentIds = TagPivot::where('source_type', 'agent_group')
362 ->whereIn('tag_id', $groupIds)
363 ->pluck('source_id')
364 ->toArray();
365 if ($agentIds) {
366 $query->whereIn('agent_id', $agentIds);
367 } else {
368 $query->whereRaw('1 = 0');
369 }
370 }
371 } else if ($filterKey == 'ticket_tags') {
372 if (!$filterValue) {
373 continue;
374 }
375 //Apply filter where ticket only has this tag id
376 $query->whereHas('tags', function ($q) use ($filterValue) {
377 $q->whereIn('tag_id', $filterValue);
378 });
379 }
380 }
381
382 return $query;
383 }
384
385 /**
386 * Local scope to filter tickets by agent id
387 * @param ModelQueryBuilder $query
388 * @param int $agentId
389 * @return ModelQueryBuilder
390 */
391 public function scopeFilterByAgentId($query, $agentId)
392 {
393 if ($agentId) {
394 $query->where('agent_id', $agentId);
395 }
396
397 return $query;
398 }
399
400 /**
401 * Local scope to filter subscribers by search/query string
402 * @param ModelQueryBuilder $query
403 * @param int $customerId
404 * @return ModelQueryBuilder
405 */
406 public function scopeFilterByCustomerId($query, $customerId)
407 {
408 $query->where('customer_id', $customerId);
409
410 return $query;
411 }
412
413 /**
414 * Local scope to filter subscribers by search/query string
415 * @param ModelQueryBuilder $query
416 * @param int $productId
417 * @return ModelQueryBuilder
418 */
419 public function scopeFilterByProductId($query, $productId)
420 {
421 if ($productId) {
422 $query->where('product_id', $productId);
423 }
424
425 return $query;
426 }
427
428 /**
429 * Local scope to filter subscribers by search/query string
430 * @param ModelQueryBuilder $query
431 * @param array $priorities
432 * @return ModelQueryBuilder
433 */
434 public function scopeFilterByPriorities($query, $priorities)
435 {
436 if ($priorities) {
437 $query->whereIn('priority', $priorities);
438 }
439
440 return $query;
441 }
442
443 /**
444 * @param $filter
445 * @return string[]
446 */
447
448 public static function parseRelationalFilterQueryMethods($filter)
449 {
450 // default operator = in
451 $method = 'whereHas';
452 $subMethod = 'whereIn';
453
454 switch ($filter['operator']) {
455 case 'not_in':
456 $method = 'whereDoesntHave';
457 $subMethod = 'whereIn';
458
459 break;
460 case 'in_all':
461 $method = 'whereHas';
462 $subMethod = 'where';
463
464 break;
465 case 'not_in_all':
466 $method = 'whereDoesntHave';
467 $subMethod = 'where';
468
469 break;
470 }
471
472 return [$method, $subMethod];
473 }
474
475 /**
476 * Parse filter to set proper operator and value for the filter query.
477 *
478 * @param array $filter
479 * @return array
480 */
481 public static function filterParser($filter)
482 {
483 switch ($filter['operator']) {
484 case 'before':
485 $filter['operator'] = '<';
486 $filter['value'] = $filter['value'] . ' 23:59:59';
487 break;
488
489 case 'after':
490 $filter['operator'] = '>';
491 $filter['value'] = $filter['value'] . ' 23:59:59';
492 break;
493
494 case 'date_equal':
495 $filter['operator'] = 'LIKE';
496 $filter['value'] = '%' . $filter['value'] . '%';
497 break;
498
499 case 'days_before':
500 $filter['operator'] = '<';
501 $filter['value'] = gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60);
502 break;
503
504 case 'days_within':
505 $filter['operator'] = 'BETWEEN';
506 $filter['value'] = [
507 gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60),
508 gmdate('Y-m-d') . ' 23:59:59'
509 ];
510 break;
511 case 'date_range':
512 $filter['operator'] = 'BETWEEN';
513 if (isset($filter['value'][0]))
514 $filter['value'][0] .= ' 00:00:00';
515 if (isset($filter['value'][1]))
516 $filter['value'][1] .= ' 23:59:59';
517 break;
518 }
519
520 return $filter;
521 }
522
523 /**
524 * @param \FluentSupport\Framework\Database\Orm\Builder|\FluentSupport\Framework\Database\Query\Builder $query
525 * @param array $filters
526 * @return ModelQueryBuilder
527 */
528 public function buildDateBaseFilterQuery($query, $filters)
529 {
530 $filter = static::filterParser($filters);
531 $query->where(function ($dateQuery) use ($filter) {
532
533 if ($filter['operator'] == 'BETWEEN') {
534 $dateQuery->whereBetween($filter['property'], $filter['value']);
535 } else {
536 $dateQuery->where($filter['property'], $filter['operator'], $filter['value']);
537 }
538 });
539
540 return $query;
541 }
542
543 /**
544 * Relation builder
545 * @param $relation
546 * @param $query
547 * @param $method
548 * @param $subMethod
549 * @param $subField
550 * @param $filter
551 * @param false $provider
552 * @return ModelQueryBuilder
553 */
554
555 public static function buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, $filter, $provider = false)
556 {
557 if (in_array($filter['operator'], ['in_all', 'not_in_all']) && $filter['value']) {
558 foreach ($filter['value'] as $item) {
559 $query = static::buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, ['value' => $item, 'operator' => ''], $provider);
560 }
561 } else {
562 $query = $query->{$method}($relation, function ($relationQuery) use ($subMethod, $subField, $filter, $provider) {
563 $relationQuery = $relationQuery->{$subMethod}($subField, $filter['value']);
564
565 if ($provider) {
566 $relationQuery = $relationQuery->where('provider', $provider);
567 }
568
569 return $relationQuery;
570 });
571 }
572
573 return $query;
574 }
575
576 /**
577 * get tickets by advanced filter segment data
578 * @param $query
579 * @param $filter
580 * @return ModelQueryBuilder
581 */
582
583 public function buildPropertiesFilterQuery($query, $filter)
584 {
585 if (in_array($filter['property'], ['tags', 'product'])) {
586 $subField = $filter['property'] == 'tags' ? 'tag_id' : 'product_id';
587 list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter);
588 $query = static::buildRelationFilterQuery($filter['property'], $query, $method, $subMethod, $subField, $filter);
589 } elseif ($filter['property'] == 'waiting_for_reply') {
590 if (($filter['value'] == 'yes' && $filter['operator'] == 'in') || ($filter['value'] == 'no' && $filter['operator'] == 'not_in')) {
591 $query = $query->where(function ($q) {
592 $q->whereColumn('last_agent_response', '<', 'last_customer_response')
593 ->orWhereNull('last_agent_response')
594 ->orWhere('status', 'new');
595 });
596 } else {
597 $query = $query->where(function ($q) {
598 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
599 });
600 }
601 } else {
602 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
603 $query = $query->{$method}($filter['property'], (array)$filter['value']);
604 }
605 return $query;
606 }
607
608 /**
609 * method to search by properties
610 * @param $provider
611 * @param $query
612 * @param $search
613 * @param string $operator
614 * @return ModelQueryBuilder
615 */
616 public function buildSearchableQuery($provider, $query, $search, $operator = 'LIKE')
617 {
618 switch ($provider) {
619 case 'customer':
620 $fields = (new Customer())->getSearchableFields();
621 break;
622 case 'agent':
623 $fields = (new Agent())->getSearchableFields();
624 break;
625 default:
626 $fields = $this->searchable;
627 break;
628 }
629
630 $query->whereHas($provider, function ($query) use ($fields, $search, $operator) {
631 $query->where(array_shift($fields), $operator, $search);
632
633 $nameArray = explode(' ', (string) $search);
634
635 if (count($nameArray) >= 2) {
636 $query->orWhere(function ($q) use ($nameArray, $operator) {
637 $firstName = array_shift($nameArray);
638 $lastName = implode(' ', $nameArray);
639
640 $q->where('first_name', $operator, $firstName);
641 $q->where('last_name', $operator, $lastName);
642 });
643 }
644
645 foreach ($fields as $field) {
646 $query->orWhere($field, $operator, $search);
647 }
648 });
649
650 return $query;
651 }
652
653 /**
654 * Filter by ticket general properties like customer name, agent name etc
655 * @param $provider
656 * @param $query
657 * @param $filters
658 * @return ModelQueryBuilder
659 */
660 public function filterTicketByUser($provider, $query, $filters)
661 {
662 foreach ($filters as $filter) {
663 if ($filter['operator'] == 'in' || $filter['operator'] == 'not_in') {
664 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
665 $query = $query->whereHas($provider, function ($q) use ($method, $filter) {
666 $q->{$method}($filter['property'], $filter['value']);
667 });
668 }
669
670 if ($filter['operator'] == 'contains' || $filter['operator'] == 'not_contains') {
671 $operator = $filter['operator'] == 'contains' ? 'LIKE' : 'NOT LIKE';
672 $query->whereHas($provider, function ($q) use ($operator, $filter) {
673 $q->where($filter['property'], $operator, '%' . $filter['value'] . '%');
674 });
675 }
676
677 if ($filter['operator'] == '=' || $filter['operator'] == '!=') {
678 $operator = $filter['operator'];
679 $query->whereHas($provider, function ($q) use ($operator, $filter) {
680 $q->where($filter['property'], $operator, $filter['value']);
681 });
682 }
683 }
684 return $query;
685 }
686
687 /**
688 * One2Many: Customer has to many Click Tickets
689 * @return Model Collection
690 */
691 public function responses()
692 {
693 $class = __NAMESPACE__ . '\Conversation';
694
695 return $this->hasMany(
696 $class, 'ticket_id', 'id'
697 )->orderBy('created_at', 'desc')
698 ->orderBy('id', 'desc');
699 }
700
701 public function preview_response()
702 {
703 $class = __NAMESPACE__ . '\Conversation';
704
705 return $this->hasOne(
706 $class, 'ticket_id', 'id'
707 );
708 }
709
710 public function tags()
711 {
712 $class = __NAMESPACE__ . '\TicketTag';
713
714 return $this->belongsToMany(
715 $class, 'fs_tag_pivot', 'source_id', 'tag_id'
716 )->wherePivot('source_type', 'ticket_tag');
717 }
718
719 public function watchers()
720 {
721 $class = __NAMESPACE__ . '\TagPivot';
722
723 return $this->hasMany($class, 'source_id', 'id')
724 ->where('source_type', 'ticket_watcher')
725 ->select(['tag_id']);
726 }
727
728 /**
729 * One2one: Customer has to many Click Tickets
730 * @return Model Collection
731 */
732 public function customer()
733 {
734 $class = __NAMESPACE__ . '\Customer';
735
736 return $this->belongsTo(
737 $class, 'customer_id', 'id'
738 );
739 }
740
741 /**
742 * One2one: Customer has to many Click Tickets
743 * @return Model Collection
744 */
745 public function agent()
746 {
747 $class = __NAMESPACE__ . '\Agent';
748
749 return $this->belongsTo(
750 $class, 'agent_id', 'id'
751 );
752 }
753
754 public function closed_by_person()
755 {
756 $class = __NAMESPACE__ . '\Person';
757
758 return $this->belongsTo(
759 $class, 'closed_by', 'id'
760 );
761 }
762
763 public function created_by_person()
764 {
765 $class = __NAMESPACE__ . '\Agent';
766
767 return $this->belongsTo(
768 $class, 'created_by', 'id'
769 );
770 }
771
772 public function product()
773 {
774 $class = __NAMESPACE__ . '\Product';
775
776 return $this->belongsTo(
777 $class, 'product_id', 'id'
778 );
779 }
780
781 public function mailbox()
782 {
783 $class = __NAMESPACE__ . '\MailBox';
784
785 return $this->belongsTo(
786 $class, 'mailbox_id', 'id'
787 );
788 }
789
790
791 public function deleteTicket()
792 {
793 /*
794 * Action on ticket deleting
795 *
796 * @since v1.0.0
797 * @param object $ticket
798 */
799 do_action('fluent_support/deleting_ticket', $this);
800 // Delete the ticket
801 $this->delete();
802 }
803
804 public static function getNextSerialNumber()
805 {
806 $businessSettings = Helper::getOption('global_business_settings', []);
807 $minNumber = (int) ($businessSettings['min_serial_number'] ?? 1);
808 $minNumber = (int) apply_filters('fluent_support/min_serial_number', $minNumber);
809
810 try {
811 $lastTicketNumber = self::query()->max('serial_number');
812 } catch (\Exception $e) {
813 $lastTicketNumber = null;
814 }
815
816 $nextNumber = ((int) $lastTicketNumber) + 1;
817
818 return max($nextNumber, $minNumber);
819 }
820
821 public static function isMinimumSerialNumberEnabled()
822 {
823 $businessSettings = Helper::getOption('global_business_settings', []);
824 return ($businessSettings['enable_min_serial_number'] ?? 'no') === 'yes';
825 }
826
827 public static function getTicketPrefix($ticket = null)
828 {
829 $businessSettings = Helper::getOption('global_business_settings', []);
830 $prefix = self::isMinimumSerialNumberEnabled() ? trim((string) ($businessSettings['ticket_prefix'] ?? '')) : '';
831
832 $productId = $ticket ? $ticket->product_id : null;
833
834 return apply_filters('fluent_support/ticket_prefix', $prefix, $ticket, $productId);
835 }
836
837 public function getDisplayTicketNumberAttribute()
838 {
839 return $this->ticket_number ?: ($this->serial_number ?: $this->id);
840 }
841
842 public function scopeWherePublicIdentifier($query, $identifier)
843 {
844 return $query->where('serial_number', $identifier);
845 }
846
847 protected function assignTicketNumber()
848 {
849 for ($attempt = 0; $attempt < 5; $attempt++) {
850 $nextNumber = $this->serial_number ?: (self::isMinimumSerialNumberEnabled() ? self::getNextSerialNumber() : $this->id);
851 $ticketNumber = $this->ticket_number ?: (self::getTicketPrefix($this) . $nextNumber);
852
853 try {
854 self::where('id', $this->id)->update([
855 'serial_number' => $nextNumber,
856 'ticket_number' => $ticketNumber
857 ]);
858 $this->serial_number = $nextNumber;
859 $this->ticket_number = $ticketNumber;
860
861 return $nextNumber;
862 } catch (\Exception $e) {
863 if (stripos($e->getMessage(), 'duplicate') === false) {
864 throw $e;
865 }
866 }
867 }
868
869 throw new \RuntimeException('Could not allocate a unique ticket number.');
870 }
871
872 public static function slugify($title)
873 {
874 $slug = sanitize_title($title, 'support-ticket-' . time(), 'display');
875 if (Ticket::where('slug', $slug)->first()) {
876 $slug .= '-' . time();
877 }
878 return $slug;
879 }
880
881 public function hasTag($tagId)
882 {
883 $tags = $this->tags;
884 foreach ($tags as $tag) {
885 if ($tag->id == $tagId) {
886 return true;
887 }
888 }
889
890 return false;
891 }
892
893 public function attachments()
894 {
895 $class = __NAMESPACE__ . '\Attachment';
896 return $this->hasMany($class, 'ticket_id', 'id')->where('conversation_id', NULL);
897 }
898
899 public function customData($scope = 'admin', $rendered = false)
900 {
901 if (!defined('FLUENTSUPPORTPRO')) {
902 return [];
903 }
904
905 $fields = \FluentSupportPro\App\Services\CustomFieldsService::getFieldLabels($scope);
906
907 if (!$fields) {
908 return [];
909 }
910
911 $keys = array_keys($fields);
912
913 $customRows = Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
914 ->whereIn('key', $keys)
915 ->get();
916
917 if (!$customRows) {
918 return [];
919 }
920
921 $formattedData = [];
922
923 $customRenderers = \FluentSupportPro\App\Services\CustomFieldsService::getCustomerRenderers();
924
925 foreach ($customRows as $row) {
926 $dataKey = $row->key;
927
928 $value = $row->value;
929
930 $fieldType = $fields[$dataKey]['type'];
931
932 if ($value) {
933 if (in_array($fieldType, $customRenderers) && $rendered) {
934 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
935 } else if ($fieldType == 'checkbox') {
936 $value = array_values(array_filter(explode('|', $value)));
937 }
938
939 if (!is_array($value) && !is_object($value)) {
940 $formattedData[$dataKey] = links_add_target(make_clickable($value));
941 } else {
942 $formattedData[$dataKey] = $value;
943 }
944 }
945 }
946
947 return $formattedData;
948 }
949
950 /**
951 * @param $data This is the data that will be saved to the ticket_meta for custom fields
952 * @return bool
953 */
954 public function syncCustomFields($data)
955 {
956 if (!is_array($data)) {
957 return false;
958 }
959
960 $fields = apply_filters('fluent_support/ticket_custom_fields', []);
961
962 if (!$fields) {
963 return false;
964 }
965
966 $keys = array_keys($fields);
967
968 $validData = Arr::only($data, $keys);
969
970 foreach ($validData as $dataKey => $validDatum) {
971 if (empty($validDatum)) {
972 Meta::where('object_type', 'ticket_meta')
973 ->where('object_id', $this->id)
974 ->where('key', $dataKey)
975 ->delete();
976 continue;
977 }
978
979 if ($fields[$dataKey]['type'] == 'checkbox' || is_array($validDatum)) {
980 $validDatum = implode('|', $validDatum);
981 $validDatum = '|' . $validDatum . '|';
982 }
983
984 $exist = Meta::where('object_type', 'ticket_meta')
985 ->where('object_id', $this->id)
986 ->where('key', $dataKey)
987 ->first();
988
989 if ($exist) {
990 $exist->value = $validDatum;
991 $exist->save();
992 } else {
993 Meta::insert([
994 'object_type' => 'ticket_meta',
995 'object_id' => $this->id,
996 'key' => $dataKey,
997 'value' => $validDatum
998 ]);
999 }
1000 }
1001
1002 return true;
1003 }
1004
1005 public function getLastAgentResponse()
1006 {
1007 $query = \FluentSupport\App\App::db()->table('fs_conversations')
1008 ->select(['fs_conversations.*'])
1009 ->where('fs_conversations.conversation_type', 'response')
1010 ->where('fs_conversations.ticket_id', $this->id)
1011 ->where('fs_persons.person_type', '=', 'agent')
1012 ->join('fs_persons', 'fs_persons.id', '=', 'fs_conversations.person_id')
1013 ->orderBy('fs_conversations.id', 'DESC');
1014
1015 return $query->first();
1016 }
1017
1018 public function getLastResponse()
1019 {
1020 return \FluentSupport\App\App::db()->table('fs_conversations')
1021 ->where('ticket_id', $this->id)
1022 ->where('conversation_type', 'response')
1023 ->latest('id')
1024 ->first();
1025 }
1026
1027 /**
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
1031 */
1032 public function applyTags($tagIds)
1033 {
1034 $result = false;
1035
1036 if (!is_array($tagIds)) {
1037 $tagIds = array($tagIds);
1038 }
1039
1040 foreach ($tagIds as $tagId) {
1041 if (!$this->hasTag($tagId)) {
1042 $this->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
1043 $result = true;
1044
1045 /*
1046 * Action while tag added to ticket
1047 *
1048 * @since v1.0.0
1049 * @param integer $tagId
1050 * @param object $ticket
1051 */
1052 do_action('fluent_support/ticket_tag_added', $tagId, $this);
1053 }
1054 }
1055 return $result;
1056 }
1057
1058 /**
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
1062 */
1063 public function detachTags($tagIds)
1064 {
1065 $result = false;
1066
1067 if (!is_array($tagIds)) {
1068 $tagIds = array($tagIds);
1069 }
1070
1071 foreach ($tagIds as $tagId) {
1072 if ($this->hasTag($tagId)) {
1073 $this->tags()->detach($tagId);
1074
1075 /*
1076 * Action while tag removed from ticket
1077 *
1078 * @since v1.0.0
1079 * @param integer $tagId
1080 * @param object $ticket
1081 */
1082 do_action('fluent_support/ticket_tag_removed', $tagId, $this);
1083 $result = true;
1084 }
1085 }
1086 return $result;
1087 }
1088
1089 /**
1090 * @deprecated Use TicketService::storeTicket() instead.
1091 */
1092 public function createTicket($ticketData, $maybeNewCustomer = false)
1093 {
1094 _deprecated_function(__METHOD__, '2.0.5', 'TicketService::storeTicket()');
1095
1096 if (empty($ticketData['customer_id']) && $maybeNewCustomer) {
1097 $email = Arr::get($maybeNewCustomer, 'email');
1098 if (!$email || !is_email($email)) {
1099 return new \WP_Error('error', 'A valid email is required to create a ticket');
1100 }
1101
1102 $existingCustomer = Customer::where('email', $email)->first();
1103 if ($existingCustomer) {
1104 $ticketData['customer_id'] = $existingCustomer->id;
1105 } else {
1106 $customerData = Arr::only($maybeNewCustomer, (new Customer())->getFillable());
1107 $customerData = array_filter($customerData);
1108 $createCustomer = Customer::create($customerData);
1109 if (!$createCustomer) {
1110 return new \WP_Error('error', 'Customer could not be created');
1111 }
1112 $ticketData['customer_id'] = $createCustomer->id;
1113 }
1114 }
1115
1116 if (empty($ticketData['customer_id'])) {
1117 return new \WP_Error('error', 'Ticket could not be created');
1118 }
1119
1120 $customer = Customer::findOrFail($ticketData['customer_id']);
1121
1122 return (new TicketService())->storeTicket($ticketData, $customer);
1123 }
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
1133 public static function countTicketByMailBoxId($mailbox_id)
1134 {
1135 return self::where('mailbox_id', $mailbox_id)->count();
1136 }
1137
1138 public static function syncMailBoxId($mailbox_id, $fallback_id)
1139 {
1140 return self::where('mailbox_id', $mailbox_id)
1141 ->update([
1142 'mailbox_id' => $fallback_id
1143 ]);
1144 }
1145
1146 public static function getTicketsQuery()
1147 {
1148 return self::with([
1149 'customer' => function ($query) {
1150 $query->select(['first_name', 'last_name', 'email', 'id', 'avatar']);
1151 }, 'agent' => function ($query) {
1152 $query->select(['first_name', 'last_name', 'id']);
1153 },
1154 'product',
1155 'tags',
1156 'preview_response' => function ($query) {
1157 $query->latest('id');
1158 }
1159 ]);
1160 }
1161
1162 public function getSettingsValue($valueKey = false, $default = false)
1163 {
1164 $exist = Meta::where('object_type', 'ticket')
1165 ->where('key', 'settings')
1166 ->where('object_id', $this->id)
1167 ->first();
1168
1169 if ($exist) {
1170 $value = Helper::safeUnserialize($exist->value);
1171 if ($valueKey) {
1172 if (!is_array($value)) {
1173 return $default;
1174 }
1175 return Arr::get($value, $valueKey, $default);
1176 }
1177 return $value;
1178 }
1179
1180 return $default;
1181 }
1182
1183 public function updateSettingsValue($valueKey, $value)
1184 {
1185 $exist = Meta::where('object_type', 'ticket')
1186 ->where('key', 'settings')
1187 ->where('object_id', $this->id)
1188 ->first();
1189
1190 if ($exist) {
1191 $existingValue = Helper::safeUnserialize($exist->value);
1192
1193 if (!is_array($existingValue)) {
1194 $existingValue = [];
1195 }
1196
1197 $existingValue[$valueKey] = $value;
1198
1199 $exist->value = maybe_serialize($existingValue);
1200 $exist->save();
1201 return $this;
1202 }
1203
1204 $settings = [
1205 'object_type' => 'ticket',
1206 'key' => 'settings',
1207 'object_id' => $this->id,
1208 'value' => maybe_serialize([
1209 $valueKey => $value
1210 ])
1211 ];
1212
1213 Meta::create($settings);
1214
1215 return $this;
1216
1217 }
1218
1219 }
1220