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

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

1,113 lines 47.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\Conversation;
7 use FluentSupport\App\Models\Customer;
8 use FluentSupport\App\Models\Person;
9 use FluentSupport\App\Models\Ticket;
10 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
11 use FluentSupport\App\Modules\MCP\Support\CustomerMetaEnricher;
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\MailerInbox\MailBoxService;
16 use FluentSupport\App\Services\ProfileInfoService;
17 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
18 use FluentSupport\App\Services\Tickets\ResponseService;
19 use FluentSupport\App\Services\Tickets\TicketService;
20
21 class TicketTools
22 {
23 const MAX_RESPONSES = 50;
24
25 public static function listTickets($params)
26 {
27 $agent = MCPHelper::resolveAgent();
28 if (!$agent) {
29 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
30 }
31
32 $query = Ticket::with([
33 'customer' => function ($q) {
34 $q->select(['id', 'first_name', 'last_name', 'email']);
35 },
36 'agent' => function ($q) {
37 $q->select(['id', 'first_name', 'last_name', 'email']);
38 },
39 'product',
40 'tags',
41 ]);
42
43 $filterError = self::applyFilters($query, $params);
44 if (is_wp_error($filterError)) {
45 return $filterError;
46 }
47
48 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
49
50 (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
51
52 // Sort columns that keyset pagination supports. waiting_since, updated_at and
53 // response_count have composite (col,id) indexes (TicketsMigrator); the
54 // low-cardinality columns make do with their single-column index.
55 $allowedSortColumns = ['id', 'created_at', 'updated_at', 'waiting_since', 'status', 'priority', 'client_priority', 'response_count'];
56 $sortBy = sanitize_text_field($params['sort_by'] ?? 'id');
57 if (!in_array($sortBy, $allowedSortColumns, true)) {
58 $sortBy = 'id';
59 }
60 $sortType = Helper::sanitizeOrderValue($params['sort_type'] ?? $params['order'] ?? 'DESC');
61
62 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
63
64 // The id tiebreaker follows the sort direction so the order is total,
65 // which the keyset cursor needs to resume correctly.
66 $query->orderBy($sortBy, $sortType)->orderBy('id', $sortType);
67
68 $cursor = sanitize_text_field($params['cursor'] ?? '');
69 if ($cursor !== '') {
70 $decoded = self::decodeListCursor($cursor);
71 if (!$decoded || $decoded['sort_by'] !== $sortBy || $decoded['sort_type'] !== $sortType) {
72 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']);
73 }
74
75 self::applyCursorWhere($query, $sortBy, $sortType, $decoded);
76 }
77
78 // Keyset mode: fetch one extra row to detect has_more without a COUNT.
79 if ($cursor !== '') {
80 $rows = $query->limit($perPage + 1)->get();
81 $hasMore = $rows->count() > $perPage;
82 $rows = $rows->slice(0, $perPage)->values();
83 $nextCursor = $hasMore && $rows->isNotEmpty()
84 ? self::encodeListCursor($rows->last(), $sortBy, $sortType)
85 : null;
86
87 $summary = sprintf(_n('Found %d ticket', 'Found %d tickets', $rows->count(), 'fluent-support'), $rows->count());
88
89 $customerMeta = CustomerMetaEnricher::resolve($rows, [
90 'surface' => 'ticket_list',
91 'agent_id' => $agent->id,
92 ]);
93
94 return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($rows, $customerMeta)], [
95 'paging' => [
96 'per_page' => $perPage,
97 'has_more' => $hasMore,
98 'next_cursor' => $nextCursor,
99 ],
100 ]);
101 }
102
103 $paginated = $query->paginate($perPage, ['*'], 'page', $page);
104
105 $total = $paginated->total();
106 $summary = sprintf(
107 /* translators: %1$d is the number of tickets found, %2$d is the current page, %3$d is the last page */
108 _n('Found %1$d ticket (page %2$d of %3$d)', 'Found %1$d tickets (page %2$d of %3$d)', $total, 'fluent-support'),
109 $total,
110 $paginated->currentPage(),
111 $paginated->lastPage()
112 );
113
114 $items = $paginated->items();
115
116 $customerMeta = CustomerMetaEnricher::resolve($items, [
117 'surface' => 'ticket_list',
118 'agent_id' => $agent->id,
119 ]);
120
121 $meta = MCPHelper::pagingMeta($paginated);
122 // Lets a sweep switch from page/offset to keyset pagination.
123 $meta['paging']['next_cursor'] = !empty($items)
124 ? self::encodeListCursor(end($items), $sortBy, $sortType)
125 : null;
126
127 return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($items, $customerMeta)], $meta);
128 }
129
130 /**
131 * Keyset WHERE for rows strictly after $decoded. MySQL sorts NULLs first in
132 * ASC and last in DESC, and a plain `>`/`<` never matches NULL, so each
133 * branch handles NULLs explicitly to avoid skipping them.
134 */
135 private static function applyCursorWhere($query, $sortBy, $sortType, $decoded)
136 {
137 $id = $decoded['id'];
138 $v = $decoded['v'];
139
140 if ($sortType === 'ASC') {
141 if ($v === null) {
142 // Still among the leading NULL rows, or past them entirely.
143 $query->where(function ($q) use ($sortBy, $id) {
144 $q->where(function ($q2) use ($sortBy, $id) {
145 $q2->whereNull($sortBy)->where('id', '>', $id);
146 })->orWhereNotNull($sortBy);
147 });
148 } else {
149 // Past the NULL rows, which sort first in ASC.
150 $query->where(function ($q) use ($sortBy, $v, $id) {
151 $q->where($sortBy, '>', $v)
152 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
153 $q2->where($sortBy, '=', $v)->where('id', '>', $id);
154 });
155 });
156 }
157 } else {
158 if ($v === null) {
159 // Already among the trailing NULL rows (they sort last in DESC).
160 $query->whereNull($sortBy)->where('id', '<', $id);
161 } else {
162 // NULL rows come after all non-null values.
163 $query->where(function ($q) use ($sortBy, $v, $id) {
164 $q->where($sortBy, '<', $v)
165 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
166 $q2->where($sortBy, '=', $v)->where('id', '<', $id);
167 })
168 ->orWhereNull($sortBy);
169 });
170 }
171 }
172 }
173
174 /**
175 * Keyset cursor: last row's id and sort value, plus the sort_by/sort_type
176 * it belongs to so it can't be reused against a different column.
177 * Not signed; it only holds values the caller has already seen.
178 */
179 private static function encodeListCursor($lastRow, $sortBy, $sortType)
180 {
181 // Raw value, since date casts would serialize as an object and break the
182 // scalar comparison in applyCursorWhere().
183 $value = $lastRow->getRawOriginal($sortBy);
184 return base64_encode(wp_json_encode([
185 'id' => (int) $lastRow->id,
186 'v' => $value,
187 'sort_by' => $sortBy,
188 'sort_type' => $sortType,
189 ]));
190 }
191
192 private static function decodeListCursor($cursor)
193 {
194 $decoded = json_decode(base64_decode($cursor, true), true);
195 if (!is_array($decoded) || !isset($decoded['id'], $decoded['sort_by'], $decoded['sort_type']) || !array_key_exists('v', $decoded)) {
196 return null;
197 }
198 return $decoded;
199 }
200
201 private static function applyFilters($query, $params)
202 {
203 $filters = [];
204
205 if (!empty($params['status'])) {
206 $filters['status_type'] = $params['status'];
207 } else if (!empty($params['waiting_for_reply']) || !empty($params['waiting_on'])) {
208 // Closed tickets aren't waiting on anyone, so default to open.
209 $filters['status_type'] = 'open';
210 }
211 if (!empty($params['agent_id'])) {
212 $filters['agent_id'] = (int) $params['agent_id'];
213 }
214 if (!empty($params['product_id'])) {
215 $filters['product_id'] = (int) $params['product_id'];
216 }
217 if (!empty($params['mailbox_id'])) {
218 $filters['mailbox_id'] = (int) $params['mailbox_id'];
219 }
220 if (!empty($params['priority'])) {
221 $filters['priority'] = MCPHelper::normalizePriority($params['priority']);
222 }
223 if (!empty($params['client_priority'])) {
224 $filters['client_priority'] = MCPHelper::normalizePriority($params['client_priority']);
225 }
226 if (!empty($params['tags'])) {
227 $filters['ticket_tags'] = array_map('intval', (array) $params['tags']);
228 }
229 $waitingOn = $params['waiting_on'] ?? '';
230 if (!empty($params['waiting_for_reply']) || $waitingOn === 'agent') {
231 $filters['waiting_for_reply'] = 'yes';
232 } elseif ($waitingOn === 'customer') {
233 // We replied last: the ticket waits on the customer (as my_queue.awaiting_customer).
234 $query->whereColumn('last_customer_response', '<', 'last_agent_response');
235 }
236
237 if (!empty($params['customer_id'])) {
238 $query->where('customer_id', (int) $params['customer_id']);
239 }
240
241 if (!empty($params['unassigned'])) {
242 $query->whereNull('agent_id');
243 }
244
245 if (!empty($params['needs_first_response'])) {
246 $query->where('response_count', 0)->whereNotIn('status', ['closed']);
247 }
248
249 $query->applyFilters($filters);
250
251 foreach (['created_after' => '>=', 'created_before' => '<='] as $field => $op) {
252 if (empty($params[$field])) {
253 continue;
254 }
255 try {
256 $d = new \DateTime(sanitize_text_field($params[$field]));
257 $query->where('created_at', $op, $d->format('Y-m-d H:i:s'));
258 } catch (\Exception $e) {
259 return MCPHelper::error('invalid_param', sprintf(__("Invalid date format for '%s'", 'fluent-support'), $field), ['fields' => [$field], 'hint' => 'Use YYYY-MM-DD or ISO 8601']);
260 }
261 }
262
263 if (!empty($params['search'])) {
264 $search = sanitize_text_field($params['search']);
265 $query->where(function ($q) use ($search) {
266 $q->where('title', 'LIKE', "%{$search}%")
267 ->orWhere('content', 'LIKE', "%{$search}%")
268 ->orWhere('id', '=', is_numeric($search) ? (int) $search : 0)
269 ->orWhereHas('customer', function ($cq) use ($search) {
270 $cq->where('email', 'LIKE', "%{$search}%")
271 ->orWhere('first_name', 'LIKE', "%{$search}%")
272 ->orWhere('last_name', 'LIKE', "%{$search}%");
273 })
274 ->orWhereHas('responses', function ($rq) use ($search) {
275 $rq->where('content', 'LIKE', "%{$search}%");
276 });
277 });
278 }
279 }
280
281 public static function getTicket($params)
282 {
283 $agent = MCPHelper::resolveAgent();
284 if (!$agent) {
285 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
286 }
287
288 $ticketId = (int) ($params['ticket_id'] ?? 0);
289 if (!$ticketId) {
290 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
291 }
292
293 $ticket = Ticket::with(['customer', 'agent', 'product', 'mailbox', 'tags'])->find($ticketId);
294
295 if (!$ticket) {
296 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
297 }
298
299 if ($err = TicketAccessGuard::assert($ticket)) {
300 return $err;
301 }
302
303 $maxChars = max(0, (int) ($params['max_chars_per_response'] ?? 0));
304
305 // Re-sign inline image URLs before formatting (not saved), so the body is
306 // converted to Markdown once.
307 $ticket->content = Helper::refreshSignedAttachmentUrls($ticket->content, $ticket->id);
308 $data = MCPHelper::formatTicketForMCP($ticket);
309 $data = array_merge($data, MCPHelper::truncatedContent($data['content'], $maxChars));
310
311 // Attachments of the opening message have no conversation.
312 $openingAttachments = \FluentSupport\App\Models\Attachment::where('ticket_id', $ticket->id)
313 ->whereNull('conversation_id')
314 ->where('status', 'active')
315 ->get();
316 if (!$openingAttachments->isEmpty()) {
317 $data['attachments'] = MCPHelper::formatAttachments($openingAttachments);
318 }
319
320 if ($ticket->customer) {
321 $customer = $ticket->customer;
322 $customerId = $customer->id;
323 // Only count and list sibling tickets this agent could open directly.
324 $customerTickets = (new AgentTicketAccess())
325 ->applyAccessScope(Ticket::where('customer_id', $customerId), $agent);
326
327 $data['customer']['total_tickets'] = (clone $customerTickets)->count();
328 $data['customer']['open_tickets'] = (clone $customerTickets)
329 ->whereNotIn('status', ['closed'])->count();
330 $data['customer']['first_seen'] = MCPHelper::toIso8601($customer->created_at);
331
332 $prevTickets = (clone $customerTickets)
333 ->where('id', '!=', $ticket->id)
334 ->select(['id', 'title', 'status', 'priority', 'created_at'])
335 ->orderBy('id', 'DESC')
336 ->limit(10)
337 ->get();
338
339 if ($prevTickets->count()) {
340 $data['previous_tickets'] = $prevTickets->map(function ($t) {
341 return [
342 'id' => $t->id,
343 'title' => $t->title,
344 'status' => $t->status ?: 'new',
345 'priority' => MCPHelper::normalizePriority($t->priority),
346 'created_at' => MCPHelper::toIso8601($t->created_at),
347 ];
348 })->toArray();
349 }
350
351 // true/false, or a list of widget keys to keep.
352 $withIntegrations = $params['with_integrations'] ?? true;
353 $integrationKeys = null;
354 if (is_array($withIntegrations)) {
355 // Widget keys are compared as given; sanitize_key() would lowercase them.
356 $integrationKeys = array_values(array_filter(array_map(function ($key) {
357 return is_scalar($key) ? sanitize_text_field((string) $key) : '';
358 }, $withIntegrations)));
359 $withIntegrations = (bool) $integrationKeys;
360 }
361
362 if ($withIntegrations !== false) {
363 $extraWidgets = ProfileInfoService::getProfileExtraWidgets($customer, ['format' => 'mcp', 'keys' => $integrationKeys]);
364 if ($extraWidgets) {
365 // Filter again for integrations that ignore the keys hint.
366 $data['integrations'] = MCPHelper::formatExtraWidgets($extraWidgets, $integrationKeys);
367 }
368 }
369
370 $crmData = Helper::getFluentCrmContactData($customer);
371 if ($crmData) {
372 $data['crm'] = [
373 'name' => $crmData['full_name'] ?? '',
374 'status' => $crmData['status'] ?? '',
375 'tags' => !empty($crmData['tags']) ? $crmData['tags']->pluck('title')->toArray() : [],
376 'lists' => !empty($crmData['lists']) ? $crmData['lists']->pluck('title')->toArray() : [],
377 ];
378 }
379 }
380
381 // Email templates are per inbox: say what this ticket's reply email adds
382 // around the agent's text, so the reply doesn't repeat it.
383 if ($ticket->mailbox) {
384 $data['reply_email'] = ManagementTools::describeAgentEmail($ticket->mailbox, 'ticket_replied_by_agent_email_to_customer', $agent);
385 }
386
387 $customFields = $ticket->customData('admin', true);
388 if ($customFields) {
389 $data['custom_fields'] = $customFields;
390 }
391
392 $withResponses = ($params['with_responses'] ?? true) !== false;
393 if ($withResponses) {
394 $responsePage = max(1, (int) ($params['response_page'] ?? 1));
395 $offset = ($responsePage - 1) * self::MAX_RESPONSES;
396 $responseId = (int) ($params['response_id'] ?? 0);
397 $sinceResponseId = (int) ($params['since_response_id'] ?? 0);
398
399 // Snapshot the newest id first and read up to it, so a reply that lands
400 // mid-call is not reported in latest_response_id without being returned.
401 $latestId = (int) \FluentSupport\App\Models\Conversation::where('ticket_id', $ticket->id)->max('id');
402
403 $query = \FluentSupport\App\Models\Conversation::where('ticket_id', $ticket->id)
404 ->where('id', '<=', $latestId)
405 ->with(['person', 'attachments']);
406
407 if ($responseId) {
408 $query->where('id', $responseId);
409 } elseif ($sinceResponseId) {
410 $query->where('id', '>', $sinceResponseId);
411 }
412
413 // One extra row tells us whether an older page exists.
414 $rows = $query->orderBy('id', 'desc')
415 ->offset($offset)
416 ->limit(self::MAX_RESPONSES + 1)
417 ->get();
418 $hasMore = $rows->count() > self::MAX_RESPONSES;
419 if ($hasMore) {
420 $rows = $rows->slice(0, self::MAX_RESPONSES);
421 }
422
423 if ($responseId && $rows->isEmpty()) {
424 return MCPHelper::error('not_found', __('Response not found on this ticket', 'fluent-support'), ['fields' => ['response_id']]);
425 }
426
427 // Inline images carry signed URLs that expire; re-sign them.
428 $rows = $rows->reverse()->values();
429 $contents = Helper::refreshSignedAttachmentUrlsInContents($rows->pluck('content')->all(), $ticket->id);
430 foreach ($rows as $index => $row) {
431 $row->content = $contents[$index];
432 }
433
434 $data['responses'] = MCPHelper::formatResponseThread($rows, $maxChars);
435 $data['responses_meta'] = [
436 'page' => $responsePage,
437 'per_page' => self::MAX_RESPONSES,
438 'has_more' => $hasMore,
439 ];
440
441 if ($sinceResponseId) {
442 $data['responses_meta']['since_response_id'] = $sinceResponseId;
443 }
444
445 // Pass this as since_response_id next time to fetch only new messages.
446 $data['responses_meta']['latest_response_id'] = max($latestId, $sinceResponseId);
447 }
448
449 $summary = "Ticket #{$ticket->id}: {$ticket->title} [{$data['status']}, {$data['priority']}]";
450
451 return MCPHelper::envelope($summary, $data);
452 }
453
454 public static function createTicket($params)
455 {
456 $agent = MCPHelper::resolveAgent();
457 if (!$agent) {
458 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
459 }
460
461 $title = sanitize_text_field($params['title'] ?? '');
462 $format = $params['content_format'] ?? 'markdown';
463 $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format));
464
465 if (!$title || !$content) {
466 return MCPHelper::error('invalid_param', __('title and content are required', 'fluent-support'), ['fields' => ['title', 'content']]);
467 }
468
469 $email = sanitize_email($params['customer_email'] ?? '');
470 if (!$email || !is_email($email)) {
471 return MCPHelper::error('invalid_param', __('A valid customer_email is required', 'fluent-support'), ['fields' => ['customer_email']]);
472 }
473
474 $customer = Customer::where('email', $email)->first();
475
476 if (!$customer) {
477 $customerData = [
478 'email' => $email,
479 'first_name' => sanitize_text_field($params['customer_first_name'] ?? ''),
480 'last_name' => sanitize_text_field($params['customer_last_name'] ?? ''),
481 ];
482
483 $wpUser = get_user_by('email', $email);
484 if ($wpUser) {
485 $customerData['user_id'] = $wpUser->ID;
486 if (!$customerData['first_name']) {
487 $customerData['first_name'] = $wpUser->first_name ?: $wpUser->display_name;
488 }
489 if (!$customerData['last_name']) {
490 $customerData['last_name'] = $wpUser->last_name;
491 }
492 }
493
494 $customerData = array_filter($customerData);
495 $customer = Customer::create($customerData);
496
497 if (!$customer || !$customer->exists) {
498 return MCPHelper::error('customer_create_failed', __('Could not create a customer record for this email', 'fluent-support'));
499 }
500
501 do_action('fluent_support/customer_created', $customer);
502 }
503
504 $ticketData = [
505 'title' => $title,
506 'content' => $content,
507 'customer_id' => $customer->id,
508 'source' => 'mcp',
509 'status' => 'new',
510 'priority' => 'normal',
511 // Logged on the customer's behalf by default, so the customer gets the
512 // created-by-agent email. See agent_initiated below for outreach.
513 ];
514
515 if (!empty($params['priority'])) {
516 $ticketData['priority'] = MCPHelper::normalizePriority($params['priority']);
517 }
518
519 if (!empty($params['product_id'])) {
520 $productId = (int) $params['product_id'];
521 if (!\FluentSupport\App\Models\Product::find($productId)) {
522 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']);
523 }
524 $ticketData['product_id'] = $productId;
525 }
526
527 if (!empty($params['mailbox_id'])) {
528 $mailboxId = (int) $params['mailbox_id'];
529 if ($err = TicketAccessGuard::assertMailboxWritable($mailboxId)) {
530 return $err;
531 }
532 if (!\FluentSupport\App\Models\MailBox::find($mailboxId)) {
533 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']);
534 }
535 $ticketData['mailbox_id'] = $mailboxId;
536 }
537
538 if (!empty($params['agent_id'])) {
539 // Check the assignee's mailbox restriction against the mailbox the
540 // ticket will land in, which is the default when none is given.
541 $effectiveMailboxId = (int) ($ticketData['mailbox_id'] ?? 0);
542 if (!$effectiveMailboxId) {
543 $defaultMailbox = Helper::getDefaultMailBox();
544 $effectiveMailboxId = $defaultMailbox ? (int) $defaultMailbox->id : 0;
545 }
546
547 $probeTicket = null;
548 if ($effectiveMailboxId) {
549 $probeTicket = new Ticket();
550 $probeTicket->mailbox_id = $effectiveMailboxId;
551 }
552
553 $agentRecord = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id');
554 if (is_wp_error($agentRecord)) {
555 return $agentRecord;
556 }
557 $ticketData['agent_id'] = $agentRecord->id;
558 }
559
560 // Agent-initiated outreach: no creation emails, and `content` is posted as
561 // the agent's opening reply.
562 if (filter_var($params['agent_initiated'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
563 $ticketData['agent_initiated'] = 'yes';
564 }
565
566 $ticket = (new TicketService())->storeTicket($ticketData, $customer);
567 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
568
569 $data = ['ticket' => MCPHelper::formatTicketForMCP($ticket)];
570
571 if (
572 !$ticket->agent_id &&
573 !PermissionManager::currentUserCan('fst_manage_unassigned_tickets') &&
574 !PermissionManager::currentUserCan('fst_manage_other_tickets')
575 ) {
576 $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');
577 }
578
579 return MCPHelper::envelope(
580 "Ticket #{$ticket->id} created: {$ticket->title}",
581 $data
582 );
583 }
584
585 public static function closeTicket($params)
586 {
587 $agent = MCPHelper::resolveAgent();
588 if (!$agent) {
589 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
590 }
591
592 $ticketId = (int) ($params['ticket_id'] ?? 0);
593 if (!$ticketId) {
594 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
595 }
596
597 $ticket = Ticket::find($ticketId);
598 if (!$ticket) {
599 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
600 }
601
602 if ($err = TicketAccessGuard::assert($ticket)) {
603 return $err;
604 }
605
606 if ($ticket->status === 'closed') {
607 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
608 return MCPHelper::envelope(
609 "Ticket #{$ticket->id} is already closed",
610 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
611 );
612 }
613
614 $format = $params['content_format'] ?? 'markdown';
615 $replyContent = wp_kses_post(MCPHelper::processContent($params['reply_content'] ?? '', $format));
616 $internalNote = wp_kses_post(MCPHelper::processContent($params['internal_note'] ?? '', $format));
617
618 // close() takes 'yes'/'no', and only the exact string 'no' notifies.
619 $silently = !empty($params['silent']) ? 'yes' : 'no';
620
621 // Atomic, so a failed close doesn't leave the reply on an open ticket.
622 (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket, $silently) {
623 if ($replyContent) {
624 $data = [
625 'content' => $replyContent,
626 'conversation_type' => 'response',
627 'source' => 'mcp',
628 ];
629 (new ResponseService())->createResponse($data, $agent, $ticket);
630 }
631
632 (new TicketService())->close($ticket, $agent, $internalNote, $silently);
633 });
634
635 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
636
637 $summary = $replyContent
638 ? "Reply sent and ticket #{$ticket->id} closed"
639 : "Ticket #{$ticket->id} closed";
640
641 if ($silently === 'yes') {
642 $summary .= ' silently (no close notification or automations fired)';
643 }
644
645 return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]);
646 }
647
648 public static function updateTicket($params)
649 {
650 $agent = MCPHelper::resolveAgent();
651 if (!$agent) {
652 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
653 }
654
655 $ticketId = (int) ($params['ticket_id'] ?? 0);
656 if (!$ticketId) {
657 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
658 }
659
660 $ticket = Ticket::find($ticketId);
661 if (!$ticket) {
662 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
663 }
664
665 if ($err = TicketAccessGuard::assert($ticket)) {
666 return $err;
667 }
668
669 $intFields = ['product_id'];
670 $updatable = ['title', 'priority', 'status', 'product_id'];
671 $reopen = false;
672 $changed = false;
673
674 // Validate the target mailbox (if any) up front, before any write, so
675 // a bad mailbox_id/agent_id below never leaves a partial change behind.
676 $targetMailboxId = null;
677 if (isset($params['mailbox_id'])) {
678 $targetMailboxId = (int) $params['mailbox_id'];
679
680 // Same gate the mailbox switcher and the bulk-move endpoint use.
681 // Picking a mailbox at creation stays open to every agent.
682 if (!PermissionManager::currentUserCan('fst_manage_settings')) {
683 return MCPHelper::error(
684 'forbidden',
685 __('You do not have permission to move this ticket to another mailbox.', 'fluent-support'),
686 ['fields' => ['mailbox_id'], 'retryable' => false]
687 );
688 }
689
690 if ($err = TicketAccessGuard::assertMailboxWritable($targetMailboxId)) {
691 return $err;
692 }
693 if (!\FluentSupport\App\Models\MailBox::find($targetMailboxId)) {
694 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']);
695 }
696 }
697
698 // Resolve the assignee before any write. When this call also moves the
699 // mailbox, probe the restriction check against the destination mailbox
700 // (not the ticket's current one) — same pattern as createTicket() above.
701 $assignTarget = null;
702 if (isset($params['agent_id'])) {
703 $probeTicket = $ticket;
704 if ($targetMailboxId !== null) {
705 $probeTicket = clone $ticket;
706 $probeTicket->mailbox_id = $targetMailboxId;
707 }
708
709 $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id');
710 if (is_wp_error($assignTarget)) {
711 return $assignTarget;
712 }
713 }
714
715 // A ticket must not land in a mailbox its eventual assignee is
716 // restricted from — the incoming agent if this call also reassigns,
717 // otherwise whoever is assigned now.
718 if ($targetMailboxId !== null) {
719 $assigneeToCheck = $assignTarget ?: $ticket->agent;
720 if ($assigneeToCheck && TicketAccessGuard::assertAssignableAgent($ticket, $assigneeToCheck, $targetMailboxId)) {
721 return MCPHelper::error(
722 'forbidden',
723 __('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'),
724 ['fields' => ['mailbox_id'], 'next_step' => 'Pass agent_id in the same call to reassign, or assign the ticket to an unrestricted agent first']
725 );
726 }
727 }
728
729 foreach ($updatable as $field) {
730 if (!isset($params[$field])) {
731 continue;
732 }
733
734 if ($field === 'product_id') {
735 $pid = (int) $params['product_id'];
736 if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) {
737 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']);
738 }
739 $ticket->product_id = $pid ?: null;
740 $changed = true;
741 continue;
742 }
743
744 $value = in_array($field, $intFields, true)
745 ? (int) $params[$field]
746 : sanitize_text_field($params[$field]);
747
748 if ($field === 'priority') {
749 $value = MCPHelper::normalizePriority($value);
750 }
751
752 if ($field === 'status') {
753 $allowedStatuses = ['new', 'active'];
754 if (!in_array($value, $allowedStatuses, true)) {
755 return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]);
756 }
757 if ($ticket->status === 'closed') {
758 // status=active reopens a closed ticket, as reopen in the ticket view does.
759 if ($value !== 'active') {
760 return MCPHelper::error(
761 'ticket_closed',
762 __('This ticket is closed. Pass status=active to reopen it.', 'fluent-support'),
763 ['fields' => ['status'], 'retryable' => false]
764 );
765 }
766 $reopen = true;
767 continue;
768 }
769 }
770
771 $ticket->{$field} = $value;
772 $changed = true;
773 }
774
775 if ($reopen) {
776 // Saves the field changes above too, and fires the reopen hooks.
777 (new TicketService())->reopen($ticket, $agent);
778 }
779
780 if ($assignTarget) {
781 // Also saves the field changes above.
782 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
783 } elseif ($changed) {
784 $ticket->save();
785 }
786
787 // Move the mailbox last, after the assignment/scalar changes are
788 // durable, so MailBoxService::moveTickets()'s own restriction check
789 // (which reads the ticket's agent fresh from the DB) sees the final
790 // agent rather than the one being replaced in this same call.
791 if ($targetMailboxId !== null) {
792 $currentMailboxId = (int) $ticket->mailbox_id;
793
794 if ($currentMailboxId !== $targetMailboxId) {
795 if ($currentMailboxId) {
796 // Go through the move service so the move hooks fire, same as every other surface.
797 try {
798 (new MailBoxService())->moveTickets([
799 'new_box_id' => $targetMailboxId,
800 'ticket_ids' => [$ticket->id],
801 'move_type' => 'Custom',
802 ], $currentMailboxId);
803 } catch (\Exception $e) {
804 return MCPHelper::error('invalid_param', $e->getMessage(), ['fields' => ['mailbox_id']]);
805 }
806 } else {
807 $ticket->mailbox_id = $targetMailboxId;
808 $ticket->save();
809 }
810
811 // Keep the in-memory ticket in sync with what was just persisted.
812 $ticket->mailbox_id = $targetMailboxId;
813 }
814 }
815
816 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
817
818 return MCPHelper::envelope(
819 $reopen ? "Ticket #{$ticket->id} reopened and updated" : "Ticket #{$ticket->id} updated",
820 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
821 );
822 }
823
824 public static function deleteTicket($params)
825 {
826 $agent = MCPHelper::resolveAgent();
827 if (!$agent) {
828 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
829 }
830
831 $ticketId = (int) ($params['ticket_id'] ?? 0);
832 if (!$ticketId) {
833 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
834 }
835
836 $ticket = Ticket::find($ticketId);
837 if (!$ticket) {
838 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
839 }
840
841 if ($err = TicketAccessGuard::assert($ticket)) {
842 return $err;
843 }
844
845 $ticketTitle = $ticket->title;
846 (new TicketService())->deleteTicket($ticket, $agent);
847
848 return MCPHelper::envelope(
849 "Ticket #{$ticketId} permanently deleted: {$ticketTitle}",
850 ['deleted_title' => sanitize_text_field($ticketTitle)]
851 );
852 }
853
854 public static function getTicketActivity($params)
855 {
856 $agent = MCPHelper::resolveAgent();
857 if (!$agent) {
858 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
859 }
860
861 $ticketId = (int) ($params['ticket_id'] ?? 0);
862 if (!$ticketId) {
863 return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
864 }
865
866 $ticket = Ticket::find($ticketId);
867 if (!$ticket) {
868 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
869 }
870
871 if ($err = TicketAccessGuard::assert($ticket)) {
872 return $err;
873 }
874
875 $activities = Activity::where('object_type', 'ticket')
876 ->where('object_id', $ticketId)
877 ->with('person')
878 ->orderBy('created_at', 'desc')
879 ->limit(50)
880 ->get();
881
882 $count = $activities->count();
883
884 return MCPHelper::envelope(
885 "Found {$count} activity entries for ticket #{$ticketId}",
886 [
887 'ticket_id' => $ticketId,
888 'activities' => $activities->map(function ($a) {
889 return [
890 'id' => $a->id,
891 'event' => $a->event_type,
892 'description' => MCPHelper::htmlToText($a->description),
893 'person' => MCPHelper::personName($a->person),
894 'person_type' => $a->person_type,
895 'created_at' => MCPHelper::toIso8601($a->created_at),
896 ];
897 })->toArray(),
898 ],
899 ['total' => $count]
900 );
901 }
902
903 public static function mergeTickets($params)
904 {
905 $agent = MCPHelper::resolveAgent();
906 if (!$agent) {
907 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
908 }
909
910 if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) {
911 return MCPHelper::error('not_available', __('Merge tickets requires Fluent Support Pro', 'fluent-support'));
912 }
913
914 $targetId = (int) ($params['target_ticket_id'] ?? 0);
915 $mergeIds = array_map('intval', (array) ($params['merge_ticket_ids'] ?? []));
916
917 if (!$targetId || empty($mergeIds)) {
918 return MCPHelper::error('invalid_param', __('target_ticket_id and merge_ticket_ids are required', 'fluent-support'), ['fields' => ['target_ticket_id', 'merge_ticket_ids']]);
919 }
920
921 $mergeIds = array_values(array_unique($mergeIds));
922
923 if (in_array($targetId, $mergeIds, true)) {
924 return MCPHelper::error(
925 'invalid_param',
926 __('target_ticket_id must not also appear in merge_ticket_ids', 'fluent-support'),
927 ['fields' => ['target_ticket_id', 'merge_ticket_ids']]
928 );
929 }
930
931 $target = Ticket::find($targetId);
932 if (!$target) {
933 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']);
934 }
935
936 if ($err = TicketAccessGuard::assert($target)) {
937 return $err;
938 }
939
940 $inaccessible = [];
941 $mergeTicketMap = Ticket::whereIn('id', $mergeIds)->get()->keyBy('id');
942 foreach ($mergeIds as $mergeId) {
943 $source = $mergeTicketMap->get($mergeId);
944 if (!$source || TicketAccessGuard::assert($source)) {
945 $inaccessible[] = $mergeId;
946 }
947 }
948
949 if ($inaccessible) {
950 return MCPHelper::error(
951 'forbidden',
952 sprintf(
953 /* translators: %s is a comma-separated list of ticket IDs */
954 _n('You do not have access to this ticket: %s', 'You do not have access to these tickets: %s', count($inaccessible), 'fluent-support'),
955 implode(', ', $inaccessible)
956 )
957 );
958 }
959
960 $proService = new \FluentSupportPro\App\Services\ProTicketService();
961 $result = $proService->mergeCustomerTickets($mergeIds, $targetId);
962
963 if ($result === null) {
964 return MCPHelper::error('merge_failed', __('Ticket merge failed or was only partially completed. Some tickets may be in an inconsistent state.', 'fluent-support'));
965 }
966
967 $target->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
968
969 $mergeCount = count($mergeIds);
970
971 return MCPHelper::envelope(
972 "{$mergeCount} ticket(s) merged into #{$targetId}",
973 ['ticket' => MCPHelper::formatTicketForMCP($target)]
974 );
975 }
976
977 /** Customer emails a new ticket can trigger; split-ticket holds them back by default. */
978 const SPLIT_CUSTOMER_EMAILS = [
979 'ticket_created_email_to_customer',
980 'ticket_created_by_agent_email_to_customer',
981 'ticket_created_by_agent_on_behalf_email_to_customer',
982 ];
983
984 /**
985 * Move one message out of a ticket into a new ticket (Pro), as the ticket view's
986 * "Split Ticket" does: the message and its attachments leave the original ticket
987 * and open the new one, and both tickets get an activity line.
988 */
989 public static function splitTicket($params)
990 {
991 $agent = MCPHelper::resolveAgent();
992 if (!$agent) {
993 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
994 }
995
996 if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) {
997 return MCPHelper::error('not_available', __('Split ticket requires Fluent Support Pro', 'fluent-support'));
998 }
999
1000 $ticketId = (int) ($params['ticket_id'] ?? 0);
1001 $responseId = (int) ($params['response_id'] ?? 0);
1002 if (!$ticketId || !$responseId) {
1003 return MCPHelper::error('invalid_param', __('ticket_id and response_id are required', 'fluent-support'), ['fields' => ['ticket_id', 'response_id']]);
1004 }
1005
1006 $ticket = Ticket::find($ticketId);
1007 if (!$ticket) {
1008 return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
1009 }
1010
1011 if ($err = TicketAccessGuard::assert($ticket)) {
1012 return $err;
1013 }
1014
1015 // Only a message someone sent: notes, drafts and activity lines stay put.
1016 $response = Conversation::where('id', $responseId)
1017 ->where('ticket_id', $ticket->id)
1018 ->where('conversation_type', 'response')
1019 ->first();
1020 if (!$response) {
1021 return MCPHelper::error('not_found', __('Message not found on this ticket', 'fluent-support'), [
1022 'fields' => ['response_id'],
1023 'next_step' => 'Use get-ticket and pick a response of type "response"',
1024 ]);
1025 }
1026
1027 $customerId = (int) ($params['customer_id'] ?? 0);
1028 if (!$customerId) {
1029 // The message's author when a customer wrote it, else the ticket's customer.
1030 $author = Person::find($response->person_id);
1031 $customerId = ($author && $author->person_type === 'customer') ? (int) $author->id : (int) $ticket->customer_id;
1032 }
1033 if (!Customer::find($customerId)) {
1034 return MCPHelper::error('not_found', __('Customer not found', 'fluent-support'), ['fields' => ['customer_id']]);
1035 }
1036
1037 $content = $response->content;
1038 if (isset($params['content']) && $params['content'] !== '') {
1039 $content = MCPHelper::processContent($params['content'], $params['content_format'] ?? 'markdown');
1040 }
1041
1042 $title = sanitize_text_field((string) ($params['title'] ?? ''));
1043 if ($title === '') {
1044 /* translators: %s is the original ticket title */
1045 $title = sprintf(__('Re: %s', 'fluent-support'), $ticket->title);
1046 }
1047
1048 $mailboxId = (int) $ticket->mailbox_id;
1049 if (!empty($params['mailbox_id']) && (int) $params['mailbox_id'] !== $mailboxId) {
1050 $mailboxId = (int) $params['mailbox_id'];
1051 if ($err = TicketAccessGuard::assertMailboxWritable($mailboxId)) {
1052 return $err;
1053 }
1054 if (!\FluentSupport\App\Models\MailBox::find($mailboxId)) {
1055 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']);
1056 }
1057 }
1058
1059 $data = [
1060 'conversation_id' => $response->id,
1061 'customer_id' => $customerId,
1062 'mailbox_id' => $mailboxId,
1063 'title' => $title,
1064 'content' => wp_kses_post($content),
1065 'client_priority' => $ticket->client_priority ?: 'normal',
1066 ];
1067
1068 $productId = (int) ($params['product_id'] ?? ($ticket->product_id ?: 0));
1069 if ($productId) {
1070 $data['product_id'] = $productId;
1071 }
1072
1073 // A split is housekeeping inside a conversation the customer is already in, so
1074 // the new ticket's "ticket created" emails to the customer are held back unless
1075 // asked for. Scoped to tickets created during this call; agent emails still go.
1076 $quiet = null;
1077 if (empty($params['notify_customer'])) {
1078 $originalId = (int) $ticket->id;
1079 $quiet = function ($shouldSend, $channel, $type, $created = null) use ($originalId) {
1080 if ($channel === 'email'
1081 && $created && (int) $created->id !== $originalId
1082 && in_array($type, self::SPLIT_CUSTOMER_EMAILS, true)) {
1083 return false;
1084 }
1085 return $shouldSend;
1086 };
1087 add_filter('fluent_support/should_send_notification', $quiet, 10, 4);
1088 }
1089
1090 try {
1091 $result = (new \FluentSupportPro\App\Services\ProTicketService())->splitToNewTicket($ticket->id, $data);
1092 } catch (\Throwable $e) {
1093 // Not Helper::getSafeErrorMessage(): it throws, which would surface as a retryable internal_error.
1094 return MCPHelper::error('split_failed', $e->getMessage() ?: __('Could not split the ticket', 'fluent-support'), ['retryable' => false]);
1095 } finally {
1096 if ($quiet) {
1097 remove_filter('fluent_support/should_send_notification', $quiet, 10);
1098 }
1099 }
1100
1101 $newTicket = Ticket::with(['customer', 'agent', 'product', 'mailbox', 'tags'])->find((int) ($result['new_ticket_id'] ?? 0));
1102 if (!$newTicket) {
1103 return MCPHelper::error('split_failed', __('The new ticket could not be loaded', 'fluent-support'), ['retryable' => false]);
1104 }
1105
1106 return MCPHelper::envelope(
1107 /* translators: 1: response ID, 2: original ticket ID, 3: new ticket ID */
1108 sprintf(__('Message #%1$d moved from ticket #%2$d to new ticket #%3$d', 'fluent-support'), $response->id, $ticket->id, $newTicket->id),
1109 ['ticket' => MCPHelper::formatTicketForMCP($newTicket), 'original_ticket_id' => (int) $ticket->id]
1110 );
1111 }
1112 }
1113