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
← All changes | app/Models/Ticket.php +614 -120 1.5.4 → 2.4.5 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,21 @@
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 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 + /**
13 30 * The attributes that are mass assignable.
14 31 *
15 32 * @var array
16 33 */
@@ -36,26 +53,65 @@
36 53 'response_count',
37 54 'first_response_time',
38 55 'total_close_time',
39 56 'resolved_at',
40 - 'closed_by'
57 + 'closed_by',
58 + 'created_by',
59 + 'serial_number',
60 + 'ticket_number'
41 61 ];
42 62
43 63 public static function boot()
44 64 {
65 + parent::boot();
66 +
45 67 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);
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 +
48 75 $model->last_customer_response = current_time('mysql');
49 - $model->content_hash = md5($model->content);
50 76 $model->created_at = current_time('mysql');
51 77 $model->updated_at = current_time('mysql');
52 78 $model->waiting_since = current_time('mysql');
79 +
53 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 + });
54 111 }
55 112
56 113 /**
57 - * $searchable Columns in table to search
58 114 * @var array
59 115 */
60 116 protected $searchable = [
61 117 'content',
@@ -60,13 +116,16 @@
60 116 protected $searchable = [
61 117 'content',
62 118 'title',
63 119 'slug',
64 - 'id'
120 + 'id',
121 + 'serial_number',
122 + 'ticket_number'
65 123 ];
66 124
67 125 /**
68 - * Local scope to filter tickets by search/query string
126 + * Supports `column:value` to match a single fillable column.
127 + *
69 128 * @param ModelQueryBuilder $query
70 129 * @param string $search
71 130 * @return ModelQueryBuilder
72 131 */
@@ -71,10 +130,15 @@
71 130 * @return ModelQueryBuilder
72 131 */
73 132 public function scopeSearchBy($query, $search)
74 133 {
134 +
135 + if(!$search) {
136 + return $query;
137 + }
138 +
75 139 if (strpos($search, ':')) {
76 - $array = explode(':', $search);
140 + $array = explode(':', (string) $search);
77 141 $column = $array[0];
78 142 $value = $array[1];
79 143 $columns = $this->fillable;
80 144 $columns[] = 'id';
@@ -100,11 +164,10 @@
100 164 return $query;
101 165 }
102 166
103 167 /**
104 - * Local scope to filter tickets by different filtering condition
105 168 * @param ModelQueryBuilder $query
106 - * @param mixed $search
169 + * @param array $search
107 170 * @return ModelQueryBuilder
108 171 */
109 172
110 173 public function doSearchForAdvancedFilter($query, $search)
@@ -110,45 +173,43 @@
110 173 public function doSearchForAdvancedFilter($query, $search)
111 174 {
112 175 foreach ($search as $s) {
113 176 $operator = $s['operator'];
114 - //If selected item for ticket either title or content
115 - if(in_array($s['property'], ['title','content' ])){
116 - //If the selected condition is contains, query operator id LIKE
177 + if (in_array($s['property'], ['title', 'content'])) {
117 178 if ($operator == 'contains') {
118 179 $query = $query->where(function ($query) use ($s) {
119 - $query->where($s['property'], 'LIKE', "%".$s['value']."%");
180 + $query->where($s['property'], 'LIKE', "%" . $s['value'] . "%");
120 181 });
121 182 } elseif ($operator == 'not_contains') {
122 - //If the selected condition is not_contains, query operator id NOT LIKE
123 - $query = $query->where(function ($query) use ($s){
124 - $query->where($s['property'], 'NOT LIKE', '%'.$s['value'].'%');
183 + $query = $query->where(function ($query) use ($s) {
184 + $query->where($s['property'], 'NOT LIKE', '%' . $s['value'] . '%');
125 185 });
126 186 }
127 187 }
128 188
129 - //If selected item is Ticket Conversation Content
130 - if($s['property'] == 'conversation_content'){
189 + if ($s['property'] == 'conversation_content') {
131 190 $operator = $s['operator'];
132 - if($operator == 'contains') {
191 + if ($operator == 'contains') {
133 192 $query = $query->whereHas('responses', function ($q) use ($s) {
134 - $q->where('content', 'LIKE', "%".$s['value']."%");
193 + $q->where('content', 'LIKE', "%" . $s['value'] . "%");
135 194 });
136 195
137 196 } else if ($operator == 'not_contains') {
138 197 $query = $query->whereHas('responses', function ($q) use ($s) {
139 - $q->where('content', 'NOT LIKE', "%".$s['value']."%");
198 + $q->where('content', 'NOT LIKE', "%" . $s['value'] . "%");
140 199 });
141 200 }
142 201 }
143 202
144 - //If selected item is Ticket created or Last Response or Customer Waiting For, or Last Agent Response or Last Customer Response
145 - if(in_array($s['property'], ['created_at', 'updated_at', 'waiting_since', 'last_agent_response', 'last_customer_response'])){
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'])) {
146 208 $query = (new \FluentSupport\App\Models\Ticket())->buildDateBaseFilterQuery($query, $s);
147 209 }
148 210
149 - //If selected item is Ticket Status or Client Priority or Agent Priority or Tags or Product or Waiting For Reply
150 - if(in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])){
211 + if (in_array($s['property'], ['status', 'client_priority', 'priority', 'tags', 'product', 'waiting_for_reply', 'agent_id', 'mailbox_id'])) {
151 212 $query = (new \FluentSupport\App\Models\Ticket())->buildPropertiesFilterQuery($query, $s);
152 213 }
153 214 }
154 215 return $query;
@@ -153,10 +214,78 @@
153 214 }
154 215 return $query;
155 216 }
156 217
218 + /** Advanced-filter value for tickets with no recorded source (older tickets). */
219 + const SOURCE_NONE = '__none__';
220 +
157 221 /**
158 - * Local scope to filter subscribers by search/query string
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 + /**
159 288 * @param ModelQueryBuilder $query
160 289 * @param array $statuses
161 290 * @return ModelQueryBuilder
162 291 */
@@ -168,12 +297,43 @@
168 297
169 298 return $query;
170 299 }
171 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 + */
172 332 public function scopeWaitingOnly($query)
173 333 {
174 - $query->where(function ($q){
175 - $q->whereColumn('last_agent_response', '<' ,'last_customer_response')
334 + $query->where(function ($q) {
335 + $q->whereColumn('last_agent_response', '<', 'last_customer_response')
176 336 ->orWhereNull('last_agent_response')
177 337 ->orWhere('status', 'new');
178 338 });
179 339 return $query;
@@ -178,13 +338,18 @@
178 338 });
179 339 return $query;
180 340 }
181 341
342 + /**
343 + * @param $query
344 + * @param $filters
345 + * @return ModelQueryBuilder
346 + */
182 347 public function scopeApplyFilters($query, $filters)
183 348 {
184 349 $supportedColumns = ['product_id', 'client_priority', 'priority', 'mailbox_id'];
185 350 foreach ($filters as $filterKey => $filterValue) {
186 - if (!$filterValue && ($filterValue !== '0' || $filterValue !== 0)) {
351 + if (!$filterValue && ($filterValue !== '0' && $filterValue !== 0)) {
187 352 continue;
188 353 }
189 354 if ($filterKey == 'status_type') {
190 355 $statusArray = Helper::getTkStatusesByGroupName($filterValue);
@@ -191,9 +356,13 @@
191 356 if ($statusArray) {
192 357 $query->whereIn('status', $statusArray);
193 358 }
194 359 } else if (in_array($filterKey, $supportedColumns)) {
195 - $query->where($filterKey, $filterValue);
360 + if (is_array($filterValue)) {
361 + $query->whereIn($filterKey, $filterValue);
362 + } else {
363 + $query->where($filterKey, $filterValue);
364 + }
196 365 } else if ($filterKey == 'waiting_for_reply') {
197 366 if ($filterValue != 'yes') {
198 367 continue;
199 368 }
@@ -198,13 +367,67 @@
198 367 continue;
199 368 }
200 369 $query = $this->scopeWaitingOnly($query);
201 370 } else if ($filterKey == 'agent_id') {
202 - if ($filterValue == 'unassigned') {
203 - $query->whereNull($filterKey);
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 + }
204 399 } else {
205 - $query->where($filterKey, $filterValue);
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 + }
206 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 + }
207 430 } else if ($filterKey == 'ticket_tags') {
208 431 if (!$filterValue) {
209 432 continue;
210 433 }
@@ -217,9 +440,8 @@
217 440 return $query;
218 441 }
219 442
220 443 /**
221 - * Local scope to filter tickets by agent id
222 444 * @param ModelQueryBuilder $query
223 445 * @param int $agentId
224 446 * @return ModelQueryBuilder
225 447 */
@@ -232,9 +454,8 @@
232 454 return $query;
233 455 }
234 456
235 457 /**
236 - * Local scope to filter subscribers by search/query string
237 458 * @param ModelQueryBuilder $query
238 459 * @param int $customerId
239 460 * @return ModelQueryBuilder
240 461 */
@@ -245,9 +466,8 @@
245 466 return $query;
246 467 }
247 468
248 469 /**
249 - * Local scope to filter subscribers by search/query string
250 470 * @param ModelQueryBuilder $query
251 471 * @param int $productId
252 472 * @return ModelQueryBuilder
253 473 */
@@ -260,9 +480,8 @@
260 480 return $query;
261 481 }
262 482
263 483 /**
264 - * Local scope to filter subscribers by search/query string
265 484 * @param ModelQueryBuilder $query
266 485 * @param array $priorities
267 486 * @return ModelQueryBuilder
268 487 */
@@ -332,23 +551,23 @@
332 551 break;
333 552
334 553 case 'days_before':
335 554 $filter['operator'] = '<';
336 - $filter['value'] = date('Y-m-d', current_time('timestamp') - $filter['value'] * 24 * 60 * 60);
555 + $filter['value'] = gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60);
337 556 break;
338 557
339 558 case 'days_within':
340 559 $filter['operator'] = 'BETWEEN';
341 560 $filter['value'] = [
342 - date('Y-m-d', current_time('timestamp') - $filter['value'] * 24 * 60 * 60),
343 - date('Y-m-d') . ' 23:59:59'
561 + gmdate('Y-m-d', time() - $filter['value'] * 24 * 60 * 60),
562 + gmdate('Y-m-d') . ' 23:59:59'
344 563 ];
345 564 break;
346 565 case 'date_range':
347 566 $filter['operator'] = 'BETWEEN';
348 - if(isset($filter['value'][0]))
349 - $filter['value'][0] .= ' 00:00:00';
350 - if(isset($filter['value'][1]))
567 + if (isset($filter['value'][0]))
568 + $filter['value'][0] .= ' 00:00:00';
569 + if (isset($filter['value'][1]))
351 570 $filter['value'][1] .= ' 23:59:59';
352 571 break;
353 572 }
354 573
@@ -357,9 +576,9 @@
357 576
358 577 /**
359 578 * @param \FluentSupport\Framework\Database\Orm\Builder|\FluentSupport\Framework\Database\Query\Builder $query
360 579 * @param array $filters
361 - * @return \FluentSupport\App\Models\Ticket
580 + * @return ModelQueryBuilder
362 581 */
363 582 public function buildDateBaseFilterQuery($query, $filters)
364 583 {
365 584 $filter = static::filterParser($filters);
@@ -375,9 +594,8 @@
375 594 return $query;
376 595 }
377 596
378 597 /**
379 - * Relation builder
380 598 * @param $relation
381 599 * @param $query
382 600 * @param $method
383 601 * @param $subMethod
@@ -383,9 +601,9 @@
383 601 * @param $subMethod
384 602 * @param $subField
385 603 * @param $filter
386 604 * @param false $provider
387 - * @return mixed
605 + * @return ModelQueryBuilder
388 606 */
389 607
390 608 public static function buildRelationFilterQuery($relation, $query, $method, $subMethod, $subField, $filter, $provider = false)
391 609 {
@@ -408,9 +626,8 @@
408 626 return $query;
409 627 }
410 628
411 629 /**
412 - * get tickets by advanced filter segment data
413 630 * @param $query
414 631 * @param $filter
415 632 * @return ModelQueryBuilder
416 633 */
@@ -420,47 +637,52 @@
420 637 if (in_array($filter['property'], ['tags', 'product'])) {
421 638 $subField = $filter['property'] == 'tags' ? 'tag_id' : 'product_id';
422 639 list($method, $subMethod) = static::parseRelationalFilterQueryMethods($filter);
423 640 $query = static::buildRelationFilterQuery($filter['property'], $query, $method, $subMethod, $subField, $filter);
424 - }
425 -
426 - elseif ($filter['property'] == 'waiting_for_reply'){
427 - if (($filter['value'] == 'yes' && $filter['operator'] == '=') || ($filter['value'] == 'no' && $filter['operator'] == '!=')){
428 - $query = $query->where(function ($q){
429 - $q->whereColumn('last_agent_response', '<' ,'last_customer_response')
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')
430 645 ->orWhereNull('last_agent_response')
431 646 ->orWhere('status', 'new');
432 647 });
433 648 } else {
434 649 $query = $query->where(function ($q) {
435 - $q->whereColumn('last_customer_response', '<' ,'last_agent_response');
650 + $q->whereColumn('last_customer_response', '<', 'last_agent_response');
436 651 });
437 652 }
438 - }
439 -
440 - else {
653 + } else {
441 654 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
442 - $query = $query->{$method}($filter['property'], (array) $filter['value']);
655 + $query = $query->{$method}($filter['property'], (array)$filter['value']);
443 656 }
444 657 return $query;
445 658 }
446 659
447 660 /**
448 - * method to search by properties
449 661 * @param $provider
450 662 * @param $query
451 663 * @param $search
452 664 * @param string $operator
453 - * @return mixed
665 + * @return ModelQueryBuilder
454 666 */
455 667 public function buildSearchableQuery($provider, $query, $search, $operator = 'LIKE')
456 668 {
457 - $fields = $this->searchable;
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 + }
458 680
459 681 $query->whereHas($provider, function ($query) use ($fields, $search, $operator) {
460 682 $query->where(array_shift($fields), $operator, $search);
461 683
462 - $nameArray = explode(' ', $search);
684 + $nameArray = explode(' ', (string) $search);
463 685
464 686 if (count($nameArray) >= 2) {
465 687 $query->orWhere(function ($q) use ($nameArray, $operator) {
466 688 $firstName = array_shift($nameArray);
@@ -479,55 +701,44 @@
479 701 return $query;
480 702 }
481 703
482 704 /**
483 - * Filter by ticket general properties like customer name, agent name etc
705 + * Filter by fields on the related customer or agent.
706 + * @param $provider
484 707 * @param $query
485 708 * @param $filters
709 + * @return ModelQueryBuilder
486 710 */
487 -
488 711 public function filterTicketByUser($provider, $query, $filters)
489 712 {
490 - foreach ($filters as $index=>$filter) {
491 - if ($filter['operator']=='in' || $filter['operator']=='not_in') {
713 + foreach ($filters as $filter) {
714 + if ($filter['operator'] == 'in' || $filter['operator'] == 'not_in') {
492 715 $method = $filter['operator'] == 'in' ? 'whereIn' : 'whereNotIn';
493 716 $query = $query->whereHas($provider, function ($q) use ($method, $filter) {
494 - $q->{$method}($filter['property'], $filter['value']);
717 + $q->{$method}($filter['property'], $filter['value']);
495 718 });
496 719 }
497 - elseif ($filter['operator'] == 'contains') {
498 - $operator = 'LIKE';
499 - $searchTerm = '%' . $filter['value'] . '%';
500 - $query = $this->buildSearchableQuery($provider, $query, $searchTerm, $operator);
501 - } elseif ($filter['operator'] == 'not_contains') {
502 - $operator = 'NOT LIKE';
503 - $searchTerm = '%' . $filter['value'] . '%';
504 - $query = $this->buildSearchableQuery($provider, $query, $searchTerm, $operator);
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 + });
505 726 }
506 727
507 - if ($filter['operator'] == '=') {
508 - $query->whereHas($provider, function ($q) use ($index, $filter) {
509 - if ($index == 0){
510 - $q->where($filter['property'], '=', $filter['value']);
511 - }else{
512 - $q->orWhere($filter['property'], '=', $filter['value']);
513 - }
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']);
514 732 });
515 - } elseif ($filter['operator'] == '!=') {
516 - $query->whereHas($provider, function ($q) use ($index, $filter) {
517 - if ($index == 0){
518 - $q->where($filter['property'], '!=', $filter['value']);
519 - }else{
520 - $q->orWhere($filter['property'], '!=', $filter['value']);
521 - }
522 - });
523 -
524 733 }
525 - return $query;
526 734 }
735 + return $query;
527 736 }
737 +
528 738 /**
529 - * One2Many: Customer has to many Click Tickets
739 + * Newest first.
740 + *
530 741 * @return Model Collection
531 742 */
532 743 public function responses()
533 744 {
@@ -534,11 +745,43 @@
534 745 $class = __NAMESPACE__ . '\Conversation';
535 746
536 747 return $this->hasMany(
537 748 $class, 'ticket_id', 'id'
538 - );
749 + )->orderBy('created_at', 'desc')
750 + ->orderBy('id', 'desc');
539 751 }
540 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 +
541 784 public function preview_response()
542 785 {
543 786 $class = __NAMESPACE__ . '\Conversation';
544 787
@@ -555,11 +798,18 @@
555 798 $class, 'fs_tag_pivot', 'source_id', 'tag_id'
556 799 )->wherePivot('source_type', 'ticket_tag');
557 800 }
558 801
802 + public function watchers()
803 + {
804 + $class = __NAMESPACE__ . '\TagPivot';
559 805
806 + return $this->hasMany($class, 'source_id', 'id')
807 + ->where('source_type', 'ticket_watcher')
808 + ->select(['tag_id']);
809 + }
810 +
560 811 /**
561 - * One2one: Customer has to many Click Tickets
562 812 * @return Model Collection
563 813 */
564 814 public function customer()
565 815 {
@@ -570,9 +820,8 @@
570 820 );
571 821 }
572 822
573 823 /**
574 - * One2one: Customer has to many Click Tickets
575 824 * @return Model Collection
576 825 */
577 826 public function agent()
578 827 {
@@ -591,8 +840,17 @@
591 840 $class, 'closed_by', 'id'
592 841 );
593 842 }
594 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 +
595 853 public function product()
596 854 {
597 855 $class = __NAMESPACE__ . '\Product';
598 856
@@ -612,17 +870,86 @@
612 870
613 871
614 872 public function deleteTicket()
615 873 {
874 + /*
875 + * Action on ticket deleting
876 + *
877 + * @since v1.0.0
878 + * @param object $ticket
879 + */
616 880 do_action('fluent_support/deleting_ticket', $this);
617 - // delete the responses first
618 - Conversation::where('ticket_id', $this->id)->delete();
619 - // Delete the ticket meta
620 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)->delete();
881 + $this->delete();
882 + }
621 883
622 - Ticket::where('id', $this->id)->delete();
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);
623 899 }
624 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 +
625 952 public static function slugify($title)
626 953 {
627 954 $slug = sanitize_title($title, 'support-ticket-' . time(), 'display');
628 955 if (Ticket::where('slug', $slug)->first()) {
@@ -684,19 +1011,27 @@
684 1011
685 1012 if ($value) {
686 1013 if (in_array($fieldType, $customRenderers) && $rendered) {
687 1014 $value = apply_filters('fluent_support/custom_field_render_' . $fieldType, $value, $scope);
688 - } else if ($fieldType == 'checkbox') {
1015 + } else if (in_array($fieldType, ['checkbox', 'date-range'])) {
689 1016 $value = array_values(array_filter(explode('|', $value)));
690 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 + }
691 1024 }
692 -
693 - $formattedData[$dataKey] = $value;
694 1025 }
695 1026
696 1027 return $formattedData;
697 1028 }
698 1029
1030 + /**
1031 + * @param array $data Custom field values keyed by field key.
1032 + * @return bool
1033 + */
699 1034 public function syncCustomFields($data)
700 1035 {
701 1036 if (!is_array($data)) {
702 1037 return false;
@@ -712,14 +1047,23 @@
712 1047
713 1048 $validData = Arr::only($data, $keys);
714 1049
715 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 +
716 1059 if ($fields[$dataKey]['type'] == 'checkbox' || is_array($validDatum)) {
717 1060 $validDatum = implode('|', $validDatum);
718 1061 $validDatum = '|' . $validDatum . '|';
719 1062 }
720 1063
721 - $exist = Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
1064 + $exist = Meta::where('object_type', 'ticket_meta')
1065 + ->where('object_id', $this->id)
722 1066 ->where('key', $dataKey)
723 1067 ->first();
724 1068
725 1069 if ($exist) {
@@ -734,29 +1078,22 @@
734 1078 ]);
735 1079 }
736 1080 }
737 1081
738 - // maybe delete data
739 - $deletedSlugs = array_diff($keys, array_keys($validData));
740 -
741 - if ($deletedSlugs) {
742 - Meta::where('object_type', 'ticket_meta')->where('object_id', $this->id)
743 - ->whereIn('key', $deletedSlugs)
744 - ->delete();
745 - }
746 -
747 1082 return true;
748 1083 }
749 1084
750 1085 public function getLastAgentResponse()
751 1086 {
752 - return \FluentSupport\App\App::db()->table('fs_conversations')
1087 + $query = \FluentSupport\App\App::db()->table('fs_conversations')
753 1088 ->select(['fs_conversations.*'])
754 1089 ->where('fs_conversations.conversation_type', 'response')
755 1090 ->where('fs_conversations.ticket_id', $this->id)
756 - ->join('fs_persons', 'fs_persons.person_type', '=', 'agent')
757 - ->orderBy('fs_conversations.id', 'DESC')
758 - ->first();
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();
759 1096 }
760 1097
761 1098 public function getLastResponse()
762 1099 {
@@ -762,19 +1099,36 @@
762 1099 {
763 1100 return \FluentSupport\App\App::db()->table('fs_conversations')
764 1101 ->where('ticket_id', $this->id)
765 1102 ->where('conversation_type', 'response')
766 - ->orderBy('id', 'DESC')
1103 + ->latest('id')
767 1104 ->first();
768 1105 }
769 1106
1107 + /**
1108 + * @param int|array $tagIds
1109 + * @return bool True if any tag was added.
1110 + */
770 1111 public function applyTags($tagIds)
771 1112 {
772 1113 $result = false;
1114 +
1115 + if (!is_array($tagIds)) {
1116 + $tagIds = array($tagIds);
1117 + }
1118 +
773 1119 foreach ($tagIds as $tagId) {
774 1120 if (!$this->hasTag($tagId)) {
775 1121 $this->tags()->attach($tagId, ['source_type' => 'ticket_tag']);
776 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 + */
777 1131 do_action('fluent_support/ticket_tag_added', $tagId, $this);
778 1132 }
779 1133 }
780 1134 return $result;
@@ -779,14 +1133,31 @@
779 1133 }
780 1134 return $result;
781 1135 }
782 1136
1137 + /**
1138 + * @param int|array $tagIds
1139 + * @return bool True if any tag was removed.
1140 + */
783 1141 public function detachTags($tagIds)
784 1142 {
785 1143 $result = false;
1144 +
1145 + if (!is_array($tagIds)) {
1146 + $tagIds = array($tagIds);
1147 + }
1148 +
786 1149 foreach ($tagIds as $tagId) {
787 1150 if ($this->hasTag($tagId)) {
788 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 + */
789 1160 do_action('fluent_support/ticket_tag_removed', $tagId, $this);
790 1161 $result = true;
791 1162 }
792 1163 }
@@ -791,5 +1162,128 @@
791 1162 }
792 1163 }
793 1164 return $result;
794 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 +
795 1289 }