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

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

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