PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 trunk, at app/Models/Ticket.php

1,238 lines 40.0 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 /**
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 /**
31 * The attributes that are mass assignable.
32 *
33 * @var array
34 */
35 protected $fillable = [
36 'customer_id',
37 'agent_id',
38 'product_id',
39 'mailbox_id',
40 'product_source',
41 'privacy',
42 'priority',
43 'client_priority',
44 'status',
45 'title',
46 'slug',
47 'hash',
48 'source',
49 'message_id',
50 'content',
51 'last_agent_response',
52 'last_customer_response',
53 'waiting_since',
54 'response_count',
55 'first_response_time',
56 'total_close_time',
57 'resolved_at',
58 'closed_by',
59 'created_by',
60 'serial_number',
61 'ticket_number'
62 ];
63
64 public static function boot()
65 {
66 parent::boot();
67
68 static::creating(function ($model) {
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
76 $model->last_customer_response = current_time('mysql');
77 $model->created_at = current_time('mysql');
78 $model->updated_at = current_time('mysql');
79 $model->waiting_since = current_time('mysql');
80
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 });
115 }
116
117 /**
118 * $searchable Columns in table to search
119 * @var array
120 */
121 protected $searchable = [
122 'content',
123 'title',
124 'slug',
125 'id',
126 'serial_number',
127 'ticket_number'
128 ];
129
130 /**
131 * Local scope to filter tickets by search/query string
132 * @param ModelQueryBuilder $query
133 * @param string $search
134 * @return ModelQueryBuilder
135 */
136 public function scopeSearchBy($query, $search)
137 {
138
139 if(!$search) {
140 return $query;
141 }
142
143 if (strpos($search, ':')) {
144 $array = explode(':', (string) $search);
145 $column = $array[0];
146 $value = $array[1];
147 $columns = $this->fillable;
148 $columns[] = 'id';
149
150 if (in_array($column, $columns) && $value) {
151 if (is_numeric($value)) {
152 $query->where($column, $value);
153 } else {
154 $query->where($column, 'LIKE', "%$value%");
155 }
156 return $query;
157 }
158 }
159
160 $fields = $this->searchable;
161 $query->where(function ($query) use ($fields, $search) {
162 $query->where(array_shift($fields), 'LIKE', "%$search%");
163 foreach ($fields as $field) {
164 $query->orWhere($field, 'LIKE', "%$search%");
165 }
166 });
167
168 return $query;
169 }
170
171 /**
172 * Local scope to filter tickets by different filtering condition
173 * @param ModelQueryBuilder $query
174 * @param mixed $search
175 * @return ModelQueryBuilder
176 */
177
178 public function doSearchForAdvancedFilter($query, $search)
179 {
180 foreach ($search as $s) {
181 $operator = $s['operator'];
182 //If selected item for ticket either title or content
183 if (in_array($s['property'], ['title', 'content'])) {
184 //If the selected condition is contains, query operator id LIKE
185 if ($operator == 'contains') {
186 $query = $query->where(function ($query) use ($s) {
187 $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
188 });
189 } elseif ($operator == 'not_contains') {
190 //If the selected condition is not_contains, query operator id NOT LIKE
191 $query = $query->where(function ($query) use ($s) {
192 $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
193 });
194 }
195 }
196
197 //If selected item is Ticket Conversation Content
198 if ($s['property'] == 'conversation_content') {
199 $operator = $s['operator'];
200 if ($operator == 'contains') {
201 $query = $query->whereHas('responses', function ($q) use ($s) {
202 $q->where('content', 'LIKE', "%" . $s['value'] . "%");
203 });
204
205 } else if ($operator == 'not_contains') {
206 $query = $query->whereHas('responses', function ($q) use ($s) {
207 $q->where('content', 'NOT LIKE', "%" . $s['value'] . "%");
208 });
209 }
210 }
211
212 //If selected item is Ticket created or Last Response or Customer Waiting For, or Last Agent Response or Last Customer Response
213 if (in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])) {
214 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
215 }
216
217 //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
218 if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
219 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
220 }
221 }
222 return $query;
223 }
224
225 /**
226 * Local scope to filter subscribers by search/query string
227 * @param ModelQueryBuilder $query
228 * @param array $statuses
229 * @return ModelQueryBuilder
230 */
231 public function scopeFilterByStatues($query, $statuses)
232 {
233 if ($statuses) {
234 $query->whereIn('status', $statuses);
235 }
236
237 return $query;
238 }
239
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 /**
274 * Local scope to filter tickets by not response by agent
275 * @param $query
276 * @return mixed
277 */
278 public function scopeWaitingOnly($query)
279 {
280 $query->where(function ($q) {
281 $q->whereColumn('last_agent_response', '<', 'last_customer_response')
282 ->orWhereNull('last_agent_response')
283 ->orWhere('status', 'new');
284 });
285 return $query;
286 }
287
288 /**
289 * scopeApplyFilters method will filet ticket based on the selected filters
290 * This method will get filter option as parameter, loop through and apply conditions in query
291 * @param $query
292 * @param $filters
293 * @return ModelQueryBuilder
294 */
295 public function scopeApplyFilters($query, $filters)
296 {
297 $supportedColumns = ['product_id', 'client_priority', 'priority', 'mailbox_id'];
298 foreach ($filters as $filterKey => $filterValue) {
299 if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
300 continue;
301 }
302 //If filer using status
303 if ($filterKey == 'status_type') {
304 //Get list of ticket status
305 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
306 if ($statusArray) {
307 //Apply filet where status in
308 $query->whereIn('status', $statusArray);
309 }
310 } else if (in_array($filterKey, $supportedColumns)) {
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 }
317 } else if ($filterKey == 'waiting_for_reply') {
318 if ($filterValue != 'yes') {
319 continue;
320 }
321 //Apply filter where no response by agent
322 $query = $this->scopeWaitingOnly($query);
323 } else if ($filterKey == 'agent_id') {
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 }
357 } else {
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 }
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 }
389 } else if ($filterKey == 'ticket_tags') {
390 if (!$filterValue) {
391 continue;
392 }
393 //Apply filter where ticket only has this tag id
394 $query->whereHas('tags', function ($q) use ($filterValue) {
395 $q->whereIn('tag_id', $filterValue);
396 });
397 }
398 }
399
400 return $query;
401 }
402
403 /**
404 * Local scope to filter tickets by agent id
405 * @param ModelQueryBuilder $query
406 * @param int $agentId
407 * @return ModelQueryBuilder
408 */
409 public function scopeFilterByAgentId($query, $agentId)
410 {
411 if ($agentId) {
412 $query->where('agent_id', $agentId);
413 }
414
415 return $query;
416 }
417
418 /**
419 * Local scope to filter subscribers by search/query string
420 * @param ModelQueryBuilder $query
421 * @param int $customerId
422 * @return ModelQueryBuilder
423 */
424 public function scopeFilterByCustomerId($query, $customerId)
425 {
426 $query->where('customer_id', $customerId);
427
428 return $query;
429 }
430
431 /**
432 * Local scope to filter subscribers by search/query string
433 * @param ModelQueryBuilder $query
434 * @param int $productId
435 * @return ModelQueryBuilder
436 */
437 public function scopeFilterByProductId($query, $productId)
438 {
439 if ($productId) {
440 $query->where('product_id', $productId);
441 }
442
443 return $query;
444 }
445
446 /**
447 * Local scope to filter subscribers by search/query string
448 * @param ModelQueryBuilder $query
449 * @param array $priorities
450 * @return ModelQueryBuilder
451 */
452 public function scopeFilterByPriorities($query, $priorities)
453 {
454 if ($priorities) {
455 $query->whereIn('priority', $priorities);
456 }
457
458 return $query;
459 }
460
461 /**
462 * @param $filter
463 * @return string[]
464 */
465
466 public static function parseRelationalFilterQueryMethods($filter)
467 {
468 // default operator = in
469 $method = 'whereHas';
470 $subMethod = 'whereIn';
471
472 switch ($filter['operator']) {
473 case 'not_in':
474 $method = 'whereDoesntHave';
475 $subMethod = 'whereIn';
476
477 break;
478 case 'in_all':
479 $method = 'whereHas';
480 $subMethod = 'where';
481
482 break;
483 case 'not_in_all':
484 $method = 'whereDoesntHave';
485 $subMethod = 'where';
486
487 break;
488 }
489
490 return [$method, $subMethod];
491 }
492
493 /**
494 * Parse filter to set proper operator and value for the filter query.
495 *
496 * @param array $filter
497 * @return array
498 */
499 public static function filterParser($filter)
500 {
501 switch ($filter['operator']) {
502 case 'before':
503 $filter['operator'] = '<';
504 $filter['value'] = $filter['value'] . ' 23:59:59';
505 break;
506
507 case 'after':
508 $filter['operator'] = '>';
509 $filter['value'] = $filter['value'] . ' 23:59:59';
510 break;
511
512 case 'date_equal':
513 $filter['operator'] = 'LIKE';
514 $filter['value'] = '%' . $filter['value'] . '%';
515 break;
516
517 case 'days_before':
518 $filter['operator'] = '<';
519 $filter['value'] = gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60);
520 break;
521
522 case 'days_within':
523 $filter['operator'] = 'BETWEEN';
524 $filter['value'] = [
525 gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60),
526 gmdate('Y-m-d') . ' 23:59:59'
527 ];
528 break;
529 case 'date_range':
530 $filter['operator'] = 'BETWEEN';
531 if (isset($filter['value'][0]))
532 $filter['value'][0] .= ' 00:00:00';
533 if (isset($filter['value'][1]))
534 $filter['value'][1] .= ' 23:59:59';
535 break;
536 }
537
538 return $filter;
539 }
540
541 /**
542 * @param \FluentSupport\Framework\Database\Orm\Builder|\FluentSupport\Framework\Database\Query\Builder $query
543 * @param array $filters
544 * @return ModelQueryBuilder
545 */
546 public function buildDateBaseFilterQuery($query, $filters)
547 {
548 $filter = static::filterParser($filters);
549 $query->where(function ($dateQuery) use ($filter) {
550
551 if ($filter['operator'] == 'BETWEEN') {
552 $dateQuery->whereBetween($filter['property'], $filter['value']);
553 } else {
554 $dateQuery->where($filter['property'], $filter['operator'], $filter['value']);
555 }
556 });
557
558 return $query;
559 }
560
561 /**
562 * Relation builder
563 * @param $relation
564 * @param $query
565 * @param $method
566 * @param $subMethod
567 * @param $subField
568 * @param $filter
569 * @param false $provider
570 * @return ModelQueryBuilder
571 */
572
573 public static function buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, $filter, $provider = false)
574 {
575 if (in_array($filter['operator'], ['in_all', 'not_in_all']) && $filter['value']) {
576 foreach ($filter['value'] as $item) {
577 $query = static::buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, ['value' => $item, 'operator' => ''], $provider);
578 }
579 } else {
580 $query = $query->{$method}($relation, function ($relationQuery) use ($subMethod, $subField, $filter, $provider) {
581 $relationQuery = $relationQuery->{$subMethod}($subField, $filter['value']);
582
583 if ($provider) {
584 $relationQuery = $relationQuery->where('provider', $provider);
585 }
586
587 return $relationQuery;
588 });
589 }
590
591 return $query;
592 }
593
594 /**
595 * get tickets by advanced filter segment data
596 * @param $query
597 * @param $filter
598 * @return ModelQueryBuilder
599 */
600
601 public function buildPropertiesFilterQuery($query, $filter)
602 {
603 if (in_array($filter['property'], ['tags', 'product'])) {
604 $subField = $filter['property'] == 'tags' ? 'tag_id' : 'product_id';
605 list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter);
606 $query = static::buildRelationFilterQuery($filter['property'], $query, $method, $subMethod, $subField, $filter);
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')
611 ->orWhereNull('last_agent_response')
612 ->orWhere('status', 'new');
613 });
614 } else {
615 $query = $query->where(function ($q) {
616 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
617 });
618 }
619 } else {
620 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
621 $query = $query->{$method}($filter['property'], (array)$filter['value']);
622 }
623 return $query;
624 }
625
626 /**
627 * method to search by properties
628 * @param $provider
629 * @param $query
630 * @param $search
631 * @param string $operator
632 * @return ModelQueryBuilder
633 */
634 public function buildSearchableQuery($provider, $query, $search, $operator = 'LIKE')
635 {
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 }
647
648 $query->whereHas($provider, function ($query) use ($fields, $search, $operator) {
649 $query->where(array_shift($fields), $operator, $search);
650
651 $nameArray = explode(' ', (string) $search);
652
653 if (count($nameArray) >= 2) {
654 $query->orWhere(function ($q) use ($nameArray, $operator) {
655 $firstName = array_shift($nameArray);
656 $lastName = implode(' ', $nameArray);
657
658 $q->where('first_name', $operator, $firstName);
659 $q->where('last_name', $operator, $lastName);
660 });
661 }
662
663 foreach ($fields as $field) {
664 $query->orWhere($field, $operator, $search);
665 }
666 });
667
668 return $query;
669 }
670
671 /**
672 * Filter by ticket general properties like customer name, agent name etc
673 * @param $provider
674 * @param $query
675 * @param $filters
676 * @return ModelQueryBuilder
677 */
678 public function filterTicketByUser($provider, $query, $filters)
679 {
680 foreach ($filters as $filter) {
681 if ($filter['operator'] == 'in' || $filter['operator'] == 'not_in') {
682 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
683 $query = $query->whereHas($provider, function ($q) use ($method, $filter) {
684 $q->{$method}($filter['property'], $filter['value']);
685 });
686 }
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 });
693 }
694
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']);
699 });
700 }
701 }
702 return $query;
703 }
704
705 /**
706 * One2Many: Customer has to many Click Tickets
707 * @return Model Collection
708 */
709 public function responses()
710 {
711 $class = __NAMESPACE__ . '\Conversation';
712
713 return $this->hasMany(
714 $class, 'ticket_id', 'id'
715 )->orderBy('created_at', 'desc')
716 ->orderBy('id', 'desc');
717 }
718
719 public function preview_response()
720 {
721 $class = __NAMESPACE__ . '\Conversation';
722
723 return $this->hasOne(
724 $class, 'ticket_id', 'id'
725 );
726 }
727
728 public function tags()
729 {
730 $class = __NAMESPACE__ . '\TicketTag';
731
732 return $this->belongsToMany(
733 $class, 'fs_tag_pivot', 'source_id', 'tag_id'
734 )->wherePivot('source_type', 'ticket_tag');
735 }
736
737 public function watchers()
738 {
739 $class = __NAMESPACE__ . '\TagPivot';
740
741 return $this->hasMany($class, 'source_id', 'id')
742 ->where('source_type', 'ticket_watcher')
743 ->select(['tag_id']);
744 }
745
746 /**
747 * One2one: Customer has to many Click Tickets
748 * @return Model Collection
749 */
750 public function customer()
751 {
752 $class = __NAMESPACE__ . '\Customer';
753
754 return $this->belongsTo(
755 $class, 'customer_id', 'id'
756 );
757 }
758
759 /**
760 * One2one: Customer has to many Click Tickets
761 * @return Model Collection
762 */
763 public function agent()
764 {
765 $class = __NAMESPACE__ . '\Agent';
766
767 return $this->belongsTo(
768 $class, 'agent_id', 'id'
769 );
770 }
771
772 public function closed_by_person()
773 {
774 $class = __NAMESPACE__ . '\Person';
775
776 return $this->belongsTo(
777 $class, 'closed_by', 'id'
778 );
779 }
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
790 public function product()
791 {
792 $class = __NAMESPACE__ . '\Product';
793
794 return $this->belongsTo(
795 $class, 'product_id', 'id'
796 );
797 }
798
799 public function mailbox()
800 {
801 $class = __NAMESPACE__ . '\MailBox';
802
803 return $this->belongsTo(
804 $class, 'mailbox_id', 'id'
805 );
806 }
807
808
809 public function deleteTicket()
810 {
811 /*
812 * Action on ticket deleting
813 *
814 * @since v1.0.0
815 * @param object $ticket
816 */
817 do_action('fluent_support/deleting_ticket', $this);
818 // Delete the ticket
819 $this->delete();
820 }
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
890 public static function slugify($title)
891 {
892 $slug = sanitize_title($title, 'support-ticket-' . time(), 'display');
893 if (Ticket::where('slug', $slug)->first()) {
894 $slug .= '-' . time();
895 }
896 return $slug;
897 }
898
899 public function hasTag($tagId)
900 {
901 $tags = $this->tags;
902 foreach ($tags as $tag) {
903 if ($tag->id == $tagId) {
904 return true;
905 }
906 }
907
908 return false;
909 }
910
911 public function attachments()
912 {
913 $class = __NAMESPACE__ . '\Attachment';
914 return $this->hasMany($class, 'ticket_id', 'id')->where('conversation_id', NULL);
915 }
916
917 public function customData($scope = 'admin', $rendered = false)
918 {
919 if (!defined('FLUENTSUPPORTPRO')) {
920 return [];
921 }
922
923 $fields = \FluentSupportPro\App\Services\CustomFieldsService::getFieldLabels($scope);
924
925 if (!$fields) {
926 return [];
927 }
928
929 $keys = array_keys($fields);
930
931 $customRows = Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
932 ->whereIn('key', $keys)
933 ->get();
934
935 if (!$customRows) {
936 return [];
937 }
938
939 $formattedData = [];
940
941 $customRenderers = \FluentSupportPro\App\Services\CustomFieldsService::getCustomerRenderers();
942
943 foreach ($customRows as $row) {
944 $dataKey = $row->key;
945
946 $value = $row->value;
947
948 $fieldType = $fields[$dataKey]['type'];
949
950 if ($value) {
951 if (in_array($fieldType, $customRenderers) && $rendered) {
952 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
953 } else if (in_array($fieldType, ['checkbox', 'date-range'])) {
954 $value = array_values(array_filter(explode('|', $value)));
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 }
962 }
963 }
964
965 return $formattedData;
966 }
967
968 /**
969 * @param $data This is the data that will be saved to the ticket_meta for custom fields
970 * @return bool
971 */
972 public function syncCustomFields($data)
973 {
974 if (!is_array($data)) {
975 return false;
976 }
977
978 $fields = apply_filters('fluent_support/ticket_custom_fields', []);
979
980 if (!$fields) {
981 return false;
982 }
983
984 $keys = array_keys($fields);
985
986 $validData = Arr::only($data, $keys);
987
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
997 if ($fields[$dataKey]['type'] == 'checkbox' || is_array($validDatum)) {
998 $validDatum = implode('|', $validDatum);
999 $validDatum = '|' . $validDatum . '|';
1000 }
1001
1002 $exist = Meta::where('object_type', 'ticket_meta')
1003 ->where('object_id', $this->id)
1004 ->where('key', $dataKey)
1005 ->first();
1006
1007 if ($exist) {
1008 $exist->value = $validDatum;
1009 $exist->save();
1010 } else {
1011 Meta::insert([
1012 'object_type' => 'ticket_meta',
1013 'object_id' => $this->id,
1014 'key' => $dataKey,
1015 'value' => $validDatum
1016 ]);
1017 }
1018 }
1019
1020 return true;
1021 }
1022
1023 public function getLastAgentResponse()
1024 {
1025 $query = \FluentSupport\App\App::db()->table('fs_conversations')
1026 ->select(['fs_conversations.*'])
1027 ->where('fs_conversations.conversation_type', 'response')
1028 ->where('fs_conversations.ticket_id', $this->id)
1029 ->where('fs_persons.person_type', '=', 'agent')
1030 ->join('fs_persons', 'fs_persons.id', '=', 'fs_conversations.person_id')
1031 ->orderBy('fs_conversations.id', 'DESC');
1032
1033 return $query->first();
1034 }
1035
1036 public function getLastResponse()
1037 {
1038 return \FluentSupport\App\App::db()->table('fs_conversations')
1039 ->where('ticket_id', $this->id)
1040 ->where('conversation_type', 'response')
1041 ->latest('id')
1042 ->first();
1043 }
1044
1045 /**
1046 * This method will assign tags to the ticket
1047 * @param $tagIds This is the array of tag ids that will be assigned to the ticket
1048 * @return \FluentSupport\App\Models\Ticket
1049 */
1050 public function applyTags($tagIds)
1051 {
1052 $result = false;
1053
1054 if (!is_array($tagIds)) {
1055 $tagIds = array($tagIds);
1056 }
1057
1058 foreach ($tagIds as $tagId) {
1059 if (!$this->hasTag($tagId)) {
1060 $this->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
1061 $result = true;
1062
1063 /*
1064 * Action while tag added to ticket
1065 *
1066 * @since v1.0.0
1067 * @param integer $tagId
1068 * @param object $ticket
1069 */
1070 do_action('fluent_support/ticket_tag_added', $tagId, $this);
1071 }
1072 }
1073 return $result;
1074 }
1075
1076 /**
1077 * This method will remove tags from ticket
1078 * @param $tagIds This is the array of tag ids that will be removed from the ticket
1079 * @return \FluentSupport\App\Models\Ticket
1080 */
1081 public function detachTags($tagIds)
1082 {
1083 $result = false;
1084
1085 if (!is_array($tagIds)) {
1086 $tagIds = array($tagIds);
1087 }
1088
1089 foreach ($tagIds as $tagId) {
1090 if ($this->hasTag($tagId)) {
1091 $this->tags()->detach($tagId);
1092
1093 /*
1094 * Action while tag removed from ticket
1095 *
1096 * @since v1.0.0
1097 * @param integer $tagId
1098 * @param object $ticket
1099 */
1100 do_action('fluent_support/ticket_tag_removed', $tagId, $this);
1101 $result = true;
1102 }
1103 }
1104 return $result;
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
1237 }
1238