PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.1
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.1
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 2.3.1, at app/Modules/MCP/Tools/TicketTools.php

873 lines 36.9 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 // Reply + close must be atomic. Without a transaction, a failure in
567 // close() after the reply was created would leave the reply visible on
568 // a still-open ticket.
569 (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket) {
570 if ($replyContent) {
571 $data = [
572 'content' => $replyContent,
573 'conversation_type' => 'response',
574 'source' => 'mcp',
575 ];
576 (new ResponseService())->createResponse($data, $agent, $ticket);
577 }
578
579 (new TicketService())->close($ticket, $agent, $internalNote);
580 });
581
582 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
583
584 $summary = $replyContent
585 ? "Reply sent and ticket #{$ticket->id} closed"
586 : "Ticket #{$ticket->id} closed";
587
588 return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]);
589 }
590
591 public static function reopenTicket($params)
592 {
593 $agent = MCPHelper::resolveAgent();
594 if (!$agent) {
595 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
596 }
597
598 $ticketId = (int) ($params['ticket_id'] ?? 0);
599 if (!$ticketId) {
600 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
601 }
602
603 $ticket = Ticket::find($ticketId);
604 if (!$ticket) {
605 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
606 }
607
608 if ($err = TicketAccessGuard::assert($ticket)) {
609 return $err;
610 }
611
612 (new TicketService())->reopen($ticket, $agent);
613 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
614
615 return MCPHelper::envelope(
616 "Ticket #{$ticket->id} reopened",
617 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
618 );
619 }
620
621 public static function updateTicket($params)
622 {
623 $agent = MCPHelper::resolveAgent();
624 if (!$agent) {
625 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
626 }
627
628 $ticketId = (int) ($params['ticket_id'] ?? 0);
629 if (!$ticketId) {
630 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
631 }
632
633 $ticket = Ticket::find($ticketId);
634 if (!$ticket) {
635 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
636 }
637
638 if ($err = TicketAccessGuard::assert($ticket)) {
639 return $err;
640 }
641
642 $intFields = ['product_id', 'mailbox_id'];
643 $updatable = ['title', 'priority', 'status', 'product_id', 'mailbox_id'];
644 $changed = false;
645
646 foreach ($updatable as $field) {
647 if (!isset($params[$field])) {
648 continue;
649 }
650
651 if ($field === 'mailbox_id') {
652 $mid = (int) $params['mailbox_id'];
653 if ($err = TicketAccessGuard::assertMailboxWritable($mid)) {
654 return $err;
655 }
656 if (!\FluentSupport\App\Models\MailBox::find($mid)) {
657 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']);
658 }
659 $ticket->mailbox_id = $mid;
660 $changed = true;
661 continue;
662 }
663
664 if ($field === 'product_id') {
665 $pid = (int) $params['product_id'];
666 if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) {
667 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']);
668 }
669 $ticket->product_id = $pid ?: null;
670 $changed = true;
671 continue;
672 }
673
674 $value = in_array($field, $intFields, true)
675 ? (int) $params[$field]
676 : sanitize_text_field($params[$field]);
677
678 if ($field === 'priority') {
679 $value = MCPHelper::normalizePriority($value);
680 }
681
682 if ($field === 'status') {
683 $allowedStatuses = ['new', 'active'];
684 if (!in_array($value, $allowedStatuses, true)) {
685 return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]);
686 }
687 if ($ticket->status === 'closed') {
688 return MCPHelper::error(
689 'ticket_closed',
690 __('Cannot change the status of a closed ticket via update-ticket.', 'fluent-support'),
691 ['next_step' => 'Use reopen-ticket to reopen the ticket first', 'retryable' => false]
692 );
693 }
694 }
695
696 $ticket->{$field} = $value;
697 $changed = true;
698 }
699
700 $assignTarget = null;
701 if (isset($params['agent_id'])) {
702 $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $ticket, 'agent_id');
703 if (is_wp_error($assignTarget)) {
704 return $assignTarget;
705 }
706 }
707
708 if ($assignTarget) {
709 // Persists agent_id together with any scalar field changes above and
710 // fires the assignment side effects when the assignee changes.
711 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
712 } elseif ($changed) {
713 $ticket->save();
714 }
715
716 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
717
718 return MCPHelper::envelope(
719 "Ticket #{$ticket->id} updated",
720 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
721 );
722 }
723
724 public static function deleteTicket($params)
725 {
726 $agent = MCPHelper::resolveAgent();
727 if (!$agent) {
728 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
729 }
730
731 $ticketId = (int) ($params['ticket_id'] ?? 0);
732 if (!$ticketId) {
733 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
734 }
735
736 $ticket = Ticket::find($ticketId);
737 if (!$ticket) {
738 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
739 }
740
741 if ($err = TicketAccessGuard::assert($ticket)) {
742 return $err;
743 }
744
745 $ticketTitle = $ticket->title;
746 (new TicketService())->deleteTicket($ticket, $agent);
747
748 return MCPHelper::envelope(
749 "Ticket #{$ticketId} permanently deleted: {$ticketTitle}",
750 ['deleted_title' => sanitize_text_field($ticketTitle)]
751 );
752 }
753
754 public static function getTicketActivity($params)
755 {
756 $agent = MCPHelper::resolveAgent();
757 if (!$agent) {
758 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
759 }
760
761 $ticketId = (int) ($params['ticket_id'] ?? 0);
762 if (!$ticketId) {
763 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
764 }
765
766 $ticket = Ticket::find($ticketId);
767 if (!$ticket) {
768 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
769 }
770
771 if ($err = TicketAccessGuard::assert($ticket)) {
772 return $err;
773 }
774
775 $activities = Activity::where('object_type', 'ticket')
776 ->where('object_id', $ticketId)
777 ->with('person')
778 ->orderBy('created_at', 'desc')
779 ->limit(50)
780 ->get();
781
782 $count = $activities->count();
783
784 return MCPHelper::envelope(
785 "Found {$count} activity entries for ticket #{$ticketId}",
786 [
787 'ticket_id' => $ticketId,
788 'activities' => $activities->map(function ($a) {
789 return [
790 'id' => $a->id,
791 'event' => $a->event_type,
792 'description' => MCPHelper::htmlToText($a->description),
793 'person' => MCPHelper::personName($a->person),
794 'person_type' => $a->person_type,
795 'created_at' => MCPHelper::toIso8601($a->created_at),
796 ];
797 })->toArray(),
798 ],
799 ['total' => $count]
800 );
801 }
802
803 public static function mergeTickets($params)
804 {
805 $agent = MCPHelper::resolveAgent();
806 if (!$agent) {
807 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
808 }
809
810 if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) {
811 return MCPHelper::error('not_available', __('Merge tickets requires Fluent Support Pro', 'fluent-support'));
812 }
813
814 $targetId = (int) ($params['target_ticket_id'] ?? 0);
815 $mergeIds = array_map('intval', (array) ($params['merge_ticket_ids'] ?? []));
816
817 if (!$targetId || empty($mergeIds)) {
818 return MCPHelper::error('invalid_param', __('target_ticket_id and merge_ticket_ids are required', 'fluent-support'), ['fields' => ['target_ticket_id', 'merge_ticket_ids']]);
819 }
820
821 $mergeIds = array_values(array_unique($mergeIds));
822
823 if (in_array($targetId, $mergeIds, true)) {
824 return MCPHelper::error(
825 'invalid_param',
826 __('target_ticket_id must not also appear in merge_ticket_ids', 'fluent-support'),
827 ['fields' => ['target_ticket_id', 'merge_ticket_ids']]
828 );
829 }
830
831 $target = Ticket::find($targetId);
832 if (!$target) {
833 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']);
834 }
835
836 if ($err = TicketAccessGuard::assert($target)) {
837 return $err;
838 }
839
840 $inaccessible = [];
841 $mergeTicketMap = Ticket::whereIn('id', $mergeIds)->get()->keyBy('id');
842 foreach ($mergeIds as $mergeId) {
843 $source = $mergeTicketMap->get($mergeId);
844 if (!$source || TicketAccessGuard::assert($source)) {
845 $inaccessible[] = $mergeId;
846 }
847 }
848
849 if ($inaccessible) {
850 return MCPHelper::error(
851 'forbidden',
852 sprintf(__('You do not have access to the following ticket(s): %s', 'fluent-support'), implode(', ', $inaccessible))
853 );
854 }
855
856 $proService = new \FluentSupportPro\App\Services\ProTicketService();
857 $result = $proService->mergeCustomerTickets($mergeIds, $targetId);
858
859 if ($result === null) {
860 return MCPHelper::error('merge_failed', __('Ticket merge failed or was only partially completed. Some tickets may be in an inconsistent state.', 'fluent-support'));
861 }
862
863 $target->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
864
865 $mergeCount = count($mergeIds);
866
867 return MCPHelper::envelope(
868 "{$mergeCount} ticket(s) merged into #{$targetId}",
869 ['ticket' => MCPHelper::formatTicketForMCP($target)]
870 );
871 }
872 }
873