PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
← All changes | app/Models/Ticket.php +808 -68 1.4.52.4.0 View file →
@@ -1,9 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Models;
4 4
5 +use Exception;
6 +use FluentSupport\App\Modules\PermissionManager;
5 7 use FluentSupport\App\Services\Helper;
8 +use FluentSupport\App\Services\TicketHelper;
9 +use FluentSupport\App\Services\TicketQueryService;
10 +use FluentSupport\App\Services\Tickets\TicketService;
6 11 use FluentSupport\Framework\Support\Arr;
7 12
8 13 class Ticket extends Model
9 14 {
@@ -8,9 +13,22 @@
8 13 class Ticket extends Model
9 14 {
10 15 protected $table = 'fs_tickets';
11 16
17 + protected $dates = ['waiting_since'];
18 +
12 19 /**
20 + * The ticket hash is a bearer credential for the signed public ticket view,
21 + * so it must never be serialized into an API response. PHP property access
22 + * is unaffected, which is what Helper::getTicketViewSignedUrl() relies on.
23 + *
24 + * @var array
25 + */
26 + protected $hidden = ['hash', 'content_hash'];
27 +
28 + protected $appends = ['display_ticket_number'];
29 +
30 + /**
13 31 * The attributes that are mass assignable.
14 32 *
15 33 * @var array
16 34 */
@@ -36,22 +54,65 @@
36 54 'response_count',
37 55 'first_response_time',
38 56 'total_close_time',
39 57 'resolved_at',
40 - 'closed_by'
58 + 'closed_by',
59 + 'created_by',
60 + 'serial_number',
61 + 'ticket_number'
41 62 ];
42 63
43 64 public static function boot()
44 65 {
66 + parent::boot();
67 +
45 68 static::creating(function ($model) {
46 - $model->slug = static::slugify($model->title);
47 - $model->hash = substr(md5( time() . wp_generate_uuid4()), 0, 8).mt_rand(1,99);
69 + if (empty($model->slug)) {
70 + $model->slug = static::slugify($model->title);
71 + }
72 +
73 + $model->hash = bin2hex(random_bytes(16));
74 + $model->content_hash = md5($model->content);
75 +
48 76 $model->last_customer_response = current_time('mysql');
49 - $model->content_hash = md5($model->content);
50 77 $model->created_at = current_time('mysql');
51 78 $model->updated_at = current_time('mysql');
52 79 $model->waiting_since = current_time('mysql');
80 +
53 81 });
82 +
83 + static::updating(function ($model) {
84 + // A hash handed out for one customer must stop working the moment
85 + // the ticket belongs to somebody else, otherwise every link already
86 + // emailed for it keeps authorizing read, reply, close and reopen.
87 + if ($model->isDirty('customer_id')) {
88 + $model->hash = bin2hex(random_bytes(16));
89 + }
90 + });
91 +
92 + static::created(function ($model) {
93 + if (empty($model->serial_number) || empty($model->ticket_number)) {
94 + $model->assignTicketNumber();
95 + }
96 + });
97 +
98 + static::deleting(function ($model) {
99 + //Delete the ticket meta
100 + Meta::where('object_type', 'ticket_meta')->where('object_id', $model->id)->delete();
101 + //Delete all cc info for the ticket
102 + Meta::where('object_type', 'ticket')->where('object_id', $model->id)->delete();
103 + //Delete draft info
104 + Meta::where('object_type', '_fs_auto_draft')->where('object_id', $model->id)->delete();
105 + //Delete internal notifications and notification recipient rows for the ticket
106 + Notification::deleteByTicketId($model->id);
107 + //delete the responses first (their attachments are cleaned up by Conversation::deleting)
108 + Conversation::deleteAll($model->id);
109 + // Delete ticket-level attachments (conversation_id IS NULL) and remove the ticket upload directory
110 + $class = __NAMESPACE__ . '\Attachment';
111 + $ticketAttachments = $class::where('ticket_id', $model->id)->whereNull('conversation_id')->get();
112 + $class::purgeAttachments($ticketAttachments, $model->id);
113 + $class::where('ticket_id', $model->id)->whereNull('conversation_id')->delete();
114 + });
54 115 }
55 116
56 117 /**
57 118 * $searchable Columns in table to search
@@ -57,16 +118,18 @@
57 118 * $searchable Columns in table to search
58 119 * @var array
59 120 */
60 121 protected $searchable = [
122 + 'content',
61 123 'title',
62 124 'slug',
63 - 'content',
64 - 'id'
125 + 'id',
126 + 'serial_number',
127 + 'ticket_number'
65 128 ];
66 129
67 130 /**
68 - * Local scope to filter subscribers by search/query string
131 + * Local scope to filter tickets by search/query string
69 132 * @param ModelQueryBuilder $query
70 133 * @param string $search
71 134 * @return ModelQueryBuilder
72 135 */
@@ -71,37 +134,92 @@
71 134 * @return ModelQueryBuilder
72 135 */
73 136 public function scopeSearchBy($query, $search)
74 137 {
75 - if ($search) {
76 138
77 - if (strpos($search, ':')) {
78 - $array = explode(':', $search);
79 - $column = $array[0];
80 - $value = $array[1];
81 - $columns = $this->fillable;
82 - $columns[] = 'id';
139 + if(!$search) {
140 + return $query;
141 + }
83 142
84 - if (in_array($column, $columns) && $value) {
85 - if (is_numeric($value)) {
86 - $query->where($column, $value);
87 - } else {
88 - $query->where($column, 'LIKE', "%$value%");
89 - }
90 - return $query;
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%");
91 155 }
156 + return $query;
92 157 }
158 + }
93 159
94 - $fields = $this->searchable;
95 - $query->where(function ($query) use ($fields, $search) {
96 - $query->where(array_shift($fields), 'LIKE', "%$search%");
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 + });
97 167
98 - foreach ($fields as $field) {
99 - $query->orWhere($field, 'LIKE', "$search%");
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 + });
100 194 }
101 - });
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 + }
102 221 }
103 -
104 222 return $query;
105 223 }
106 224
107 225 /**
@@ -118,13 +236,50 @@
118 236
119 237 return $query;
120 238 }
121 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 + */
122 278 public function scopeWaitingOnly($query)
123 279 {
124 - global $wpdb;
125 - $query->where(function ($q) use ($wpdb) {
126 - $q->whereRaw($wpdb->prefix . 'fs_tickets.last_agent_response < ' . $wpdb->prefix . 'fs_tickets.last_customer_response')
280 + $query->where(function ($q) {
281 + $q->whereColumn('last_agent_response', '<', 'last_customer_response')
127 282 ->orWhereNull('last_agent_response')
128 283 ->orWhere('status', 'new');
129 284 });
130 285 return $query;
@@ -129,37 +284,114 @@
129 284 });
130 285 return $query;
131 286 }
132 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 + */
133 295 public function scopeApplyFilters($query, $filters)
134 296 {
135 297 $supportedColumns = ['product_id', 'client_priority', 'priority', 'mailbox_id'];
136 298 foreach ($filters as $filterKey => $filterValue) {
137 - if (!$filterValue && ($filterValue !== '0' || $filterValue !== 0)) {
299 + if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
138 300 continue;
139 301 }
302 + //If filer using status
140 303 if ($filterKey == 'status_type') {
304 + //Get list of ticket status
141 305 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
142 306 if ($statusArray) {
307 + //Apply filet where status in
143 308 $query->whereIn('status', $statusArray);
144 309 }
145 310 } else if (in_array($filterKey, $supportedColumns)) {
146 - $query->where($filterKey, $filterValue);
311 + // Use whereIn for all supported columns (they all now support multi-select)
312 + if (is_array($filterValue)) {
313 + $query->whereIn($filterKey, $filterValue);
314 + } else {
315 + $query->where($filterKey, $filterValue);
316 + }
147 317 } else if ($filterKey == 'waiting_for_reply') {
148 318 if ($filterValue != 'yes') {
149 319 continue;
150 320 }
321 + //Apply filter where no response by agent
151 322 $query = $this->scopeWaitingOnly($query);
152 323 } else if ($filterKey == 'agent_id') {
153 - if ($filterValue == 'unassigned') {
154 - $query->whereNull($filterKey);
324 + // Handle array of agent IDs for multi-select
325 + if (is_array($filterValue)) {
326 + // Check if 'unassigned' is in the array
327 + $hasUnassigned = in_array('unassigned', $filterValue);
328 + $agentIds = array_filter($filterValue, function($v) {
329 + return $v !== 'unassigned';
330 + });
331 +
332 + if ($hasUnassigned && !empty($agentIds)) {
333 + // Include both unassigned and specific agents
334 + $query->where(function($q) use ($agentIds) {
335 + $q->whereNull('agent_id')
336 + ->orWhereIn('agent_id', $agentIds);
337 + });
338 + } elseif ($hasUnassigned) {
339 + // Only unassigned
340 + $query->whereNull('agent_id');
341 + } elseif (!empty($agentIds)) {
342 + // Only specific agents
343 + if (defined('FLUENTSUPPORTPRO')) {
344 + if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
345 + $watcherTickets = [];
346 + foreach ($agentIds as $agentId) {
347 + $watcherTickets = array_merge($watcherTickets, TicketHelper::getWatcherTicketIds($agentId));
348 + }
349 + $query->whereIn('id', array_unique($watcherTickets));
350 + } else {
351 + $query->whereIn('agent_id', $agentIds);
352 + }
353 + } else {
354 + $query->whereIn('agent_id', $agentIds);
355 + }
356 + }
155 357 } else {
156 - $query->where($filterKey, $filterValue);
358 + // Single value (backward compatibility)
359 + if ($filterValue == 'unassigned') {
360 + $query->whereNull($filterKey);
361 + } else {
362 + if (defined('FLUENTSUPPORTPRO')) {
363 + if (isset($filters['watcher']) && $filters['watcher'] == 'watcher') {
364 + $watcherTickets = TicketHelper::getWatcherTicketIds($filterValue);
365 + $query->whereIn('id', $watcherTickets);
366 + } else {
367 + //Apply filter, get only assigned ticket
368 + $query->where($filterKey, $filterValue);
369 + }
370 + } else {
371 + $query->where($filterKey, $filterValue);
372 + }
373 + }
157 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 + }
158 389 } else if ($filterKey == 'ticket_tags') {
159 390 if (!$filterValue) {
160 391 continue;
161 392 }
393 + //Apply filter where ticket only has this tag id
162 394 $query->whereHas('tags', function ($q) use ($filterValue) {
163 395 $q->whereIn('tag_id', $filterValue);
164 396 });
165 397 }
@@ -168,11 +400,11 @@
168 400 return $query;
169 401 }
170 402
171 403 /**
172 - * Local scope to filter subscribers by search/query string
404 + * Local scope to filter tickets by agent id
173 405 * @param ModelQueryBuilder $query
174 - * @param array $statuses
406 + * @param int $agentId
175 407 * @return ModelQueryBuilder
176 408 */
177 409 public function scopeFilterByAgentId($query, $agentId)
178 410 {
@@ -225,10 +457,253 @@
225 457
226 458 return $query;
227 459 }
228 460
461 + /**
462 + * @param $filter
463 + * @return string[]
464 + */
229 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 +
230 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 + /**
231 706 * One2Many: Customer has to many Click Tickets
232 707 * @return Model Collection
233 708 */
234 709 public function responses()
@@ -236,9 +711,10 @@
236 711 $class = __NAMESPACE__ . '\Conversation';
237 712
238 713 return $this->hasMany(
239 714 $class, 'ticket_id', 'id'
240 - );
715 + )->orderBy('created_at', 'desc')
716 + ->orderBy('id', 'desc');
241 717 }
242 718
243 719 public function preview_response()
244 720 {
@@ -257,9 +733,17 @@
257 733 $class, 'fs_tag_pivot', 'source_id', 'tag_id'
258 734 )->wherePivot('source_type', 'ticket_tag');
259 735 }
260 736
737 + public function watchers()
738 + {
739 + $class = __NAMESPACE__ . '\TagPivot';
261 740
741 + return $this->hasMany($class, 'source_id', 'id')
742 + ->where('source_type', 'ticket_watcher')
743 + ->select(['tag_id']);
744 + }
745 +
262 746 /**
263 747 * One2one: Customer has to many Click Tickets
264 748 * @return Model Collection
265 749 */
@@ -293,8 +777,17 @@
293 777 $class, 'closed_by', 'id'
294 778 );
295 779 }
296 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 +
297 790 public function product()
298 791 {
299 792 $class = __NAMESPACE__ . '\Product';
300 793
@@ -314,18 +807,87 @@
314 807
315 808
316 809 public function deleteTicket()
317 810 {
811 + /*
812 + * Action on ticket deleting
813 + *
814 + * @since v1.0.0
815 + * @param object $ticket
816 + */
318 817 do_action('fluent_support/deleting_ticket', $this);
319 - // delete the responses first
320 - Conversation::where('ticket_id', $this->id)->delete();
321 - // Delete the ticket meta
322 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)->delete();
323 - // Delete Attachments
324 - Attachment::where('ticket_id', $this->id)->delete();
325 - Ticket::where('id', $this->id)->delete();
818 + // Delete the ticket
819 + $this->delete();
326 820 }
327 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 +
328 890 public static function slugify($title)
329 891 {
330 892 $slug = sanitize_title($title, 'support-ticket-' . time(), 'display');
331 893 if (Ticket::where('slug', $slug)->first()) {
@@ -353,9 +915,9 @@
353 915 }
354 916
355 917 public function customData($scope = 'admin', $rendered = false)
356 918 {
357 - if(!defined('FLUENTSUPPORTPRO')) {
919 + if (!defined('FLUENTSUPPORTPRO')) {
358 920 return [];
359 921 }
360 922
361 923 $fields = \FluentSupportPro\App\Services\CustomFieldsService::getFieldLabels($scope);
@@ -384,22 +946,30 @@
384 946 $value = $row->value;
385 947
386 948 $fieldType = $fields[$dataKey]['type'];
387 949
388 - if($value) {
389 - if(in_array($fieldType, $customRenderers) && $rendered) {
390 - $value = apply_filters('fluent_support/custom_field_render_'.$fieldType, $value, $scope);
391 - } else if ($fieldType == 'checkbox') {
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'])) {
392 954 $value = array_values(array_filter(explode('|', $value)));
393 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 + }
394 962 }
395 -
396 - $formattedData[$dataKey] = $value;
397 963 }
398 964
399 965 return $formattedData;
400 966 }
401 967
968 + /**
969 + * @param $data This is the data that will be saved to the ticket_meta for custom fields
970 + * @return bool
971 + */
402 972 public function syncCustomFields($data)
403 973 {
404 974 if (!is_array($data)) {
405 975 return false;
@@ -415,14 +985,23 @@
415 985
416 986 $validData = Arr::only($data, $keys);
417 987
418 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 +
419 997 if ($fields[$dataKey]['type'] == 'checkbox' || is_array($validDatum)) {
420 998 $validDatum = implode('|', $validDatum);
421 999 $validDatum = '|' . $validDatum . '|';
422 1000 }
423 1001
424 - $exist = Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
1002 + $exist = Meta::where('object_type', 'ticket_meta')
1003 + ->where('object_id', $this->id)
425 1004 ->where('key', $dataKey)
426 1005 ->first();
427 1006
428 1007 if ($exist) {
@@ -437,29 +1016,22 @@
437 1016 ]);
438 1017 }
439 1018 }
440 1019
441 - // maybe delete data
442 - $deletedSlugs = array_diff($keys, array_keys($validData));
443 -
444 - if ($deletedSlugs) {
445 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
446 - ->whereIn('key', $deletedSlugs)
447 - ->delete();
448 - }
449 -
450 1020 return true;
451 1021 }
452 1022
453 1023 public function getLastAgentResponse()
454 1024 {
455 - return \FluentSupport\App\App::db()->table('fs_conversations')
1025 + $query = \FluentSupport\App\App::db()->table('fs_conversations')
456 1026 ->select(['fs_conversations.*'])
457 1027 ->where('fs_conversations.conversation_type', 'response')
458 1028 ->where('fs_conversations.ticket_id', $this->id)
459 - ->join('fs_persons', 'fs_persons.person_type', '=', 'agent')
460 - ->orderBy('fs_conversations.id', 'DESC')
461 - ->first();
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();
462 1034 }
463 1035
464 1036 public function getLastResponse()
465 1037 {
@@ -464,19 +1036,38 @@
464 1036 public function getLastResponse()
465 1037 {
466 1038 return \FluentSupport\App\App::db()->table('fs_conversations')
467 1039 ->where('ticket_id', $this->id)
468 - ->orderBy('id', 'DESC')
1040 + ->where('conversation_type', 'response')
1041 + ->latest('id')
469 1042 ->first();
470 1043 }
471 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 + */
472 1050 public function applyTags($tagIds)
473 1051 {
474 1052 $result = false;
1053 +
1054 + if (!is_array($tagIds)) {
1055 + $tagIds = array($tagIds);
1056 + }
1057 +
475 1058 foreach ($tagIds as $tagId) {
476 1059 if (!$this->hasTag($tagId)) {
477 1060 $this->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
478 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 + */
479 1070 do_action('fluent_support/ticket_tag_added', $tagId, $this);
480 1071 }
481 1072 }
482 1073 return $result;
@@ -481,14 +1072,32 @@
481 1072 }
482 1073 return $result;
483 1074 }
484 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 + */
485 1081 public function detachTags($tagIds)
486 1082 {
487 1083 $result = false;
1084 +
1085 + if (!is_array($tagIds)) {
1086 + $tagIds = array($tagIds);
1087 + }
1088 +
488 1089 foreach ($tagIds as $tagId) {
489 1090 if ($this->hasTag($tagId)) {
490 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 + */
491 1100 do_action('fluent_support/ticket_tag_removed', $tagId, $this);
492 1101 $result = true;
493 1102 }
494 1103 }
@@ -493,5 +1102,136 @@
493 1102 }
494 1103 }
495 1104 return $result;
496 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 +
497 1237 }