PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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
fluent-support / app / Modules / MCP / Tools / ManagementTools.php

ManagementTools.php in Fluent Support – Helpdesk & Customer Support Ticket System trunk, at app/Modules/MCP/Tools/ManagementTools.php

967 lines 40.1 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\Modules\MCP\Tools;
4
5 use FluentSupport\App\Models\Agent;
6 use FluentSupport\App\Models\MailBox;
7 use FluentSupport\App\Models\Product;
8 use FluentSupport\App\Models\Ticket;
9 use FluentSupport\App\Models\TicketTag;
10 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
11 use FluentSupport\App\Modules\MCP\Support\PermissionGate;
12 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
13 use FluentSupport\App\Modules\PermissionManager;
14 use FluentSupport\App\Services\Helper;
15 use FluentSupport\App\Services\TicketHelper;
16 use FluentSupport\App\Services\Tickets\TicketService;
17
18 class ManagementTools
19 {
20 const CACHE_PREFIX = 'fsmcp_ctx_';
21 const CACHE_TTL = 60;
22 const MAX_AGENTS = 50;
23 const MAX_BULK = 50;
24 const MAX_TAG_NAMES = 20;
25
26 public static function getSupportContext($params)
27 {
28 $currentAgent = MCPHelper::resolveAgent();
29 if (!$currentAgent) {
30 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
31 }
32
33 $cacheKey = self::CACHE_PREFIX . $currentAgent->id;
34 $cached = get_transient($cacheKey);
35 if ($cached !== false) {
36 return $cached;
37 }
38
39 $result = self::buildContext($currentAgent);
40 set_transient($cacheKey, $result, self::CACHE_TTL);
41
42 return $result;
43 }
44
45 private static function buildContext($currentAgent)
46 {
47 $canSeeSensitive = PermissionManager::userCan('fst_sensitive_data');
48 $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
49
50 $agents = Agent::select(['id', 'first_name', 'last_name', 'email', 'status'])
51 ->where('person_type', 'agent')
52 ->where('first_name', '!=', '')
53 ->whereNotNull('first_name')
54 ->orderBy('first_name', 'ASC')
55 ->get()
56 ->map(function ($agent) use ($canSeeSensitive) {
57 $entry = [
58 'id' => $agent->id,
59 'name' => MCPHelper::personName($agent),
60 'status' => $agent->status,
61 ];
62 if ($canSeeSensitive) {
63 $entry['email'] = $agent->email;
64 }
65 return $entry;
66 })->toArray();
67
68 $products = Product::select(['id', 'title', 'description'])
69 ->orderBy('title', 'ASC')
70 ->get()
71 ->map(function ($p) {
72 return ['id' => $p->id, 'title' => $p->title, 'description' => $p->description ?: ''];
73 })->toArray();
74
75 $mailboxes = MailBox::getAccessibleBoxes($canSeeSensitive)
76 ->map(function ($mb) use ($canSeeSensitive) {
77 $entry = ['id' => $mb->id, 'name' => $mb->name];
78 if ($canSeeSensitive) {
79 $entry['email'] = $mb->email;
80 }
81 return $entry;
82 })->toArray();
83
84 $tags = TicketTag::select(['id', 'title'])
85 ->orderBy('title', 'ASC')
86 ->get()
87 ->toArray();
88
89 $statusCounts = Ticket::selectRaw('status, COUNT(*) as cnt')
90 ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
91 ->groupBy('status')
92 ->get()
93 ->pluck('cnt', 'status');
94
95 $stats = [
96 'total' => (int) $statusCounts->sum(),
97 'new' => (int) ($statusCounts['new'] ?? 0),
98 'active' => (int) ($statusCounts['active'] ?? 0),
99 'closed' => (int) ($statusCounts['closed'] ?? 0),
100 'unassigned' => Ticket::whereNull('agent_id')
101 ->whereNotIn('status', ['closed'])
102 ->when($restrictedMailboxes, fn($q) => $q->whereNotIn('mailbox_id', $restrictedMailboxes))
103 ->count(),
104 ];
105
106 $ticketFields = ['id', 'title', 'status', 'priority', 'customer_id', 'agent_id', 'waiting_since', 'created_at', 'response_count', 'last_agent_response', 'last_customer_response'];
107 $eagerLoad = [
108 'customer' => function ($q) { $q->select(['id', 'first_name', 'last_name', 'email']); },
109 ];
110
111 $slaSettings = PermissionGate::getSlaSettings();
112 $firstResponseHours = $slaSettings['first_response_hours'];
113 $resolutionHours = $slaSettings['resolution_hours'];
114
115 $now = strtotime(current_time('mysql'));
116 $firstResponseCutoff = date('Y-m-d H:i:s', $now - ($firstResponseHours * 3600));
117 $resolutionCutoff = date('Y-m-d H:i:s', $now - ($resolutionHours * 3600));
118
119 $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
120 ->where('agent_id', $currentAgent->id)
121 ->whereNotIn('status', ['closed']);
122
123 // Split by who's turn it is instead of ranking both populations by raw waiting_since:
124 // an agent-last ticket idle for 12 days and a customer-last ticket waiting 12 days both
125 // have an old waiting_since, but only the latter needs a reply. Mixing them let stale
126 // agent-last tickets crowd the actionable ones out of the capped list.
127 $awaitingYourReplyQuery = (clone $myQueueBase)->waitingOnly();
128 $awaitingYourReplyTotal = (clone $awaitingYourReplyQuery)->count();
129 $awaitingYourReply = $awaitingYourReplyQuery
130 ->orderBy('waiting_since', 'ASC')
131 ->limit(10)
132 ->get()
133 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
134 ->toArray();
135
136 $awaitingCustomerQuery = (clone $myQueueBase)->where(function ($q) {
137 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
138 });
139 $awaitingCustomerTotal = (clone $awaitingCustomerQuery)->count();
140 $awaitingCustomer = $awaitingCustomerQuery
141 ->orderBy('waiting_since', 'ASC')
142 ->limit(5)
143 ->get()
144 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
145 ->toArray();
146
147 $myQueue = [
148 'awaiting_your_reply' => $awaitingYourReply,
149 'awaiting_your_reply_total' => $awaitingYourReplyTotal,
150 'awaiting_your_reply_truncated' => $awaitingYourReplyTotal > count($awaitingYourReply),
151 'awaiting_customer' => $awaitingCustomer,
152 'awaiting_customer_total' => $awaitingCustomerTotal,
153 'awaiting_customer_truncated' => $awaitingCustomerTotal > count($awaitingCustomer),
154 ];
155
156 $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
157 ->whereNull('agent_id')
158 ->whereNotIn('status', ['closed'])
159 ->orderBy('created_at', 'ASC')
160 ->limit(5)
161 ->get()
162 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
163 ->toArray();
164
165 $longestWaiting = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
166 ->whereNotIn('status', ['closed'])
167 ->whereNotNull('waiting_since')
168 ->orderBy('waiting_since', 'ASC')
169 ->limit(5)
170 ->get()
171 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
172 ->toArray();
173
174 $critical = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
175 ->where('priority', 'critical')
176 ->whereNotIn('status', ['closed'])
177 ->orderBy('created_at', 'ASC')
178 ->limit(5)
179 ->get()
180 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
181 ->toArray();
182
183 $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
184 ->whereNotIn('status', ['closed'])
185 ->where('response_count', 0)
186 ->where('created_at', '<=', $firstResponseCutoff)
187 ->orderBy('created_at', 'ASC')
188 ->limit(5)
189 ->get()
190 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
191 ->toArray();
192
193 $slaResolution = static::scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
194 ->whereNotIn('status', ['closed'])
195 ->where('created_at', '<=', $resolutionCutoff)
196 ->orderBy('created_at', 'ASC')
197 ->limit(5)
198 ->get()
199 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
200 ->toArray();
201
202 $guidelines = apply_filters('fluent_support/mcp_ai_guidelines', PermissionGate::getAiGuidelines());
203 if (!$guidelines) {
204 $guidelines = 'Be professional and empathetic. Address customers by name. '
205 . 'For triage, prioritize: critical priority first, then longest waiting, then unassigned. '
206 . 'Always check the conversation history before replying. '
207 . 'Use internal notes to document decisions or escalation reasons.';
208 }
209
210 $openCount = $stats['new'] + $stats['active'];
211
212 return MCPHelper::envelope(
213 "Support context: {$openCount} open ticket(s), {$stats['unassigned']} unassigned",
214 [
215 'you' => [
216 'agent_id' => $currentAgent->id,
217 'name' => MCPHelper::personName($currentAgent),
218 'email' => $currentAgent->email,
219 ],
220 'my_queue' => $myQueue,
221 'needs_attention' => [
222 'unassigned' => $unassigned,
223 'longest_waiting' => $longestWaiting,
224 'critical' => $critical,
225 'sla_breach' => [
226 'no_first_response' => $slaFirstResponse,
227 'overdue_resolution' => $slaResolution,
228 'thresholds' => [
229 'first_response' => $firstResponseHours . 'h',
230 'resolution' => $resolutionHours . 'h',
231 ],
232 ],
233 ],
234 'agents' => $agents,
235 'products' => $products,
236 'mailboxes' => $mailboxes,
237 'tags' => $tags,
238 'stats' => $stats,
239 'priorities' => ['normal', 'medium', 'critical'],
240 'statuses' => ['new', 'active', 'closed'],
241 'guidelines' => $guidelines,
242 ]
243 );
244 }
245
246 public static function invalidateSupportContextCache()
247 {
248 global $wpdb;
249 $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
250 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
251 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
252 $like = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
253 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
254 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
255 }
256
257 /**
258 * Build a ticket query scoped to the current agent's visibility, mirroring listTickets().
259 *
260 * Applies the same `fluent_support/tickets_query_by_permission_ref` permission scope used by
261 * TicketTools::listTickets so the "needs attention" lists never expose tickets — or the
262 * customer names/emails they serialize — outside the agent's visibility scope. The
263 * restricted-mailbox filter alone is not enough: an agent scoped to only their own tickets
264 * must not enumerate other customers' tickets across the rest of the instance.
265 */
266 private static function scopedTicketQuery($ticketFields, $eagerLoad, $restrictedMailboxes)
267 {
268 $query = Ticket::select($ticketFields)->with($eagerLoad);
269
270 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
271
272 if ($restrictedMailboxes) {
273 $query->whereNotIn('mailbox_id', $restrictedMailboxes);
274 }
275
276 return $query;
277 }
278
279 private static function formatContextTicket($ticket, int $now)
280 {
281 $item = [
282 'id' => $ticket->id,
283 'title' => $ticket->title,
284 'status' => $ticket->status ?: 'new',
285 'priority' => MCPHelper::normalizePriority($ticket->priority),
286 'response_count' => (int) $ticket->response_count,
287 'last_reply_by' => $ticket->last_reply_by,
288 ];
289
290 if ($ticket->relationLoaded('customer') && $ticket->customer) {
291 $item['customer'] = MCPHelper::formatPersonSummary($ticket->customer);
292 }
293
294 if ($ticket->waiting_since) {
295 $wait = max(0, $now - strtotime($ticket->waiting_since));
296 $item['waiting'] = self::formatDuration($wait);
297 }
298
299 return $item;
300 }
301
302 public static function assignTicket($params)
303 {
304 $agent = MCPHelper::resolveAgent();
305 if (!$agent) {
306 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
307 }
308
309 $ticketId = (int) ($params['ticket_id'] ?? 0);
310 $newAgentId = (int) ($params['agent_id'] ?? 0);
311
312 if (!$ticketId || !$newAgentId) {
313 return MCPHelper::error('invalid_param', __('ticket_id and agent_id are required', 'fluent-support'), ['fields' => ['ticket_id', 'agent_id']]);
314 }
315
316 $ticket = Ticket::find($ticketId);
317 if (!$ticket) {
318 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
319 }
320
321 if ($err = TicketAccessGuard::assert($ticket)) {
322 return $err;
323 }
324
325 $newAgent = MCPHelper::resolveAssignmentTarget($newAgentId, $ticket, 'agent_id');
326 if (is_wp_error($newAgent)) {
327 return $newAgent;
328 }
329
330 MCPHelper::applyAgentAssignment($ticket, $newAgent, $agent);
331
332 $agentName = MCPHelper::personName($newAgent);
333
334 return MCPHelper::envelope(
335 "Ticket #{$ticketId} assigned to {$agentName}",
336 ['agent' => MCPHelper::formatPersonSummary($newAgent)]
337 );
338 }
339
340 public static function tagTicket($params)
341 {
342 $agent = MCPHelper::resolveAgent();
343 if (!$agent) {
344 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
345 }
346
347 $ticketId = (int) ($params['ticket_id'] ?? 0);
348 if (!$ticketId) {
349 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
350 }
351
352 $ticket = Ticket::find($ticketId);
353 if (!$ticket) {
354 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
355 }
356
357 if ($err = TicketAccessGuard::assert($ticket)) {
358 return $err;
359 }
360
361 $addTagIds = array_map('intval', (array) ($params['add_tag_ids'] ?? []));
362 $removeTagIds = array_map('intval', (array) ($params['remove_tag_ids'] ?? []));
363
364 $addTagNames = array_values(array_unique(array_filter(array_map('sanitize_text_field', (array) ($params['add_tag_names'] ?? [])), function ($name) {
365 return $name !== '';
366 })));
367
368 if (count($addTagNames) > self::MAX_TAG_NAMES) {
369 return MCPHelper::error('invalid_param', sprintf(__('add_tag_names may not exceed %d items per request. Split into multiple calls.', 'fluent-support'), self::MAX_TAG_NAMES), ['fields' => ['add_tag_names'], 'limit' => self::MAX_TAG_NAMES]);
370 }
371
372 $namesResult = self::findOrCreateTagsByTitles($addTagNames);
373 $failedTagNames = $namesResult['failed'];
374 $createdAnyTag = $namesResult['created_any'];
375 foreach ($namesResult['tags'] as $tag) {
376 $addTagIds[] = (int) $tag->id;
377 }
378
379 if ($addTagIds) {
380 $ticket->applyTags(array_unique($addTagIds));
381 }
382
383 if ($removeTagIds) {
384 $ticket->detachTags($removeTagIds);
385 }
386
387 if ($createdAnyTag) {
388 self::invalidateSupportContextCache();
389 }
390
391 $ticket->load('tags');
392
393 $summary = "Tags updated on ticket #{$ticketId}";
394 if ($failedTagNames) {
395 $summary .= sprintf(__(' (failed to create: %s)', 'fluent-support'), implode(', ', $failedTagNames));
396 }
397
398 $data = ['tags' => $ticket->tags->map(function ($tag) {
399 return ['id' => $tag->id, 'title' => $tag->title];
400 })->toArray()];
401
402 if ($failedTagNames) {
403 $data['failed_tag_names'] = array_values($failedTagNames);
404 }
405
406 return MCPHelper::envelope($summary, $data);
407 }
408
409 /**
410 * Find an existing ticket tag by exact title match, or create one.
411 * created_by is left unset so TicketTag::boot() applies its own
412 * get_current_user_id() default — the same convention every other
413 * tag-creation path (REST, UI) relies on; setting it here to an
414 * agent/person id would mix two incompatible id spaces in the column.
415 *
416 * @return array{tag: ?TicketTag, created: bool}|null
417 */
418 private static function findOrCreateTag($title, $description = null)
419 {
420 $title = trim($title);
421 if ($title === '') {
422 return null;
423 }
424
425 $existing = TicketTag::where('title', $title)->first();
426 if ($existing) {
427 return ['tag' => $existing, 'created' => false];
428 }
429
430 $data = ['title' => $title];
431 if ($description !== null) {
432 $data['description'] = $description;
433 }
434
435 return ['tag' => TicketTag::create($data), 'created' => true];
436 }
437
438 /**
439 * Batched sibling of findOrCreateTag() for tag-ticket's add_tag_names —
440 * resolves all titles with a single whereIn() lookup instead of one
441 * query per name, then creates only the titles that didn't already
442 * exist. Bounded by MAX_TAG_NAMES at the call site.
443 *
444 * @return array{tags: TicketTag[], created_any: bool, failed: string[]}
445 */
446 private static function findOrCreateTagsByTitles(array $titles)
447 {
448 if (!$titles) {
449 return ['tags' => [], 'created_any' => false, 'failed' => []];
450 }
451
452 $existingByTitle = TicketTag::whereIn('title', $titles)->get()->keyBy('title');
453
454 $tags = [];
455 $failed = [];
456 $createdAny = false;
457
458 foreach ($titles as $title) {
459 $existing = $existingByTitle->get($title);
460 if ($existing) {
461 $tags[] = $existing;
462 continue;
463 }
464
465 $tag = TicketTag::create(['title' => $title]);
466 if ($tag && $tag->id) {
467 $tags[] = $tag;
468 $createdAny = true;
469 } else {
470 $failed[] = $title;
471 }
472 }
473
474 return ['tags' => $tags, 'created_any' => $createdAny, 'failed' => $failed];
475 }
476
477 public static function createTag($params)
478 {
479 $agent = MCPHelper::resolveAgent();
480 if (!$agent) {
481 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
482 }
483
484 $title = sanitize_text_field($params['title'] ?? '');
485 if ($title === '') {
486 return MCPHelper::error('invalid_param', __('title is required', 'fluent-support'), ['fields' => ['title']]);
487 }
488
489 $description = isset($params['description']) ? sanitize_textarea_field($params['description']) : null;
490
491 $resolved = self::findOrCreateTag($title, $description);
492 $tag = $resolved['tag'] ?? null;
493
494 if (!$tag || !$tag->id) {
495 return MCPHelper::error('failed', __('Failed to create tag', 'fluent-support'), ['retryable' => true]);
496 }
497
498 if ($resolved['created']) {
499 self::invalidateSupportContextCache();
500 }
501
502 $summary = $resolved['created']
503 ? sprintf(__('Tag "%s" created', 'fluent-support'), $tag->title)
504 : sprintf(__('Tag "%s" already exists', 'fluent-support'), $tag->title);
505
506 return MCPHelper::envelope(
507 $summary,
508 ['tag' => ['id' => $tag->id, 'title' => $tag->title], 'created' => $resolved['created']]
509 );
510 }
511
512 public static function getSupportInsights($params)
513 {
514 $agent = MCPHelper::resolveAgent();
515 if (!$agent) {
516 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
517 }
518
519 $period = sanitize_text_field($params['period'] ?? '7d');
520 $periodMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
521 $days = $periodMap[$period] ?? 7;
522 $since = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
523
524 $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
525 $scoped = function () use ($restrictedMailboxes) {
526 $query = Ticket::query();
527 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
528 if ($restrictedMailboxes) {
529 $query->whereNotIn('mailbox_id', $restrictedMailboxes);
530 }
531 return $query;
532 };
533
534 $periodTicketsQuery = $scoped()->where('created_at', '>=', $since);
535 $closedInPeriodQuery = $scoped()->where('resolved_at', '>=', $since);
536
537 // Response times — aggregates in SQL; bounded sample only for median.
538 $rtBase = $scoped()->where('first_response_time', '>', 0)->where('created_at', '>=', $since);
539 $rtAgg = (clone $rtBase)->selectRaw('COUNT(*) as cnt, AVG(first_response_time) as avg_val, MAX(first_response_time) as max_val, MIN(first_response_time) as min_val')->first();
540 $rtSample = ($rtAgg && (int) $rtAgg->cnt > 0)
541 ? (clone $rtBase)->orderByDesc('id')->limit(500)->pluck('first_response_time')->toArray()
542 : [];
543
544 // Resolution times — same pattern.
545 $ctBase = (clone $closedInPeriodQuery)->where('total_close_time', '>', 0);
546 $ctAgg = (clone $ctBase)->selectRaw('COUNT(*) as cnt, AVG(total_close_time) as avg_val, MAX(total_close_time) as max_val, MIN(total_close_time) as min_val')->first();
547 $ctSample = ($ctAgg && (int) $ctAgg->cnt > 0)
548 ? (clone $ctBase)->orderByDesc('id')->limit(500)->pluck('total_close_time')->toArray()
549 : [];
550
551 // Waiting — split by who's turn it is. last_reply_by=customer tickets are genuinely
552 // waiting on an agent; last_reply_by=agent tickets only reflect how long the customer
553 // has been idle since our last reply. A single blended average over both populations
554 // measures mostly customer silence and hides how many tickets actually need a reply.
555 $waitOnAgentBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->waitingOnly();
556 $waitOnCustomerBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->where(function ($q) {
557 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
558 });
559 $waitingOnAgent = self::computeWaitStats($waitOnAgentBase);
560 $waitingOnCustomer = self::computeWaitStats($waitOnCustomerBase);
561
562 $allAgents = Agent::where('person_type', 'agent')
563 ->whereNotNull('first_name')
564 ->where('first_name', '!=', '')
565 ->select(['id', 'first_name', 'last_name'])
566 ->get()
567 ->keyBy('id');
568
569 $agentWorkload = $scoped()->whereNotIn('status', ['closed'])
570 ->whereNotNull('agent_id')
571 ->selectRaw('agent_id, COUNT(*) as ticket_count')
572 ->groupBy('agent_id')
573 ->get()
574 ->keyBy('agent_id');
575
576 $agentClosedInPeriod = $scoped()->where('resolved_at', '>=', $since)
577 ->whereNotNull('agent_id')
578 ->selectRaw('agent_id, COUNT(*) as closed_count')
579 ->groupBy('agent_id')
580 ->get()
581 ->keyBy('agent_id');
582
583 $agentResponseTimes = $scoped()->where('first_response_time', '>', 0)
584 ->where('created_at', '>=', $since)
585 ->whereNotNull('agent_id')
586 ->selectRaw('agent_id, AVG(first_response_time) as avg_response_time')
587 ->groupBy('agent_id')
588 ->get()
589 ->keyBy('agent_id');
590
591 $agentResolutionTimes = $scoped()->where('resolved_at', '>=', $since)
592 ->where('total_close_time', '>', 0)
593 ->whereNotNull('agent_id')
594 ->selectRaw('agent_id, AVG(total_close_time) as avg_close_time')
595 ->groupBy('agent_id')
596 ->get()
597 ->keyBy('agent_id');
598
599 $agentIds = $allAgents->keys()
600 ->merge($agentWorkload->keys())
601 ->merge($agentClosedInPeriod->keys())
602 ->unique();
603
604 $performance = [];
605 foreach ($agentIds as $agentId) {
606 $agentModel = $allAgents->get($agentId);
607 if (!$agentModel) {
608 continue;
609 }
610
611 $entry = [
612 'agent_id' => $agentId,
613 'agent_name' => MCPHelper::personName($agentModel),
614 'open_tickets' => (int) ($agentWorkload->get($agentId)->ticket_count ?? 0),
615 'closed_in_period' => (int) ($agentClosedInPeriod->get($agentId)->closed_count ?? 0),
616 ];
617
618 $avgResponse = $agentResponseTimes->get($agentId);
619 $entry['avg_first_response'] = $avgResponse
620 ? self::formatDuration((float) $avgResponse->avg_response_time)
621 : null;
622
623 $avgResolution = $agentResolutionTimes->get($agentId);
624 $entry['avg_resolution'] = $avgResolution
625 ? self::formatDuration((float) $avgResolution->avg_close_time)
626 : null;
627
628 $performance[] = $entry;
629 }
630
631 usort($performance, fn($a, $b) => $b['open_tickets'] - $a['open_tickets']);
632
633 $truncatedAgents = count($performance) > self::MAX_AGENTS;
634 if ($truncatedAgents) {
635 $performance = array_slice($performance, 0, self::MAX_AGENTS);
636 }
637
638 $created = (clone $periodTicketsQuery)->count();
639 $closed = (clone $closedInPeriodQuery)->count();
640
641 return MCPHelper::envelope(
642 "Support insights for {$period}: {$created} created, {$closed} closed",
643 [
644 'period' => $period,
645 'volume' => ['created' => $created, 'closed' => $closed],
646 'first_response_time' => self::computeTimeStatsFromAgg($rtAgg, $rtSample),
647 'resolution_time' => self::computeTimeStatsFromAgg($ctAgg, $ctSample),
648 'waiting' => [
649 'waiting_on_agent' => $waitingOnAgent,
650 'waiting_on_customer' => $waitingOnCustomer,
651 ],
652 'agent_performance' => $performance,
653 'agent_performance_truncated' => $truncatedAgents,
654 ]
655 );
656 }
657
658 private static function computeTimeStatsFromAgg($agg, array $sample)
659 {
660 if (!$agg || (int) $agg->cnt === 0) {
661 return ['count' => 0, 'average' => null, 'median' => null, 'max' => null, 'min' => null];
662 }
663
664 return [
665 'count' => (int) $agg->cnt,
666 'average' => self::formatDuration((float) $agg->avg_val),
667 'median' => self::formatDuration(self::median($sample)),
668 'max' => self::formatDuration((float) $agg->max_val),
669 'min' => self::formatDuration((float) $agg->min_val),
670 ];
671 }
672
673 /**
674 * Aggregate + bounded-sample-for-median pattern for a waiting_since-based population,
675 * mirroring computeTimeStatsFromAgg() but for the TIMESTAMPDIFF-against-now duration used
676 * by the two waiting populations (waiting_on_agent / waiting_on_customer) instead of a
677 * stored duration column.
678 */
679 private static function computeWaitStats($baseQuery)
680 {
681 $agg = (clone $baseQuery)
682 ->selectRaw('COUNT(*) as cnt, AVG(TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP())) as avg_val, MAX(TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP())) as max_val')
683 ->first();
684 $sample = ($agg && (int) $agg->cnt > 0)
685 ? (clone $baseQuery)->selectRaw('TIMESTAMPDIFF(SECOND, waiting_since, UTC_TIMESTAMP()) as wait_sec')->orderByDesc('id')->limit(500)->pluck('wait_sec')->toArray()
686 : [];
687
688 return [
689 'count' => $agg ? (int) $agg->cnt : 0,
690 'average' => self::formatDuration($agg ? (float) $agg->avg_val : 0),
691 'max' => self::formatDuration($agg ? (float) $agg->max_val : 0),
692 'median' => self::formatDuration(self::median($sample)),
693 ];
694 }
695
696 private static function median(array $values)
697 {
698 if (empty($values)) {
699 return 0;
700 }
701 sort($values);
702 $count = count($values);
703 $mid = (int) floor($count / 2);
704 return ($count % 2 === 0)
705 ? ($values[$mid - 1] + $values[$mid]) / 2
706 : $values[$mid];
707 }
708
709 private static function formatDuration($seconds)
710 {
711 return MCPHelper::formatDuration($seconds);
712 }
713
714 public static function bulkAction($params)
715 {
716 $agent = MCPHelper::resolveAgent();
717 if (!$agent) {
718 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
719 }
720
721 $ticketIds = array_map('intval', (array) ($params['ticket_ids'] ?? []));
722 $action = sanitize_text_field($params['action'] ?? '');
723
724 if (empty($ticketIds) || !$action) {
725 return MCPHelper::error('invalid_param', __('ticket_ids and action are required', 'fluent-support'), ['fields' => ['ticket_ids', 'action']]);
726 }
727
728 if (count($ticketIds) > self::MAX_BULK) {
729 return MCPHelper::error('invalid_param', sprintf(__('ticket_ids may not exceed %d items per request. Split into multiple calls.', 'fluent-support'), self::MAX_BULK), ['fields' => ['ticket_ids'], 'limit' => self::MAX_BULK]);
730 }
731
732 $allowedActions = ['close', 'assign', 'tag'];
733 if (!in_array($action, $allowedActions)) {
734 return MCPHelper::error('invalid_param', sprintf(__('action must be one of: %s', 'fluent-support'), implode(', ', $allowedActions)), ['fields' => ['action']]);
735 }
736
737 $assignTarget = null;
738 if ($action === 'assign') {
739 // Null ticket: each ticket's mailbox restriction is checked per-item
740 // in the loop below, since the batch can span multiple mailboxes.
741 $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'] ?? 0, null, 'agent_id');
742 if (is_wp_error($assignTarget)) {
743 return $assignTarget;
744 }
745 }
746
747 if ($action === 'tag') {
748 $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
749 if (empty($tagIds)) {
750 return MCPHelper::error('invalid_param', __('tag_ids is required for the tag action', 'fluent-support'), ['fields' => ['tag_ids']]);
751 }
752 }
753
754 $tickets = Ticket::whereIn('id', $ticketIds)->get();
755 $ticketService = new TicketService();
756 $results = [];
757 $foundIds = $tickets->pluck('id')->toArray();
758
759 foreach ($ticketIds as $tid) {
760 if (!in_array($tid, $foundIds)) {
761 $results[] = ['id' => $tid, 'status' => 'not_found'];
762 }
763 }
764
765 foreach ($tickets as $ticket) {
766 if (TicketAccessGuard::assert($ticket)) {
767 $results[] = ['id' => $ticket->id, 'status' => 'forbidden'];
768 continue;
769 }
770
771 // Isolate each ticket: a failure on one (e.g. inside close(),
772 // onAgentChange(), or applyTags()) must not abort the whole batch.
773 try {
774 switch ($action) {
775 case 'close':
776 if ($ticket->status === 'closed') {
777 $results[] = ['id' => $ticket->id, 'status' => 'already_closed'];
778 } else {
779 $ticketService->close($ticket, $agent);
780 $results[] = ['id' => $ticket->id, 'status' => 'closed'];
781 }
782 break;
783
784 case 'assign':
785 if (TicketAccessGuard::assertAssignableAgent($ticket, $assignTarget)) {
786 $results[] = ['id' => $ticket->id, 'status' => 'mailbox_restricted'];
787 break;
788 }
789 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
790 $results[] = ['id' => $ticket->id, 'status' => 'assigned'];
791 break;
792
793 case 'tag':
794 $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
795 $ticket->applyTags($tagIds);
796 $results[] = ['id' => $ticket->id, 'status' => 'tagged'];
797 break;
798 }
799 } catch (\Exception $e) {
800 $results[] = ['id' => $ticket->id, 'status' => 'error', 'message' => $e->getMessage()];
801 }
802 }
803
804 $processed = count(array_filter($results, function ($r) {
805 return !in_array($r['status'], ['not_found', 'forbidden', 'mailbox_restricted', 'error']);
806 }));
807
808 $total = count($ticketIds);
809 $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged'];
810 $verb = $verbMap[$action] ?? $action;
811
812 return MCPHelper::envelope(
813 "{$processed} of {$total} ticket(s) {$verb}",
814 ['results' => $results],
815 ['processed' => $processed, 'total' => $total]
816 );
817 }
818
819 public static function getMentions($params)
820 {
821 $agent = MCPHelper::resolveAgent();
822 if (!$agent) {
823 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
824 }
825
826 $notificationSettings = new \FluentSupport\App\Services\Notifications\NotificationSettings();
827 if (!$notificationSettings->canUseNotificationTables(true)) {
828 return MCPHelper::error('not_available', __('Internal notifications are not enabled. Enable them in Fluent Support Settings > Notifications.', 'fluent-support'), [
829 'next_step' => 'Enable internal notifications in Fluent Support admin settings',
830 ]);
831 }
832
833 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params, 15);
834
835 $status = sanitize_text_field($params['status'] ?? 'all');
836 if (!in_array($status, ['all', 'unread', 'read'], true)) {
837 $status = 'all';
838 }
839
840 $filters = [
841 'category' => \FluentSupport\App\Services\Notifications\NotificationCategory::MENTIONS,
842 ];
843
844 if ($status !== 'all') {
845 $filters['status'] = $status;
846 }
847
848 if (!empty($params['ticket_id'])) {
849 $filters['ticket_id'] = (int) $params['ticket_id'];
850 }
851
852 $queryService = new \FluentSupport\App\Services\Notifications\NotificationQueryService();
853 $query = $queryService->getNotificationsForPerson($agent->id, $filters);
854
855 if (!$query) {
856 return MCPHelper::error('not_available', __('Internal notifications are not enabled.', 'fluent-support'));
857 }
858
859 $paginated = $query->paginate($perPage, ['*'], 'page', $page);
860
861 $unreadCount = (new \FluentSupport\App\Services\Notifications\NotificationQueryService())
862 ->getUnreadCount($agent->id, ['category' => \FluentSupport\App\Services\Notifications\NotificationCategory::MENTIONS]);
863
864 $mentions = [];
865 foreach ($paginated->items() as $notification) {
866 $payload = $notification->getPayloadAttribute($notification->getRawOriginal('payload') ?? null);
867 if (!is_array($payload)) {
868 $payload = [];
869 }
870
871 $readStatus = null;
872 if ($notification->relationLoaded('recipients') && $notification->recipients->isNotEmpty()) {
873 $recipient = $notification->recipients->first();
874 $readStatus = (bool) $recipient->is_read;
875 }
876
877 $actor = $notification->relationLoaded('actor') ? $notification->actor : null;
878 $ticket = $notification->relationLoaded('ticket') ? $notification->ticket : null;
879 $actorName = $actor ? MCPHelper::personName($actor) : (__('Someone', 'fluent-support'));
880 $ticketTitle = $ticket ? $ticket->title : ($payload['ticket_title'] ?? null);
881
882 $summary = $ticketTitle
883 ? sprintf(__('%1$s mentioned you in "%2$s"', 'fluent-support'), $actorName, $ticketTitle)
884 : sprintf(__('%1$s mentioned you', 'fluent-support'), $actorName);
885
886 $mention = [
887 'id' => $notification->id,
888 'summary' => $summary,
889 'is_read' => $readStatus,
890 'ticket_id' => $notification->ticket_id,
891 'ticket_title' => $ticketTitle,
892 'conversation_id' => $notification->conversation_id,
893 'mentioned_by' => $actor ? MCPHelper::formatPersonSummary($actor) : null,
894 'content_preview' => isset($payload['content_preview']) ? sanitize_text_field($payload['content_preview']) : null,
895 'created_at' => MCPHelper::toIso8601($notification->created_at),
896 ];
897
898 $mentions[] = $mention;
899 }
900
901 $total = $paginated->total();
902
903 return MCPHelper::envelope(
904 sprintf(
905 _n('Found %d mention', 'Found %d mentions', $total, 'fluent-support'),
906 $total
907 ) . ($unreadCount ? " ({$unreadCount} unread)" : ''),
908 ['mentions' => $mentions, 'unread_count' => $unreadCount],
909 MCPHelper::pagingMeta($paginated)
910 );
911 }
912
913 public static function listWorkflows($params)
914 {
915 if (!class_exists('\FluentSupportPro\App\Models\Workflow')) {
916 return MCPHelper::error('not_available', __('Workflows require Fluent Support Pro', 'fluent-support'));
917 }
918
919 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
920
921 $query = \FluentSupportPro\App\Models\Workflow::query();
922
923 $status = sanitize_text_field($params['status'] ?? 'published');
924 if ($status !== 'all') {
925 $query->where('status', $status);
926 }
927
928 if (!empty($params['trigger_type'])) {
929 $query->where('trigger_type', sanitize_text_field($params['trigger_type']));
930 }
931
932 $paginated = $query->with([
933 'actions' => fn($q) => $q->select(['id', 'workflow_id', 'title', 'action_name']),
934 ])->orderBy('priority', 'ASC')->paginate($perPage, ['*'], 'page', $page);
935
936 $triggerLabels = [
937 'fluent_support/ticket_created' => 'When a new ticket is created',
938 'fluent_support/response_added_by_customer' => 'When a customer replies',
939 'fluent_support/ticket_closed' => 'When a ticket is closed',
940 ];
941
942 $total = $paginated->total();
943
944 $workflows = array_map(function ($wf) use ($triggerLabels) {
945 return [
946 'id' => $wf->id,
947 'title' => $wf->title,
948 'status' => $wf->status,
949 'trigger_type' => $wf->trigger_type,
950 'trigger_key' => sanitize_text_field($wf->trigger_key),
951 'trigger_label' => $triggerLabels[$wf->trigger_key] ?? sanitize_text_field($wf->trigger_key),
952 'actions' => $wf->actions->map(fn($a) => [
953 'action' => $a->action_name,
954 'title' => $a->title,
955 ])->toArray(),
956 'last_ran_at' => MCPHelper::toIso8601($wf->last_ran_at),
957 ];
958 }, $paginated->items());
959
960 return MCPHelper::envelope(
961 sprintf(_n('Found %d workflow', 'Found %d workflows', $total, 'fluent-support'), $total),
962 ['workflows' => $workflows],
963 MCPHelper::pagingMeta($paginated)
964 );
965 }
966 }
967