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

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

1,045 lines 45.3 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\AgentTicketAccess;
17 use FluentSupport\App\Services\Tickets\TicketService;
18
19 class ManagementTools
20 {
21 const CACHE_PREFIX = 'fsmcp_ctx_';
22 const CACHE_TTL = 60;
23
24 /** Bumped to invalidate every agent's cached context; it is part of the cache key. */
25 const CACHE_VERSION_OPTION = '_fluent_support_mcp_ctx_version';
26 const MAX_AGENTS = 50;
27 const MAX_BULK = 50;
28 const MAX_TAG_NAMES = 20;
29
30 public static function getSupportContext($params)
31 {
32 $currentAgent = MCPHelper::resolveAgent();
33 if (!$currentAgent) {
34 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
35 }
36
37 $cacheKey = self::CACHE_PREFIX . $currentAgent->id . '_' . (int) get_option(self::CACHE_VERSION_OPTION, 0);
38 $cached = get_transient($cacheKey);
39 if ($cached !== false) {
40 return $cached;
41 }
42
43 $result = self::buildContext($currentAgent);
44 set_transient($cacheKey, $result, self::CACHE_TTL);
45
46 return $result;
47 }
48
49 private static function buildContext($currentAgent)
50 {
51 $canSeeSensitive = PermissionManager::userCan('fst_sensitive_data');
52 $access = new AgentTicketAccess();
53
54 $agents = Agent::select(['id', 'first_name', 'last_name', 'email', 'status'])
55 ->where('person_type', 'agent')
56 ->where('first_name', '!=', '')
57 ->whereNotNull('first_name')
58 ->orderBy('first_name', 'ASC')
59 ->get()
60 ->map(function ($agent) use ($canSeeSensitive) {
61 $entry = [
62 'id' => $agent->id,
63 'name' => MCPHelper::personName($agent),
64 'status' => $agent->status,
65 ];
66 if ($canSeeSensitive) {
67 $entry['email'] = $agent->email;
68 }
69 return $entry;
70 })->toArray();
71
72 $products = Product::select(['id', 'title', 'description'])
73 ->orderBy('title', 'ASC')
74 ->get()
75 ->map(function ($p) {
76 return ['id' => $p->id, 'title' => $p->title, 'description' => $p->description ?: ''];
77 })->toArray();
78
79 $signature = \FluentSupport\App\Services\Tickets\ResponseService::getAppendedSignature($currentAgent);
80
81 $mailboxes = MailBox::getAccessibleBoxes($canSeeSensitive)
82 ->map(function ($mb) use ($canSeeSensitive, $currentAgent, $signature) {
83 $entry = ['id' => $mb->id, 'name' => $mb->name];
84 if ($canSeeSensitive) {
85 $entry['email'] = $mb->email;
86 }
87 // What the customer receives around the agent's text, so replies
88 // don't repeat the template's greeting, sign-off or signature.
89 $entry['emails'] = [
90 'reply' => self::describeAgentEmail($mb, 'ticket_replied_by_agent_email_to_customer', $currentAgent, $signature, true),
91 'outreach' => self::describeAgentEmail($mb, 'ticket_created_by_agent_on_behalf_email_to_customer', $currentAgent, $signature, true),
92 ];
93 return $entry;
94 })->toArray();
95
96 $tags = TicketTag::select(['id', 'title'])
97 ->orderBy('title', 'ASC')
98 ->get()
99 ->toArray();
100
101 $statusCounts = $access->applyMailboxRestrictionScope(Ticket::selectRaw('status, COUNT(*) as cnt'))
102 ->groupBy('status')
103 ->get()
104 ->pluck('cnt', 'status');
105
106 $stats = [
107 'total' => (int) $statusCounts->sum(),
108 'new' => (int) ($statusCounts['new'] ?? 0),
109 'active' => (int) ($statusCounts['active'] ?? 0),
110 'closed' => (int) ($statusCounts['closed'] ?? 0),
111 'unassigned' => $access->applyMailboxRestrictionScope(Ticket::whereNull('agent_id'))
112 ->whereNotIn('status', ['closed'])
113 ->count(),
114 ];
115
116 $ticketFields = ['id', 'title', 'status', 'priority', 'customer_id', 'agent_id', 'waiting_since', 'created_at', 'response_count', 'last_agent_response', 'last_customer_response'];
117 $eagerLoad = [
118 'customer' => function ($q) { $q->select(['id', 'first_name', 'last_name', 'email']); },
119 ];
120
121 $slaSettings = PermissionGate::getSlaSettings();
122 $firstResponseHours = $slaSettings['first_response_hours'];
123 $resolutionHours = $slaSettings['resolution_hours'];
124
125 $now = strtotime(current_time('mysql'));
126 $firstResponseCutoff = date('Y-m-d H:i:s', $now - ($firstResponseHours * 3600));
127 $resolutionCutoff = date('Y-m-d H:i:s', $now - ($resolutionHours * 3600));
128
129 $myQueueBase = static::scopedTicketQuery($ticketFields, $eagerLoad)
130 ->where('agent_id', $currentAgent->id)
131 ->whereNotIn('status', ['closed']);
132
133 // Split by whose turn it is instead of ranking both populations by raw waiting_since:
134 // an agent-last ticket idle for 12 days and a customer-last ticket waiting 12 days both
135 // have an old waiting_since, but only the latter needs a reply. Mixing them let stale
136 // agent-last tickets crowd the actionable ones out of the capped list.
137 $awaitingYourReplyQuery = (clone $myQueueBase)->waitingOnly();
138 $awaitingYourReplyTotal = (clone $awaitingYourReplyQuery)->count();
139 $awaitingYourReply = $awaitingYourReplyQuery
140 ->orderBy('waiting_since', 'ASC')
141 ->limit(10)
142 ->get()
143 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
144 ->toArray();
145
146 $awaitingCustomerQuery = (clone $myQueueBase)->where(function ($q) {
147 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
148 });
149 $awaitingCustomerTotal = (clone $awaitingCustomerQuery)->count();
150 $awaitingCustomer = $awaitingCustomerQuery
151 ->orderBy('waiting_since', 'ASC')
152 ->limit(5)
153 ->get()
154 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
155 ->toArray();
156
157 $myQueue = [
158 'awaiting_your_reply' => $awaitingYourReply,
159 'awaiting_your_reply_total' => $awaitingYourReplyTotal,
160 'awaiting_your_reply_truncated' => $awaitingYourReplyTotal > count($awaitingYourReply),
161 'awaiting_customer' => $awaitingCustomer,
162 'awaiting_customer_total' => $awaitingCustomerTotal,
163 'awaiting_customer_truncated' => $awaitingCustomerTotal > count($awaitingCustomer),
164 ];
165
166 $unassigned = static::scopedTicketQuery($ticketFields, $eagerLoad)
167 ->whereNull('agent_id')
168 ->whereNotIn('status', ['closed'])
169 ->orderBy('created_at', 'ASC')
170 ->limit(5)
171 ->get()
172 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
173 ->toArray();
174
175 // Only tickets waiting on an agent: a ticket we answered months ago has the
176 // oldest waiting_since but needs nothing from us.
177 $longestWaiting = static::scopedTicketQuery($ticketFields, $eagerLoad)
178 ->whereNotIn('status', ['closed'])
179 ->whereNotNull('waiting_since')
180 ->waitingOnly()
181 ->orderBy('waiting_since', 'ASC')
182 ->limit(5)
183 ->get()
184 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
185 ->toArray();
186
187 $critical = static::scopedTicketQuery($ticketFields, $eagerLoad)
188 ->where('priority', 'critical')
189 ->whereNotIn('status', ['closed'])
190 ->orderBy('created_at', 'ASC')
191 ->limit(5)
192 ->get()
193 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
194 ->toArray();
195
196 $slaFirstResponse = static::scopedTicketQuery($ticketFields, $eagerLoad)
197 ->whereNotIn('status', ['closed'])
198 ->where('response_count', 0)
199 ->where('created_at', '<=', $firstResponseCutoff)
200 ->orderBy('created_at', 'ASC')
201 ->limit(5)
202 ->get()
203 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
204 ->toArray();
205
206 // Past the resolution target and still waiting on an agent. Tickets waiting on the
207 // customer are excluded: nothing we can do moves them.
208 $slaResolution = static::scopedTicketQuery($ticketFields, $eagerLoad)
209 ->whereNotIn('status', ['closed'])
210 ->where('created_at', '<=', $resolutionCutoff)
211 ->waitingOnly()
212 ->orderBy('created_at', 'ASC')
213 ->limit(5)
214 ->get()
215 ->map(function ($t) use ($now) { return self::formatContextTicket($t, $now); })
216 ->toArray();
217
218 $guidelines = apply_filters('fluent_support/mcp_ai_guidelines', PermissionGate::getAiGuidelines());
219 if (!$guidelines) {
220 // Tone and conduct only: the triage order is in the server instructions.
221 $guidelines = 'Be professional and empathetic. Address customers by name. '
222 . 'Read the whole conversation before replying. '
223 . 'Use internal notes to record decisions and escalations.';
224 }
225
226 $openCount = $stats['new'] + $stats['active'];
227
228 return MCPHelper::envelope(
229 "Support context: {$openCount} open ticket(s), {$stats['unassigned']} unassigned",
230 [
231 'you' => array_merge([
232 'agent_id' => $currentAgent->id,
233 'name' => MCPHelper::personName($currentAgent),
234 'email' => $currentAgent->email,
235 ], self::replyMode(), [
236 // Added only where an inbox email template uses {{agent.signature}};
237 // each mailbox's emails say whether theirs does.
238 'signature' => $signature !== '' ? [
239 'enabled' => true,
240 'text' => mb_substr(MCPHelper::htmlToMarkdown($signature), 0, self::SIGNATURE_MAX_CHARS),
241 ] : [
242 'enabled' => false,
243 'note' => 'You have no signature set.',
244 ],
245 ]),
246 'my_queue' => $myQueue,
247 'needs_attention' => [
248 'unassigned' => $unassigned,
249 'longest_waiting' => $longestWaiting,
250 'critical' => $critical,
251 'sla_breach' => [
252 'no_first_response' => $slaFirstResponse,
253 'overdue_resolution' => $slaResolution,
254 'thresholds' => [
255 'first_response' => $firstResponseHours . 'h',
256 'resolution' => $resolutionHours . 'h',
257 ],
258 ],
259 ],
260 'agents' => $agents,
261 'products' => $products,
262 'mailboxes' => $mailboxes,
263 'tags' => $tags,
264 'stats' => $stats,
265 'priorities' => ['normal', 'medium', 'critical'],
266 'statuses' => ['new', 'active', 'closed'],
267 'uploads' => self::uploadLimits(),
268 // Every date the MCP returns is in this zone, shown by its offset.
269 'site_timezone' => wp_timezone_string(),
270 'guidelines' => $guidelines,
271 ]
272 );
273 }
274
275 /**
276 * How this agent's replies go out, as reply-to-ticket applies it. Only an agent
277 * who cannot send directly gets a sentence explaining why.
278 *
279 * @return array
280 */
281 private static function replyMode()
282 {
283 if (PermissionManager::userCan(\FluentSupport\App\Modules\MCP\Support\AbilityGuard::MANAGE_TICKET_CAPS)) {
284 return ['replies' => 'send'];
285 }
286
287 if (PermissionManager::currentUserCan('fst_draft_reply')) {
288 return [
289 'replies' => 'draft_only',
290 'replies_note' => 'You only have the Draft Reply permission: reply-to-ticket saves your replies as drafts for another agent to approve.',
291 ];
292 }
293
294 return [
295 'replies' => 'none',
296 'replies_note' => 'You can read tickets but not reply to them.',
297 ];
298 }
299
300 /** How much of an inbox email template the MCP shows, in characters of Markdown. */
301 const TEMPLATE_MAX_CHARS = 2000;
302
303 /** How much of the agent's signature the MCP shows, in characters of Markdown. */
304 const SIGNATURE_MAX_CHARS = 1000;
305
306 /**
307 * The email the customer gets when the agent writes from this mailbox: the
308 * template itself, with [YOUR MESSAGE] where the agent's text goes and
309 * [YOUR SIGNATURE] where their signature is added. The agent reads the template
310 * and decides what to leave out; nothing here guesses at greetings or sign-offs.
311 *
312 * adds_signature is true only when the template has {{agent.signature}} and the
313 * agent has a signature; then [YOUR SIGNATURE] marks it.
314 *
315 * @param MailBox $mailbox
316 * @param string $emailKey
317 * @param Agent $agent
318 * @param string|null $signature The agent's signature HTML. Pass it when describing several mailboxes for one agent.
319 * @param bool $compact For get-support-context: leave out the signature text (it is under
320 * you.signature) and the shared note (it is in the server instructions).
321 * @return array
322 */
323 public static function describeAgentEmail($mailbox, $emailKey, $agent, $signature = null, $compact = false)
324 {
325 if ($signature === null) {
326 $signature = \FluentSupport\App\Services\Tickets\ResponseService::getAppendedSignature($agent);
327 }
328 $hasSignature = (string) $signature !== '';
329
330 $settings = (new \FluentSupport\App\Services\EmailNotification\Settings())->getBoxEmailSettings($mailbox, $emailKey);
331 if (!$settings) {
332 return ['sent' => false, 'note' => 'This inbox does not send this email.'];
333 }
334
335 $body = (string) ($settings['email_body'] ?? '');
336
337 // The outreach email carries the agent's text as {{ticket.content}}: for an
338 // agent-initiated ticket that is the agent's first message.
339 $contentPattern = $emailKey === 'ticket_created_by_agent_on_behalf_email_to_customer'
340 ? '/{{\s*(response\.(full_)?content|ticket\.content)\s*}}/'
341 : '/{{\s*response\.(full_)?content\s*}}/';
342 $signaturePattern = '/{{\s*agent\.signature\s*}}/';
343
344 $includesMessage = (bool) preg_match($contentPattern, $body);
345 $hasSignatureSlot = (bool) preg_match($signaturePattern, $body);
346 $addsSignature = $hasSignature && $hasSignatureSlot;
347
348 // The signature placeholder renders empty for an agent without one.
349 $shown = preg_replace($contentPattern, '[YOUR MESSAGE]', $body);
350 $shown = preg_replace($signaturePattern, $hasSignature ? '[YOUR SIGNATURE]' : '', $shown);
351 $template = MCPHelper::htmlToMarkdown($shown);
352 $truncated = mb_strlen($template) > self::TEMPLATE_MAX_CHARS;
353 if ($truncated) {
354 $template = mb_substr($template, 0, self::TEMPLATE_MAX_CHARS) . '...';
355 }
356
357 // From the agent's side a signature is either added automatically or not;
358 // why not (no slot in the template, or no signature set) doesn't change what to write.
359 if (!$includesMessage) {
360 $note = 'This email does not include your message: it tells the customer to read it in the portal.';
361 } elseif ($compact) {
362 // The shared rule is in the server instructions; adds_signature carries the rest.
363 $note = '';
364 } else {
365 $note = 'The customer gets this template with [YOUR MESSAGE] replaced by your text, so leave out any greeting or sign-off it already has. '
366 . ($addsSignature
367 ? 'Your signature, shown in signature, is added automatically at [YOUR SIGNATURE], so do not sign your message. '
368 : 'No signature is added automatically. ')
369 . 'The [...] markers are for reading the template only; never type them in a reply.';
370 }
371
372 $description = [
373 'sent' => ($settings['status'] ?? 'no') === 'yes',
374 'includes_reply' => $includesMessage,
375 'adds_signature' => $addsSignature,
376 'subject' => (string) ($settings['email_subject'] ?? ''),
377 'template' => $template,
378 'from_name' => $mailbox->getFromName($agent),
379 ];
380
381 if ($truncated) {
382 $description['template_truncated'] = true;
383 }
384
385 if ($addsSignature && !$compact) {
386 $description['signature'] = mb_substr(MCPHelper::htmlToMarkdown($signature), 0, self::SIGNATURE_MAX_CHARS);
387 }
388
389 if ($note !== '') {
390 $description['note'] = $note;
391 }
392
393 return $description;
394 }
395
396 /**
397 * The limits AttachmentStager applies to files sent with a reply or note.
398 *
399 * @return array
400 */
401 private static function uploadLimits()
402 {
403 $settings = (new \FluentSupport\App\Services\EmailNotification\Settings())->globalBusinessSettings();
404
405 return [
406 'max_files' => \FluentSupport\App\Modules\MCP\Support\AttachmentStager::maxFiles(),
407 'max_file_size_mb' => floatval($settings['max_file_size'] ?? 0) ?: null,
408 'allowed_extensions' => \FluentSupport\App\Modules\MCP\Support\AttachmentStager::allowedExtensions(),
409 'note' => 'Per reply or note: up to max_files files, each up to max_file_size_mb as a file (base64 text is about a third larger).',
410 ];
411 }
412
413 public static function invalidateSupportContextCache()
414 {
415 update_option(self::CACHE_VERSION_OPTION, (int) get_option(self::CACHE_VERSION_OPTION, 0) + 1, false);
416 }
417
418 /**
419 * Build a ticket query scoped to the current agent's visibility, mirroring listTickets().
420 *
421 * Applies the same `fluent_support/tickets_query_by_permission_ref` permission scope used by
422 * TicketTools::listTickets so the "needs attention" lists never expose tickets — or the
423 * customer names/emails they serialize — outside the agent's visibility scope. The
424 * restricted-mailbox filter alone is not enough: an agent scoped to only their own tickets
425 * must not enumerate other customers' tickets across the rest of the instance.
426 */
427 private static function scopedTicketQuery($ticketFields, $eagerLoad)
428 {
429 $query = Ticket::select($ticketFields)->with($eagerLoad);
430
431 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
432
433 return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
434 }
435
436 private static function formatContextTicket($ticket, int $now)
437 {
438 $item = [
439 'id' => $ticket->id,
440 'title' => $ticket->title,
441 'status' => $ticket->status ?: 'new',
442 'priority' => MCPHelper::normalizePriority($ticket->priority),
443 'response_count' => (int) $ticket->response_count,
444 'waiting_on' => MCPHelper::waitingOn($ticket),
445 ];
446
447 if ($ticket->relationLoaded('customer') && $ticket->customer) {
448 $item['customer'] = MCPHelper::formatPersonSummary($ticket->customer);
449 }
450
451 if ($ticket->waiting_since) {
452 $wait = max(0, $now - strtotime($ticket->waiting_since));
453 $item['waiting'] = self::formatDuration($wait);
454 }
455
456 return $item;
457 }
458
459 public static function tagTicket($params)
460 {
461 $agent = MCPHelper::resolveAgent();
462 if (!$agent) {
463 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
464 }
465
466 $ticketId = (int) ($params['ticket_id'] ?? 0);
467 if (!$ticketId) {
468 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
469 }
470
471 $ticket = Ticket::find($ticketId);
472 if (!$ticket) {
473 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
474 }
475
476 if ($err = TicketAccessGuard::assert($ticket)) {
477 return $err;
478 }
479
480 $addTagIds = array_map('intval', (array) ($params['add_tag_ids'] ?? []));
481 $removeTagIds = array_map('intval', (array) ($params['remove_tag_ids'] ?? []));
482
483 $addTagNames = array_values(array_unique(array_filter(array_map('sanitize_text_field', (array) ($params['add_tag_names'] ?? [])), function ($name) {
484 return $name !== '';
485 })));
486
487 if (count($addTagNames) > self::MAX_TAG_NAMES) {
488 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]);
489 }
490
491 // Names only pick existing tags: the tag list is configuration that people
492 // manage in the admin. Any unknown name stops the call before anything changes.
493 $resolved = self::resolveTagNames($addTagNames);
494 if ($resolved['unknown']) {
495 return MCPHelper::error(
496 'unknown_tag',
497 /* translators: %s is a comma-separated list of tag names */
498 sprintf(__('No tag is called: %s', 'fluent-support'), implode(', ', $resolved['unknown'])),
499 [
500 'fields' => ['add_tag_names'],
501 'available_tags' => $resolved['available'],
502 'next_step' => 'Use one of these tags, or ask a person to create a new tag in the admin.',
503 ]
504 );
505 }
506 foreach ($resolved['ids'] as $tagId) {
507 $addTagIds[] = $tagId;
508 }
509
510 // Only real ticket tags: fs_taggables also holds agent groups and other types.
511 if ($addTagIds) {
512 $addTagIds = TicketTag::whereIn('id', array_unique($addTagIds))->pluck('id')->toArray();
513 }
514
515 if ($addTagIds) {
516 $ticket->applyTags($addTagIds);
517 }
518
519 if ($removeTagIds) {
520 $ticket->detachTags($removeTagIds);
521 }
522
523 $ticket->load('tags');
524
525 /* translators: %d is the ticket ID */
526 $summary = sprintf(__('Tags updated on ticket #%d', 'fluent-support'), $ticketId);
527
528 $data = ['tags' => $ticket->tags->map(function ($tag) {
529 return ['id' => $tag->id, 'title' => $tag->title];
530 })->toArray()];
531
532 return MCPHelper::envelope($summary, $data);
533 }
534
535 /**
536 * Existing tags for tag-ticket's add_tag_names, matched by title ignoring case
537 * and surrounding spaces. Nothing is created.
538 *
539 * @param string[] $names
540 * @return array{ids: int[], unknown: string[], available: string[]}
541 */
542 private static function resolveTagNames(array $names)
543 {
544 if (!$names) {
545 return ['ids' => [], 'unknown' => [], 'available' => []];
546 }
547
548 $tags = TicketTag::select(['id', 'title'])->orderBy('title', 'ASC')->get();
549 $byKey = [];
550 foreach ($tags as $tag) {
551 $key = mb_strtolower(trim((string) $tag->title));
552 if (!isset($byKey[$key])) {
553 $byKey[$key] = (int) $tag->id;
554 }
555 }
556
557 $ids = [];
558 $unknown = [];
559 foreach ($names as $name) {
560 $key = mb_strtolower(trim($name));
561 if (isset($byKey[$key])) {
562 $ids[] = $byKey[$key];
563 } else {
564 $unknown[] = $name;
565 }
566 }
567
568 return [
569 'ids' => array_values(array_unique($ids)),
570 'unknown' => $unknown,
571 'available' => $unknown ? array_slice($tags->pluck('title')->all(), 0, 200) : [],
572 ];
573 }
574
575 public static function getSupportInsights($params)
576 {
577 $agent = MCPHelper::resolveAgent();
578 if (!$agent) {
579 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
580 }
581
582 $periodMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
583 $period = sanitize_text_field($params['period'] ?? '7d');
584 if (!isset($periodMap[$period])) {
585 $period = '7d';
586 }
587
588 // Ticket dates are stored in site time (current_time('mysql')), so the
589 // cut-off must be site time too; a UTC cut-off shifts the whole window.
590 $nowTs = time();
591 $sinceTs = $nowTs - $periodMap[$period] * DAY_IN_SECONDS;
592 $since = wp_date('Y-m-d H:i:s', $sinceTs);
593
594 $scoped = function () {
595 $query = Ticket::query();
596 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
597 return (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
598 };
599
600 $periodTicketsQuery = $scoped()->where('created_at', '>=', $since);
601 $closedInPeriodQuery = $scoped()->where('resolved_at', '>=', $since);
602
603 // Response times — aggregates in SQL; bounded sample only for median.
604 $rtBase = $scoped()->where('first_response_time', '>', 0)->where('created_at', '>=', $since);
605 $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();
606 $rtSample = ($rtAgg && (int) $rtAgg->cnt > 0)
607 ? (clone $rtBase)->orderByDesc('id')->limit(500)->pluck('first_response_time')->toArray()
608 : [];
609
610 // Resolution times — same pattern.
611 $ctBase = (clone $closedInPeriodQuery)->where('total_close_time', '>', 0);
612 $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();
613 $ctSample = ($ctAgg && (int) $ctAgg->cnt > 0)
614 ? (clone $ctBase)->orderByDesc('id')->limit(500)->pluck('total_close_time')->toArray()
615 : [];
616
617 // Waiting, split by whose turn it is. waiting_on=agent tickets genuinely need
618 // a reply; waiting_on=customer tickets only reflect how long the customer
619 // has been idle since our last reply. A single blended average over both populations
620 // measures mostly customer silence and hides how many tickets actually need a reply.
621 $waitOnAgentBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->waitingOnly();
622 $waitOnCustomerBase = $scoped()->whereNotIn('status', ['closed'])->whereNotNull('waiting_since')->where(function ($q) {
623 $q->whereColumn('last_customer_response', '<', 'last_agent_response');
624 });
625 $waitingOnAgent = self::computeWaitStats($waitOnAgentBase);
626 $waitingOnCustomer = self::computeWaitStats($waitOnCustomerBase);
627
628 $allAgents = Agent::where('person_type', 'agent')
629 ->whereNotNull('first_name')
630 ->where('first_name', '!=', '')
631 ->select(['id', 'first_name', 'last_name'])
632 ->get()
633 ->keyBy('id');
634
635 $agentWorkload = $scoped()->whereNotIn('status', ['closed'])
636 ->whereNotNull('agent_id')
637 ->selectRaw('agent_id, COUNT(*) as ticket_count')
638 ->groupBy('agent_id')
639 ->get()
640 ->keyBy('agent_id');
641
642 $agentClosedInPeriod = $scoped()->where('resolved_at', '>=', $since)
643 ->whereNotNull('agent_id')
644 ->selectRaw('agent_id, COUNT(*) as closed_count')
645 ->groupBy('agent_id')
646 ->get()
647 ->keyBy('agent_id');
648
649 $agentResponseTimes = $scoped()->where('first_response_time', '>', 0)
650 ->where('created_at', '>=', $since)
651 ->whereNotNull('agent_id')
652 ->selectRaw('agent_id, AVG(first_response_time) as avg_response_time')
653 ->groupBy('agent_id')
654 ->get()
655 ->keyBy('agent_id');
656
657 $agentResolutionTimes = $scoped()->where('resolved_at', '>=', $since)
658 ->where('total_close_time', '>', 0)
659 ->whereNotNull('agent_id')
660 ->selectRaw('agent_id, AVG(total_close_time) as avg_close_time')
661 ->groupBy('agent_id')
662 ->get()
663 ->keyBy('agent_id');
664
665 $agentIds = $allAgents->keys()
666 ->merge($agentWorkload->keys())
667 ->merge($agentClosedInPeriod->keys())
668 ->unique();
669
670 $performance = [];
671 foreach ($agentIds as $agentId) {
672 $agentModel = $allAgents->get($agentId);
673 if (!$agentModel) {
674 continue;
675 }
676
677 $entry = [
678 'agent_id' => $agentId,
679 'agent_name' => MCPHelper::personName($agentModel),
680 'open_tickets' => (int) ($agentWorkload->get($agentId)->ticket_count ?? 0),
681 'closed_in_period' => (int) ($agentClosedInPeriod->get($agentId)->closed_count ?? 0),
682 ];
683
684 $avgResponse = $agentResponseTimes->get($agentId);
685 $entry['avg_first_response'] = $avgResponse
686 ? self::formatDuration((float) $avgResponse->avg_response_time)
687 : null;
688
689 $avgResolution = $agentResolutionTimes->get($agentId);
690 $entry['avg_resolution'] = $avgResolution
691 ? self::formatDuration((float) $avgResolution->avg_close_time)
692 : null;
693
694 $performance[] = $entry;
695 }
696
697 usort($performance, fn($a, $b) => $b['open_tickets'] - $a['open_tickets']);
698
699 if (!empty($params['agent_id'])) {
700 $onlyAgent = (int) $params['agent_id'];
701 $performance = array_values(array_filter($performance, fn($row) => (int) $row['agent_id'] === $onlyAgent));
702 }
703
704 $agentTotal = count($performance);
705 $truncatedAgents = $agentTotal > self::MAX_AGENTS;
706 if ($truncatedAgents) {
707 $performance = array_slice($performance, 0, self::MAX_AGENTS);
708 }
709
710 $created = (clone $periodTicketsQuery)->count();
711 $closed = (clone $closedInPeriodQuery)->count();
712
713 return MCPHelper::envelope(
714 "Support insights for {$period}: {$created} created, {$closed} closed",
715 [
716 'period' => $period,
717 'from' => wp_date('c', $sinceTs),
718 'to' => wp_date('c', $nowTs),
719 'volume' => ['created' => $created, 'closed' => $closed],
720 'first_response_time' => self::computeTimeStatsFromAgg($rtAgg, $rtSample),
721 'resolution_time' => self::computeTimeStatsFromAgg($ctAgg, $ctSample),
722 'waiting' => [
723 'waiting_on_agent' => $waitingOnAgent,
724 'waiting_on_customer' => $waitingOnCustomer,
725 ],
726 'agent_performance' => $performance,
727 'agent_performance_total' => $agentTotal,
728 'agent_performance_truncated' => $truncatedAgents,
729 ]
730 );
731 }
732
733 private static function computeTimeStatsFromAgg($agg, array $sample)
734 {
735 if (!$agg || (int) $agg->cnt === 0) {
736 return ['count' => 0, 'average' => null, 'median' => null, 'max' => null, 'min' => null];
737 }
738
739 return [
740 'count' => (int) $agg->cnt,
741 'average' => self::formatDuration((float) $agg->avg_val),
742 'median' => self::formatDuration(self::median($sample)),
743 'max' => self::formatDuration((float) $agg->max_val),
744 'min' => self::formatDuration((float) $agg->min_val),
745 ];
746 }
747
748 /**
749 * Aggregate + bounded-sample-for-median pattern for a waiting_since-based population,
750 * mirroring computeTimeStatsFromAgg() but for the TIMESTAMPDIFF-against-now duration used
751 * by the two waiting populations (waiting_on_agent / waiting_on_customer) instead of a
752 * stored duration column.
753 */
754 private static function computeWaitStats($baseQuery)
755 {
756 // waiting_since is stored in site time, so measure against site time, not UTC.
757 $now = current_time('mysql');
758 $agg = (clone $baseQuery)
759 ->selectRaw('COUNT(*) as cnt, AVG(TIMESTAMPDIFF(SECOND, waiting_since, ?)) as avg_val, MAX(TIMESTAMPDIFF(SECOND, waiting_since, ?)) as max_val', [$now, $now])
760 ->first();
761 $sample = ($agg && (int) $agg->cnt > 0)
762 ? (clone $baseQuery)->selectRaw('TIMESTAMPDIFF(SECOND, waiting_since, ?) as wait_sec', [$now])->orderByDesc('id')->limit(500)->pluck('wait_sec')->toArray()
763 : [];
764
765 return [
766 'count' => $agg ? (int) $agg->cnt : 0,
767 'average' => self::formatDuration($agg ? (float) $agg->avg_val : 0),
768 'max' => self::formatDuration($agg ? (float) $agg->max_val : 0),
769 'median' => self::formatDuration(self::median($sample)),
770 ];
771 }
772
773 private static function median(array $values)
774 {
775 if (empty($values)) {
776 return 0;
777 }
778 sort($values);
779 $count = count($values);
780 $mid = (int) floor($count / 2);
781 return ($count % 2 === 0)
782 ? ($values[$mid - 1] + $values[$mid]) / 2
783 : $values[$mid];
784 }
785
786 private static function formatDuration($seconds)
787 {
788 return MCPHelper::formatDuration($seconds);
789 }
790
791 public static function bulkAction($params)
792 {
793 $agent = MCPHelper::resolveAgent();
794 if (!$agent) {
795 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
796 }
797
798 $ticketIds = array_map('intval', (array) ($params['ticket_ids'] ?? []));
799 $action = sanitize_text_field($params['action'] ?? '');
800
801 if (empty($ticketIds) || !$action) {
802 return MCPHelper::error('invalid_param', __('ticket_ids and action are required', 'fluent-support'), ['fields' => ['ticket_ids', 'action']]);
803 }
804
805 if (count($ticketIds) > self::MAX_BULK) {
806 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]);
807 }
808
809 $allowedActions = ['close', 'assign', 'tag', 'reply'];
810 if (!in_array($action, $allowedActions)) {
811 return MCPHelper::error('invalid_param', sprintf(__('action must be one of: %s', 'fluent-support'), implode(', ', $allowedActions)), ['fields' => ['action']]);
812 }
813
814 $assignTarget = null;
815 if ($action === 'assign') {
816 // Null ticket: each ticket's mailbox restriction is checked per-item
817 // in the loop below, since the batch can span multiple mailboxes.
818 $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'] ?? 0, null, 'agent_id');
819 if (is_wp_error($assignTarget)) {
820 return $assignTarget;
821 }
822 }
823
824 $bulkTagIds = [];
825 if ($action === 'tag') {
826 $tagIds = array_map('intval', (array) ($params['tag_ids'] ?? []));
827 if (empty($tagIds)) {
828 return MCPHelper::error('invalid_param', __('tag_ids is required for the tag action', 'fluent-support'), ['fields' => ['tag_ids']]);
829 }
830 // Only real ticket tags: fs_taggables also holds agent groups and other types.
831 $bulkTagIds = TicketTag::whereIn('id', $tagIds)->pluck('id')->toArray();
832 if (!$bulkTagIds) {
833 return MCPHelper::error('invalid_param', __('None of the tag_ids is a ticket tag', 'fluent-support'), ['fields' => ['tag_ids'], 'next_step' => 'Use get-support-context to see available tags and their IDs']);
834 }
835 }
836
837 if ($action === 'reply' && empty($params['content']) && empty($params['saved_reply_id'])) {
838 return MCPHelper::error('invalid_param', __('content or saved_reply_id is required for the reply action', 'fluent-support'), ['fields' => ['content', 'saved_reply_id']]);
839 }
840
841 $tickets = Ticket::whereIn('id', $ticketIds)->get();
842 $ticketService = new TicketService();
843 $results = [];
844 $foundIds = $tickets->pluck('id')->toArray();
845
846 foreach ($ticketIds as $tid) {
847 if (!in_array($tid, $foundIds)) {
848 $results[] = ['id' => $tid, 'status' => 'not_found'];
849 }
850 }
851
852 foreach ($tickets as $ticket) {
853 if (TicketAccessGuard::assert($ticket)) {
854 $results[] = ['id' => $ticket->id, 'status' => 'forbidden'];
855 continue;
856 }
857
858 // Isolate each ticket: a failure on one (e.g. inside close(),
859 // onAgentChange(), or applyTags()) must not abort the whole batch.
860 try {
861 switch ($action) {
862 case 'close':
863 if ($ticket->status === 'closed') {
864 $results[] = ['id' => $ticket->id, 'status' => 'already_closed'];
865 } else {
866 // close() takes 'yes'/'no'; only 'no' sends the closing email.
867 $ticketService->close($ticket, $agent, '', !empty($params['silent']) ? 'yes' : 'no');
868 $results[] = ['id' => $ticket->id, 'status' => 'closed'];
869 }
870 break;
871
872 case 'assign':
873 if (TicketAccessGuard::assertAssignableAgent($ticket, $assignTarget)) {
874 $results[] = ['id' => $ticket->id, 'status' => 'mailbox_restricted'];
875 break;
876 }
877 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
878 $results[] = ['id' => $ticket->id, 'status' => 'assigned'];
879 break;
880
881 case 'tag':
882 $ticket->applyTags($bulkTagIds);
883 $results[] = ['id' => $ticket->id, 'status' => 'tagged'];
884 break;
885
886 case 'reply':
887 $results[] = self::bulkReply($ticket, $params);
888 break;
889 }
890 } catch (\Exception $e) {
891 $results[] = ['id' => $ticket->id, 'status' => 'error', 'message' => $e->getMessage()];
892 }
893 }
894
895 $processed = count(array_filter($results, function ($r) {
896 return in_array($r['status'], ['closed', 'already_closed', 'assigned', 'tagged', 'replied', 'drafted', 'duplicate'], true);
897 }));
898
899 $total = count($ticketIds);
900 $verbMap = ['close' => 'closed', 'assign' => 'assigned', 'tag' => 'tagged', 'reply' => 'replied to'];
901 $verb = $verbMap[$action] ?? $action;
902
903 return MCPHelper::envelope(
904 "{$processed} of {$total} ticket(s) {$verb}",
905 ['results' => $results],
906 ['processed' => $processed, 'total' => $total]
907 );
908 }
909
910 /**
911 * One ticket of a bulk reply, through the same path as reply-to-ticket
912 * (access, reopen, auto-assign, drafts, duplicate guard).
913 */
914 private static function bulkReply(Ticket $ticket, $params)
915 {
916 $replyParams = array_intersect_key($params, array_flip(['content', 'content_format', 'saved_reply_id', 'close_ticket', 'draft']));
917 $replyParams['ticket_id'] = $ticket->id;
918
919 $result = ResponseTools::replyToTicket($replyParams);
920
921 if (is_wp_error($result)) {
922 $error = json_decode($result->get_error_message(), true);
923 return [
924 'id' => $ticket->id,
925 'status' => $error['error']['code'] ?? 'error',
926 'message' => $error['error']['message'] ?? $result->get_error_message(),
927 ];
928 }
929
930 $data = $result['data'];
931 if (!empty($data['duplicate'])) {
932 $status = 'duplicate';
933 } elseif (!empty($data['draft'])) {
934 $status = 'drafted';
935 } else {
936 $status = 'replied';
937 }
938
939 return array_filter([
940 'id' => $ticket->id,
941 'status' => $status,
942 'response_id' => $data['response']['id'] ?? null,
943 'ticket_status' => $data['ticket_status'] ?? null,
944 'reopened' => !empty($data['reopened']) ?: null,
945 ], function ($value) {
946 return $value !== null;
947 });
948 }
949
950 public static function getMentions($params)
951 {
952 $agent = MCPHelper::resolveAgent();
953 if (!$agent) {
954 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
955 }
956
957 $notificationSettings = new \FluentSupport\App\Services\Notifications\NotificationSettings();
958 if (!$notificationSettings->canUseNotificationTables(true)) {
959 return MCPHelper::error('not_available', __('Internal notifications are not enabled. Enable them in Fluent Support Settings > Notifications.', 'fluent-support'), [
960 'next_step' => 'Enable internal notifications in Fluent Support admin settings',
961 ]);
962 }
963
964 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params, 15);
965
966 $status = sanitize_text_field($params['status'] ?? 'all');
967 if (!in_array($status, ['all', 'unread', 'read'], true)) {
968 $status = 'all';
969 }
970
971 $filters = [
972 'category' => \FluentSupport\App\Services\Notifications\NotificationCategory::MENTIONS,
973 ];
974
975 if ($status !== 'all') {
976 $filters['status'] = $status;
977 }
978
979 if (!empty($params['ticket_id'])) {
980 $filters['ticket_id'] = (int) $params['ticket_id'];
981 }
982
983 $queryService = new \FluentSupport\App\Services\Notifications\NotificationQueryService();
984 $query = $queryService->getNotificationsForPerson($agent->id, $filters);
985
986 if (!$query) {
987 return MCPHelper::error('not_available', __('Internal notifications are not enabled.', 'fluent-support'));
988 }
989
990 $paginated = $query->paginate($perPage, ['*'], 'page', $page);
991
992 $unreadCount = (new \FluentSupport\App\Services\Notifications\NotificationQueryService())
993 ->getUnreadCount($agent->id, ['category' => \FluentSupport\App\Services\Notifications\NotificationCategory::MENTIONS]);
994
995 $mentions = [];
996 foreach ($paginated->items() as $notification) {
997 $payload = $notification->getPayloadAttribute($notification->getRawOriginal('payload') ?? null);
998 if (!is_array($payload)) {
999 $payload = [];
1000 }
1001
1002 $readStatus = null;
1003 if ($notification->relationLoaded('recipients') && $notification->recipients->isNotEmpty()) {
1004 $recipient = $notification->recipients->first();
1005 $readStatus = (bool) $recipient->is_read;
1006 }
1007
1008 $actor = $notification->relationLoaded('actor') ? $notification->actor : null;
1009 $ticket = $notification->relationLoaded('ticket') ? $notification->ticket : null;
1010 $actorName = $actor ? MCPHelper::personName($actor) : (__('Someone', 'fluent-support'));
1011 $ticketTitle = $ticket ? $ticket->title : ($payload['ticket_title'] ?? null);
1012
1013 $summary = $ticketTitle
1014 ? sprintf(__('%1$s mentioned you in "%2$s"', 'fluent-support'), $actorName, $ticketTitle)
1015 : sprintf(__('%1$s mentioned you', 'fluent-support'), $actorName);
1016
1017 $mention = [
1018 'id' => $notification->id,
1019 'summary' => $summary,
1020 'is_read' => $readStatus,
1021 'ticket_id' => $notification->ticket_id,
1022 'ticket_title' => $ticketTitle,
1023 'conversation_id' => $notification->conversation_id,
1024 'mentioned_by' => $actor ? MCPHelper::formatPersonSummary($actor) : null,
1025 'content_preview' => isset($payload['content_preview']) ? sanitize_text_field($payload['content_preview']) : null,
1026 'created_at' => MCPHelper::toIso8601($notification->created_at),
1027 ];
1028
1029 $mentions[] = $mention;
1030 }
1031
1032 $total = $paginated->total();
1033
1034 return MCPHelper::envelope(
1035 sprintf(
1036 _n('Found %d mention', 'Found %d mentions', $total, 'fluent-support'),
1037 $total
1038 ) . ($unreadCount ? " ({$unreadCount} unread)" : ''),
1039 ['mentions' => $mentions, 'unread_count' => $unreadCount],
1040 MCPHelper::pagingMeta($paginated)
1041 );
1042 }
1043
1044 }
1045