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 / TicketTools.php

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

908 lines 38.8 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\Activity;
6 use FluentSupport\App\Models\Customer;
7 use FluentSupport\App\Models\Ticket;
8 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
9 use FluentSupport\App\Modules\MCP\Support\CustomerMetaEnricher;
10 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
11 use FluentSupport\App\Modules\PermissionManager;
12 use FluentSupport\App\Services\Helper;
13 use FluentSupport\App\Services\ProfileInfoService;
14 use FluentSupport\App\Services\Tickets\ResponseService;
15 use FluentSupport\App\Services\Tickets\TicketService;
16
17 class TicketTools
18 {
19 const MAX_RESPONSES = 50;
20
21 public static function listTickets($params)
22 {
23 $agent = MCPHelper::resolveAgent();
24 if (!$agent) {
25 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
26 }
27
28 $query = Ticket::with([
29 'customer' => function ($q) {
30 $q->select(['id', 'first_name', 'last_name', 'email']);
31 },
32 'agent' => function ($q) {
33 $q->select(['id', 'first_name', 'last_name', 'email']);
34 },
35 'product',
36 'tags',
37 ]);
38
39 $filterError = self::applyFilters($query, $params);
40 if (is_wp_error($filterError)) {
41 return $filterError;
42 }
43
44 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
45
46 $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
47 if ($restrictedMailboxes) {
48 $query->whereNotIn('mailbox_id', $restrictedMailboxes);
49 }
50
51 // Allowed sort columns that are supported by keyset pagination. Low-cardinality
52 // columns (status, priority, client_priority) rely on their plain single-column
53 // index — id is always the tiebreaker, so a composite (col,id) index is only
54 // worth the write overhead for the high-churn triage columns below.
55 // For fresh installs: defined in CREATE TABLE statement (TicketsMigrator::migrate)
56 // For upgrades: added by addMissingIndexes() (TicketsMigrator::alterTable)
57 // Composite (col,id) indexes: (waiting_since,id), (updated_at,id), (response_count,id)
58 $allowedSortColumns = ['id', 'created_at', 'updated_at', 'waiting_since', 'status', 'priority', 'client_priority', 'response_count'];
59 $sortBy = sanitize_text_field($params['sort_by'] ?? 'id');
60 if (!in_array($sortBy, $allowedSortColumns, true)) {
61 $sortBy = 'id';
62 }
63 $sortType = Helper::sanitizeOrderValue($params['sort_type'] ?? $params['order'] ?? 'DESC');
64
65 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
66
67 // Tiebreaker matches the primary sort direction (not a fixed DESC) so
68 // rows with equal $sortBy values are still totally ordered — required
69 // for the keyset cursor below to be a valid resume point, not just for
70 // display determinism.
71 $query->orderBy($sortBy, $sortType)->orderBy('id', $sortType);
72
73 $cursor = sanitize_text_field($params['cursor'] ?? '');
74 if ($cursor !== '') {
75 $decoded = self::decodeListCursor($cursor);
76 if (!$decoded || $decoded['sort_by'] !== $sortBy || $decoded['sort_type'] !== $sortType) {
77 return MCPHelper::error('invalid_param', __('Invalid or expired cursor, or it was generated with a different sort_by/sort_type than this request', 'fluent-support'), ['fields' => ['cursor'], 'next_step' => 'Use the same sort_by/sort_type as the call that produced this cursor, or omit cursor to start a fresh sweep']);
78 }
79
80 self::applyCursorWhere($query, $sortBy, $sortType, $decoded);
81 }
82
83 // Keyset mode: fetch one extra row to detect has_more without a
84 // separate COUNT — a mutating waiting_since makes "total" only ever
85 // an approximation mid-sweep anyway, so it's computed once (not
86 // re-derived from the keyset window) purely for a consistent
87 // response shape with page-mode, matching what pagingMeta() returns.
88 if ($cursor !== '') {
89 $rows = $query->limit($perPage + 1)->get();
90 $hasMore = $rows->count() > $perPage;
91 $rows = $rows->slice(0, $perPage)->values();
92 $nextCursor = $hasMore && $rows->isNotEmpty()
93 ? self::encodeListCursor($rows->last(), $sortBy, $sortType)
94 : null;
95
96 $summary = sprintf(_n('Found %d ticket', 'Found %d tickets', $rows->count(), 'fluent-support'), $rows->count());
97
98 $customerMeta = CustomerMetaEnricher::resolve($rows, [
99 'surface' => 'ticket_list',
100 'agent_id' => $agent->id,
101 ]);
102
103 return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($rows, $customerMeta)], [
104 'paging' => [
105 'per_page' => $perPage,
106 'has_more' => $hasMore,
107 'next_cursor' => $nextCursor,
108 ],
109 ]);
110 }
111
112 $paginated = $query->paginate($perPage, ['*'], 'page', $page);
113
114 $total = $paginated->total();
115 $summary = sprintf(_n('Found %d ticket', 'Found %d tickets', $total, 'fluent-support'), $total)
116 . sprintf(__(' — page %d of %d', 'fluent-support'), $paginated->currentPage(), $paginated->lastPage());
117
118 $items = $paginated->items();
119
120 // Optional integration-provided per-customer meta line (one batch call, gated by fst_sensitive_data).
121 $customerMeta = CustomerMetaEnricher::resolve($items, [
122 'surface' => 'ticket_list',
123 'agent_id' => $agent->id,
124 ]);
125
126 $meta = MCPHelper::pagingMeta($paginated);
127 // Offer a stable cursor from page 1's last row so a sweep can switch to
128 // keyset pagination from here on instead of continuing with page/offset.
129 $meta['paging']['next_cursor'] = !empty($items)
130 ? self::encodeListCursor(end($items), $sortBy, $sortType)
131 : null;
132
133 return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($items, $customerMeta)], $meta);
134 }
135
136 /**
137 * Builds the keyset WHERE for "rows strictly after $decoded". NULL-aware:
138 * MySQL sorts NULLs first in ASC and last in DESC by default, so a naive
139 * `$sortBy > $v` / `< $v` would silently exclude NULL rows forever once
140 * the cursor passes into non-null values (NULL > x and NULL < x are both
141 * SQL UNKNOWN, never true). Each branch below matches that default
142 * ordering so NULL rows are neither skipped nor duplicated.
143 */
144 private static function applyCursorWhere($query, $sortBy, $sortType, $decoded)
145 {
146 $id = $decoded['id'];
147 $v = $decoded['v'];
148
149 if ($sortType === 'ASC') {
150 if ($v === null) {
151 // Still among the leading NULL rows, or past them entirely.
152 $query->where(function ($q) use ($sortBy, $id) {
153 $q->where(function ($q2) use ($sortBy, $id) {
154 $q2->whereNull($sortBy)->where('id', '>', $id);
155 })->orWhereNotNull($sortBy);
156 });
157 } else {
158 // Past the NULL rows (they all sort first in ASC) — plain tuple compare.
159 $query->where(function ($q) use ($sortBy, $v, $id) {
160 $q->where($sortBy, '>', $v)
161 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
162 $q2->where($sortBy, '=', $v)->where('id', '>', $id);
163 });
164 });
165 }
166 } else {
167 if ($v === null) {
168 // Already among the trailing NULL rows (they sort last in DESC).
169 $query->whereNull($sortBy)->where('id', '<', $id);
170 } else {
171 // Not yet into the NULL rows — include them once past all non-null values.
172 $query->where(function ($q) use ($sortBy, $v, $id) {
173 $q->where($sortBy, '<', $v)
174 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
175 $q2->where($sortBy, '=', $v)->where('id', '<', $id);
176 })
177 ->orWhereNull($sortBy);
178 });
179 }
180 }
181 }
182
183 /**
184 * Opaque continuation token for keyset pagination: the last row's id, its
185 * value for the current sort column, and the sort_by/sort_type it was
186 * generated with. The sort binding matters — without it, reusing a
187 * cursor after changing sort_by/sort_type would compare the old value
188 * against an unrelated column with no error. Not encrypted/signed — it
189 * only encodes values the requester already saw in the prior response.
190 */
191 private static function encodeListCursor($lastRow, $sortBy, $sortType)
192 {
193 // getRawOriginal(), not the cast attribute — date columns like
194 // waiting_since cast to a DateTime-like object, which would serialize
195 // as a nested {date, timezone_type, timezone} blob and break the
196 // plain scalar WHERE comparison in applyCursorWhere().
197 $value = $lastRow->getRawOriginal($sortBy);
198 return base64_encode(wp_json_encode([
199 'id' => (int) $lastRow->id,
200 'v' => $value,
201 'sort_by' => $sortBy,
202 'sort_type' => $sortType,
203 ]));
204 }
205
206 private static function decodeListCursor($cursor)
207 {
208 $decoded = json_decode(base64_decode($cursor, true), true);
209 if (!is_array($decoded) || !isset($decoded['id'], $decoded['sort_by'], $decoded['sort_type']) || !array_key_exists('v', $decoded)) {
210 return null;
211 }
212 return $decoded;
213 }
214
215 private static function applyFilters($query, $params)
216 {
217 $filters = [];
218
219 if (!empty($params['status'])) {
220 $filters['status_type'] = $params['status'];
221 } else if (!empty($params['waiting_for_reply'])) {
222 // A closed ticket can't be "waiting for an agent response" -
223 // default to open tickets unless the caller explicitly asked for another status.
224 $filters['status_type'] = 'open';
225 }
226 if (!empty($params['agent_id'])) {
227 $filters['agent_id'] = (int) $params['agent_id'];
228 }
229 if (!empty($params['product_id'])) {
230 $filters['product_id'] = (int) $params['product_id'];
231 }
232 if (!empty($params['mailbox_id'])) {
233 $filters['mailbox_id'] = (int) $params['mailbox_id'];
234 }
235 if (!empty($params['priority'])) {
236 $filters['priority'] = MCPHelper::normalizePriority($params['priority']);
237 }
238 if (!empty($params['client_priority'])) {
239 $filters['client_priority'] = MCPHelper::normalizePriority($params['client_priority']);
240 }
241 if (!empty($params['tags'])) {
242 $filters['ticket_tags'] = array_map('intval', (array) $params['tags']);
243 }
244 if (!empty($params['waiting_for_reply'])) {
245 $filters['waiting_for_reply'] = 'yes';
246 }
247
248 if (!empty($params['customer_id'])) {
249 $query->where('customer_id', (int) $params['customer_id']);
250 }
251
252 if (!empty($params['unassigned'])) {
253 $query->whereNull('agent_id');
254 }
255
256 if (!empty($params['needs_first_response'])) {
257 $query->where('response_count', 0)->whereNotIn('status', ['closed']);
258 }
259
260 $query->applyFilters($filters);
261
262 foreach (['created_after' => '>=', 'created_before' => '<='] as $field => $op) {
263 if (empty($params[$field])) {
264 continue;
265 }
266 try {
267 $d = new \DateTime(sanitize_text_field($params[$field]));
268 $query->where('created_at', $op, $d->format('Y-m-d H:i:s'));
269 } catch (\Exception $e) {
270 return MCPHelper::error('invalid_param', sprintf(__("Invalid date format for '%s'", 'fluent-support'), $field), ['fields' => [$field], 'hint' => 'Use YYYY-MM-DD or ISO 8601']);
271 }
272 }
273
274 if (!empty($params['search'])) {
275 $search = sanitize_text_field($params['search']);
276 $query->where(function ($q) use ($search) {
277 $q->where('title', 'LIKE', "%{$search}%")
278 ->orWhere('content', 'LIKE', "%{$search}%")
279 ->orWhere('id', '=', is_numeric($search) ? (int) $search : 0)
280 ->orWhereHas('customer', function ($cq) use ($search) {
281 $cq->where('email', 'LIKE', "%{$search}%")
282 ->orWhere('first_name', 'LIKE', "%{$search}%")
283 ->orWhere('last_name', 'LIKE', "%{$search}%");
284 })
285 ->orWhereHas('responses', function ($rq) use ($search) {
286 $rq->where('content', 'LIKE', "%{$search}%");
287 });
288 });
289 }
290 }
291
292 public static function getTicket($params)
293 {
294 $agent = MCPHelper::resolveAgent();
295 if (!$agent) {
296 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
297 }
298
299 $ticketId = (int) ($params['ticket_id'] ?? 0);
300 if (!$ticketId) {
301 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
302 }
303
304 $ticket = Ticket::with(['customer', 'agent', 'product', 'mailbox', 'tags'])->find($ticketId);
305
306 if (!$ticket) {
307 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
308 }
309
310 if ($err = TicketAccessGuard::assert($ticket)) {
311 return $err;
312 }
313
314 $data = MCPHelper::formatTicketForMCP($ticket);
315
316 if ($ticket->customer) {
317 $customer = $ticket->customer;
318 $customerId = $customer->id;
319 $data['customer']['total_tickets'] = Ticket::where('customer_id', $customerId)->count();
320 $data['customer']['open_tickets'] = Ticket::where('customer_id', $customerId)
321 ->whereNotIn('status', ['closed'])->count();
322 $data['customer']['first_seen'] = MCPHelper::toIso8601($customer->created_at);
323
324 $prevTickets = Ticket::where('customer_id', $customerId)
325 ->where('id', '!=', $ticket->id)
326 ->select(['id', 'title', 'status', 'priority', 'created_at'])
327 ->orderBy('id', 'DESC')
328 ->limit(10)
329 ->get();
330
331 if ($prevTickets->count()) {
332 $data['previous_tickets'] = $prevTickets->map(function ($t) {
333 return [
334 'id' => $t->id,
335 'title' => $t->title,
336 'status' => $t->status ?: 'new',
337 'priority' => MCPHelper::normalizePriority($t->priority),
338 'created_at' => MCPHelper::toIso8601($t->created_at),
339 ];
340 })->toArray();
341 }
342
343 $withIntegrations = ($params['with_integrations'] ?? true) !== false;
344 if ($withIntegrations) {
345 $extraWidgets = ProfileInfoService::getProfileExtraWidgets($customer);
346 if ($extraWidgets) {
347 $data['integrations'] = MCPHelper::formatExtraWidgets($extraWidgets);
348 }
349 }
350
351 $crmData = Helper::getFluentCrmContactData($customer);
352 if ($crmData) {
353 $data['crm'] = [
354 'name' => $crmData['full_name'] ?? '',
355 'status' => $crmData['status'] ?? '',
356 'tags' => !empty($crmData['tags']) ? $crmData['tags']->pluck('title')->toArray() : [],
357 'lists' => !empty($crmData['lists']) ? $crmData['lists']->pluck('title')->toArray() : [],
358 ];
359 }
360 }
361
362 $customFields = $ticket->customData('admin', true);
363 if ($customFields) {
364 $data['custom_fields'] = $customFields;
365 }
366
367 $withResponses = ($params['with_responses'] ?? true) !== false;
368 if ($withResponses) {
369 $responsePage = max(1, (int) ($params['response_page'] ?? 1));
370 $offset = ($responsePage - 1) * self::MAX_RESPONSES;
371
372 // Fetch one extra to detect if an older page exists (no separate COUNT).
373 $rows = \FluentSupport\App\Models\Conversation::where('ticket_id', $ticket->id)
374 ->with('person')
375 ->orderBy('id', 'desc')
376 ->offset($offset)
377 ->limit(self::MAX_RESPONSES + 1)
378 ->get();
379 $hasMore = $rows->count() > self::MAX_RESPONSES;
380 if ($hasMore) {
381 $rows = $rows->slice(0, self::MAX_RESPONSES);
382 }
383
384 $data['responses'] = MCPHelper::formatResponseThread($rows->reverse()->values());
385 $data['responses_meta'] = [
386 'page' => $responsePage,
387 'per_page' => self::MAX_RESPONSES,
388 'has_more' => $hasMore,
389 ];
390 }
391
392 $summary = "Ticket #{$ticket->id}: {$ticket->title} [{$data['status']}, {$data['priority']}]";
393
394 return MCPHelper::envelope($summary, $data);
395 }
396
397 public static function createTicket($params)
398 {
399 $agent = MCPHelper::resolveAgent();
400 if (!$agent) {
401 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
402 }
403
404 $title = sanitize_text_field($params['title'] ?? '');
405 $format = $params['content_format'] ?? 'markdown';
406 $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format));
407
408 if (!$title || !$content) {
409 return MCPHelper::error('invalid_param', __('title and content are required', 'fluent-support'), ['fields' => ['title', 'content']]);
410 }
411
412 $email = sanitize_email($params['customer_email'] ?? '');
413 if (!$email || !is_email($email)) {
414 return MCPHelper::error('invalid_param', __('A valid customer_email is required', 'fluent-support'), ['fields' => ['customer_email']]);
415 }
416
417 $customer = Customer::where('email', $email)->first();
418
419 if (!$customer) {
420 $customerData = [
421 'email' => $email,
422 'first_name' => sanitize_text_field($params['customer_first_name'] ?? ''),
423 'last_name' => sanitize_text_field($params['customer_last_name'] ?? ''),
424 ];
425
426 $wpUser = get_user_by('email', $email);
427 if ($wpUser) {
428 $customerData['user_id'] = $wpUser->ID;
429 if (!$customerData['first_name']) {
430 $customerData['first_name'] = $wpUser->first_name ?: $wpUser->display_name;
431 }
432 if (!$customerData['last_name']) {
433 $customerData['last_name'] = $wpUser->last_name;
434 }
435 }
436
437 $customerData = array_filter($customerData);
438 $customer = Customer::create($customerData);
439 do_action('fluent_support/customer_created', $customer);
440 }
441
442 $ticketData = [
443 'title' => $title,
444 'content' => $content,
445 'customer_id' => $customer->id,
446 'source' => 'mcp',
447 'status' => 'new',
448 'priority' => 'normal',
449 // Logged on the customer's behalf ("created by agent"), NOT agent-initiated:
450 // storeTicket() sets created_by to the acting agent and fires the
451 // ticket_created_by_agent_email_to_customer notification. We deliberately do
452 // not set 'agent_initiated' => 'yes' — that flow suppresses the created-by-agent
453 // email and treats the content as the agent's opening reply, which is wrong for
454 // a ticket logged via MCP on the customer's behalf.
455 ];
456
457 if (!empty($params['priority'])) {
458 $ticketData['priority'] = MCPHelper::normalizePriority($params['priority']);
459 }
460
461 if (!empty($params['product_id'])) {
462 $productId = (int) $params['product_id'];
463 if (!\FluentSupport\App\Models\Product::find($productId)) {
464 return MCPHelper::error('invalid_param', __('The specified product does not exist', 'fluent-support'), ['fields' => ['product_id'], 'next_step' => 'Use get-support-context to see available products and their IDs']);
465 }
466 $ticketData['product_id'] = $productId;
467 }
468
469 if (!empty($params['mailbox_id'])) {
470 $mailboxId = (int) $params['mailbox_id'];
471 if ($err = TicketAccessGuard::assertMailboxWritable($mailboxId)) {
472 return $err;
473 }
474 if (!\FluentSupport\App\Models\MailBox::find($mailboxId)) {
475 return MCPHelper::error('invalid_param', __('The specified mailbox does not exist', 'fluent-support'), ['fields' => ['mailbox_id'], 'next_step' => 'Use get-support-context to see available mailboxes and their IDs']);
476 }
477 $ticketData['mailbox_id'] = $mailboxId;
478 }
479
480 if (!empty($params['agent_id'])) {
481 // Probe ticket carries the effective target mailbox so
482 // resolveAssignmentTarget can enforce the assignee's mailbox
483 // restriction at creation too. When mailbox_id is omitted,
484 // storeTicket() falls back to the default mailbox — mirror that
485 // here so the restriction is checked against the mailbox the
486 // ticket will actually be persisted with.
487 $effectiveMailboxId = (int) ($ticketData['mailbox_id'] ?? 0);
488 if (!$effectiveMailboxId) {
489 $defaultMailbox = Helper::getDefaultMailBox();
490 $effectiveMailboxId = $defaultMailbox ? (int) $defaultMailbox->id : 0;
491 }
492
493 $probeTicket = null;
494 if ($effectiveMailboxId) {
495 $probeTicket = new Ticket();
496 $probeTicket->mailbox_id = $effectiveMailboxId;
497 }
498
499 $agentRecord = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id');
500 if (is_wp_error($agentRecord)) {
501 return $agentRecord;
502 }
503 $ticketData['agent_id'] = $agentRecord->id;
504 }
505
506 // Opt-in: caller can turn this into an agent-initiated ticket (agent proactively
507 // reaching out — billing follow-up, onboarding, etc.). When enabled, storeTicket()
508 // suppresses the created-by-agent email and posts `content` as the agent's opening
509 // reply to the customer. Default (off) logs the ticket on the customer's behalf.
510 if (filter_var($params['agent_initiated'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
511 $ticketData['agent_initiated'] = 'yes';
512 }
513
514 $ticket = (new TicketService())->storeTicket($ticketData, $customer);
515 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
516
517 $data = ['ticket' => MCPHelper::formatTicketForMCP($ticket)];
518
519 if (
520 !$ticket->agent_id &&
521 !PermissionManager::currentUserCan('fst_manage_unassigned_tickets') &&
522 !PermissionManager::currentUserCan('fst_manage_other_tickets')
523 ) {
524 $data['warning'] = __('Ticket created unassigned. Your permission level does not include access to unassigned tickets, so follow-up actions (reply, close, update) will be denied until the ticket is assigned to you by a manager.', 'fluent-support');
525 }
526
527 return MCPHelper::envelope(
528 "Ticket #{$ticket->id} created: {$ticket->title}",
529 $data
530 );
531 }
532
533 public static function closeTicket($params)
534 {
535 $agent = MCPHelper::resolveAgent();
536 if (!$agent) {
537 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
538 }
539
540 $ticketId = (int) ($params['ticket_id'] ?? 0);
541 if (!$ticketId) {
542 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
543 }
544
545 $ticket = Ticket::find($ticketId);
546 if (!$ticket) {
547 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
548 }
549
550 if ($err = TicketAccessGuard::assert($ticket)) {
551 return $err;
552 }
553
554 if ($ticket->status === 'closed') {
555 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
556 return MCPHelper::envelope(
557 "Ticket #{$ticket->id} is already closed",
558 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
559 );
560 }
561
562 $format = $params['content_format'] ?? 'markdown';
563 $replyContent = wp_kses_post(MCPHelper::processContent($params['reply_content'] ?? '', $format));
564 $internalNote = wp_kses_post(MCPHelper::processContent($params['internal_note'] ?? '', $format));
565
566 // TicketService::close() takes the admin UI's 'yes'/'no' string, not a
567 // bool - and it only treats the exact string 'no' as "notify", so an
568 // empty/absent value would close silently. Always send one or the other.
569 $silently = !empty($params['silent']) ? 'yes' : 'no';
570
571 // Reply + close must be atomic. Without a transaction, a failure in
572 // close() after the reply was created would leave the reply visible on
573 // a still-open ticket.
574 (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket, $silently) {
575 if ($replyContent) {
576 $data = [
577 'content' => $replyContent,
578 'conversation_type' => 'response',
579 'source' => 'mcp',
580 ];
581 (new ResponseService())->createResponse($data, $agent, $ticket);
582 }
583
584 (new TicketService())->close($ticket, $agent, $internalNote, $silently);
585 });
586
587 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
588
589 $summary = $replyContent
590 ? "Reply sent and ticket #{$ticket->id} closed"
591 : "Ticket #{$ticket->id} closed";
592
593 if ($silently === 'yes') {
594 $summary .= ' silently (no close notification or automations fired)';
595 }
596
597 return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]);
598 }
599
600 public static function reopenTicket($params)
601 {
602 $agent = MCPHelper::resolveAgent();
603 if (!$agent) {
604 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
605 }
606
607 $ticketId = (int) ($params['ticket_id'] ?? 0);
608 if (!$ticketId) {
609 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
610 }
611
612 $ticket = Ticket::find($ticketId);
613 if (!$ticket) {
614 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
615 }
616
617 if ($err = TicketAccessGuard::assert($ticket)) {
618 return $err;
619 }
620
621 (new TicketService())->reopen($ticket, $agent);
622 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
623
624 return MCPHelper::envelope(
625 "Ticket #{$ticket->id} reopened",
626 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
627 );
628 }
629
630 public static function updateTicket($params)
631 {
632 $agent = MCPHelper::resolveAgent();
633 if (!$agent) {
634 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
635 }
636
637 $ticketId = (int) ($params['ticket_id'] ?? 0);
638 if (!$ticketId) {
639 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
640 }
641
642 $ticket = Ticket::find($ticketId);
643 if (!$ticket) {
644 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
645 }
646
647 if ($err = TicketAccessGuard::assert($ticket)) {
648 return $err;
649 }
650
651 $intFields = ['product_id', 'mailbox_id'];
652 $updatable = ['title', 'priority', 'status', 'product_id', 'mailbox_id'];
653 $changed = false;
654
655 foreach ($updatable as $field) {
656 if (!isset($params[$field])) {
657 continue;
658 }
659
660 if ($field === 'mailbox_id') {
661 $mid = (int) $params['mailbox_id'];
662
663 // Same gate the mailbox switcher and the REST property endpoint
664 // use. Picking a mailbox at creation stays open to every agent.
665 if (!PermissionManager::currentUserCan('fst_manage_settings')) {
666 return MCPHelper::error(
667 'forbidden',
668 __('You do not have permission to move this ticket to another mailbox.', 'fluent-support'),
669 ['fields' => ['mailbox_id'], 'retryable' => false]
670 );
671 }
672
673 if ($err = TicketAccessGuard::assertMailboxWritable($mid)) {
674 return $err;
675 }
676 if (!\FluentSupport\App\Models\MailBox::find($mid)) {
677 return MCPHelper::error('invalid_param', __('The specified mailbox does not exist', 'fluent-support'), ['fields' => ['mailbox_id'], 'next_step' => 'Use get-support-context to see available mailboxes and their IDs']);
678 }
679
680 // A ticket must not land in a mailbox its assignee is restricted
681 // from. Skipped when agent_id is also set, since
682 // resolveAssignmentTarget then checks the incoming agent instead.
683 if ($ticket->agent_id && !isset($params['agent_id'])) {
684 $assignedAgent = $ticket->agent;
685 if ($assignedAgent && TicketAccessGuard::assertAssignableAgent($ticket, $assignedAgent, $mid)) {
686 return MCPHelper::error(
687 'forbidden',
688 __('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'),
689 ['fields' => ['mailbox_id'], 'next_step' => 'Pass agent_id in the same call to reassign, or assign the ticket to an unrestricted agent first']
690 );
691 }
692 }
693
694 $ticket->mailbox_id = $mid;
695 $changed = true;
696 continue;
697 }
698
699 if ($field === 'product_id') {
700 $pid = (int) $params['product_id'];
701 if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) {
702 return MCPHelper::error('invalid_param', __('The specified product does not exist', 'fluent-support'), ['fields' => ['product_id'], 'next_step' => 'Use get-support-context to see available products and their IDs']);
703 }
704 $ticket->product_id = $pid ?: null;
705 $changed = true;
706 continue;
707 }
708
709 $value = in_array($field, $intFields, true)
710 ? (int) $params[$field]
711 : sanitize_text_field($params[$field]);
712
713 if ($field === 'priority') {
714 $value = MCPHelper::normalizePriority($value);
715 }
716
717 if ($field === 'status') {
718 $allowedStatuses = ['new', 'active'];
719 if (!in_array($value, $allowedStatuses, true)) {
720 return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]);
721 }
722 if ($ticket->status === 'closed') {
723 return MCPHelper::error(
724 'ticket_closed',
725 __('Cannot change the status of a closed ticket via update-ticket.', 'fluent-support'),
726 ['next_step' => 'Use reopen-ticket to reopen the ticket first', 'retryable' => false]
727 );
728 }
729 }
730
731 $ticket->{$field} = $value;
732 $changed = true;
733 }
734
735 $assignTarget = null;
736 if (isset($params['agent_id'])) {
737 $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $ticket, 'agent_id');
738 if (is_wp_error($assignTarget)) {
739 return $assignTarget;
740 }
741 }
742
743 if ($assignTarget) {
744 // Persists agent_id together with any scalar field changes above and
745 // fires the assignment side effects when the assignee changes.
746 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
747 } elseif ($changed) {
748 $ticket->save();
749 }
750
751 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
752
753 return MCPHelper::envelope(
754 "Ticket #{$ticket->id} updated",
755 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
756 );
757 }
758
759 public static function deleteTicket($params)
760 {
761 $agent = MCPHelper::resolveAgent();
762 if (!$agent) {
763 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
764 }
765
766 $ticketId = (int) ($params['ticket_id'] ?? 0);
767 if (!$ticketId) {
768 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
769 }
770
771 $ticket = Ticket::find($ticketId);
772 if (!$ticket) {
773 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
774 }
775
776 if ($err = TicketAccessGuard::assert($ticket)) {
777 return $err;
778 }
779
780 $ticketTitle = $ticket->title;
781 (new TicketService())->deleteTicket($ticket, $agent);
782
783 return MCPHelper::envelope(
784 "Ticket #{$ticketId} permanently deleted: {$ticketTitle}",
785 ['deleted_title' => sanitize_text_field($ticketTitle)]
786 );
787 }
788
789 public static function getTicketActivity($params)
790 {
791 $agent = MCPHelper::resolveAgent();
792 if (!$agent) {
793 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
794 }
795
796 $ticketId = (int) ($params['ticket_id'] ?? 0);
797 if (!$ticketId) {
798 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
799 }
800
801 $ticket = Ticket::find($ticketId);
802 if (!$ticket) {
803 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
804 }
805
806 if ($err = TicketAccessGuard::assert($ticket)) {
807 return $err;
808 }
809
810 $activities = Activity::where('object_type', 'ticket')
811 ->where('object_id', $ticketId)
812 ->with('person')
813 ->orderBy('created_at', 'desc')
814 ->limit(50)
815 ->get();
816
817 $count = $activities->count();
818
819 return MCPHelper::envelope(
820 "Found {$count} activity entries for ticket #{$ticketId}",
821 [
822 'ticket_id' => $ticketId,
823 'activities' => $activities->map(function ($a) {
824 return [
825 'id' => $a->id,
826 'event' => $a->event_type,
827 'description' => MCPHelper::htmlToText($a->description),
828 'person' => MCPHelper::personName($a->person),
829 'person_type' => $a->person_type,
830 'created_at' => MCPHelper::toIso8601($a->created_at),
831 ];
832 })->toArray(),
833 ],
834 ['total' => $count]
835 );
836 }
837
838 public static function mergeTickets($params)
839 {
840 $agent = MCPHelper::resolveAgent();
841 if (!$agent) {
842 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
843 }
844
845 if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) {
846 return MCPHelper::error('not_available', __('Merge tickets requires Fluent Support Pro', 'fluent-support'));
847 }
848
849 $targetId = (int) ($params['target_ticket_id'] ?? 0);
850 $mergeIds = array_map('intval', (array) ($params['merge_ticket_ids'] ?? []));
851
852 if (!$targetId || empty($mergeIds)) {
853 return MCPHelper::error('invalid_param', __('target_ticket_id and merge_ticket_ids are required', 'fluent-support'), ['fields' => ['target_ticket_id', 'merge_ticket_ids']]);
854 }
855
856 $mergeIds = array_values(array_unique($mergeIds));
857
858 if (in_array($targetId, $mergeIds, true)) {
859 return MCPHelper::error(
860 'invalid_param',
861 __('target_ticket_id must not also appear in merge_ticket_ids', 'fluent-support'),
862 ['fields' => ['target_ticket_id', 'merge_ticket_ids']]
863 );
864 }
865
866 $target = Ticket::find($targetId);
867 if (!$target) {
868 return MCPHelper::error('not_found', __('Target ticket not found', 'fluent-support'), ['fields' => ['target_ticket_id'], 'next_step' => 'Use list-tickets to find valid ticket IDs']);
869 }
870
871 if ($err = TicketAccessGuard::assert($target)) {
872 return $err;
873 }
874
875 $inaccessible = [];
876 $mergeTicketMap = Ticket::whereIn('id', $mergeIds)->get()->keyBy('id');
877 foreach ($mergeIds as $mergeId) {
878 $source = $mergeTicketMap->get($mergeId);
879 if (!$source || TicketAccessGuard::assert($source)) {
880 $inaccessible[] = $mergeId;
881 }
882 }
883
884 if ($inaccessible) {
885 return MCPHelper::error(
886 'forbidden',
887 sprintf(__('You do not have access to the following ticket(s): %s', 'fluent-support'), implode(', ', $inaccessible))
888 );
889 }
890
891 $proService = new \FluentSupportPro\App\Services\ProTicketService();
892 $result = $proService->mergeCustomerTickets($mergeIds, $targetId);
893
894 if ($result === null) {
895 return MCPHelper::error('merge_failed', __('Ticket merge failed or was only partially completed. Some tickets may be in an inconsistent state.', 'fluent-support'));
896 }
897
898 $target->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
899
900 $mergeCount = count($mergeIds);
901
902 return MCPHelper::envelope(
903 "{$mergeCount} ticket(s) merged into #{$targetId}",
904 ['ticket' => MCPHelper::formatTicketForMCP($target)]
905 );
906 }
907 }
908