PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Models / Ticket.php

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

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