function ($q) { $q->select(['id', 'first_name', 'last_name', 'email']); }, 'agent' => function ($q) { $q->select(['id', 'first_name', 'last_name', 'email']); }, 'product', 'tags', ]); $filterError = self::applyFilters($query, $params); if (is_wp_error($filterError)) { return $filterError; } do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]); (new AgentTicketAccess())->applyMailboxRestrictionScope($query); // Sort columns that keyset pagination supports. waiting_since, updated_at and // response_count have composite (col,id) indexes (TicketsMigrator); the // low-cardinality columns make do with their single-column index. $allowedSortColumns = ['id', 'created_at', 'updated_at', 'waiting_since', 'status', 'priority', 'client_priority', 'response_count']; $sortBy = sanitize_text_field($params['sort_by'] ?? 'id'); if (!in_array($sortBy, $allowedSortColumns, true)) { $sortBy = 'id'; } $sortType = Helper::sanitizeOrderValue($params['sort_type'] ?? $params['order'] ?? 'DESC'); ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params); // The id tiebreaker follows the sort direction so the order is total, // which the keyset cursor needs to resume correctly. $query->orderBy($sortBy, $sortType)->orderBy('id', $sortType); $cursor = sanitize_text_field($params['cursor'] ?? ''); if ($cursor !== '') { $decoded = self::decodeListCursor($cursor); if (!$decoded || $decoded['sort_by'] !== $sortBy || $decoded['sort_type'] !== $sortType) { 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']); } self::applyCursorWhere($query, $sortBy, $sortType, $decoded); } // Keyset mode: fetch one extra row to detect has_more without a COUNT. if ($cursor !== '') { $rows = $query->limit($perPage + 1)->get(); $hasMore = $rows->count() > $perPage; $rows = $rows->slice(0, $perPage)->values(); $nextCursor = $hasMore && $rows->isNotEmpty() ? self::encodeListCursor($rows->last(), $sortBy, $sortType) : null; $summary = sprintf(_n('Found %d ticket', 'Found %d tickets', $rows->count(), 'fluent-support'), $rows->count()); $customerMeta = CustomerMetaEnricher::resolve($rows, [ 'surface' => 'ticket_list', 'agent_id' => $agent->id, ]); return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($rows, $customerMeta)], [ 'paging' => [ 'per_page' => $perPage, 'has_more' => $hasMore, 'next_cursor' => $nextCursor, ], ]); } $paginated = $query->paginate($perPage, ['*'], 'page', $page); $total = $paginated->total(); $summary = sprintf( /* translators: %1$d is the number of tickets found, %2$d is the current page, %3$d is the last page */ _n('Found %1$d ticket (page %2$d of %3$d)', 'Found %1$d tickets (page %2$d of %3$d)', $total, 'fluent-support'), $total, $paginated->currentPage(), $paginated->lastPage() ); $items = $paginated->items(); $customerMeta = CustomerMetaEnricher::resolve($items, [ 'surface' => 'ticket_list', 'agent_id' => $agent->id, ]); $meta = MCPHelper::pagingMeta($paginated); // Lets a sweep switch from page/offset to keyset pagination. $meta['paging']['next_cursor'] = !empty($items) ? self::encodeListCursor(end($items), $sortBy, $sortType) : null; return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($items, $customerMeta)], $meta); } /** * Keyset WHERE for rows strictly after $decoded. MySQL sorts NULLs first in * ASC and last in DESC, and a plain `>`/`<` never matches NULL, so each * branch handles NULLs explicitly to avoid skipping them. */ private static function applyCursorWhere($query, $sortBy, $sortType, $decoded) { $id = $decoded['id']; $v = $decoded['v']; if ($sortType === 'ASC') { if ($v === null) { // Still among the leading NULL rows, or past them entirely. $query->where(function ($q) use ($sortBy, $id) { $q->where(function ($q2) use ($sortBy, $id) { $q2->whereNull($sortBy)->where('id', '>', $id); })->orWhereNotNull($sortBy); }); } else { // Past the NULL rows, which sort first in ASC. $query->where(function ($q) use ($sortBy, $v, $id) { $q->where($sortBy, '>', $v) ->orWhere(function ($q2) use ($sortBy, $v, $id) { $q2->where($sortBy, '=', $v)->where('id', '>', $id); }); }); } } else { if ($v === null) { // Already among the trailing NULL rows (they sort last in DESC). $query->whereNull($sortBy)->where('id', '<', $id); } else { // NULL rows come after all non-null values. $query->where(function ($q) use ($sortBy, $v, $id) { $q->where($sortBy, '<', $v) ->orWhere(function ($q2) use ($sortBy, $v, $id) { $q2->where($sortBy, '=', $v)->where('id', '<', $id); }) ->orWhereNull($sortBy); }); } } } /** * Keyset cursor: last row's id and sort value, plus the sort_by/sort_type * it belongs to so it can't be reused against a different column. * Not signed; it only holds values the caller has already seen. */ private static function encodeListCursor($lastRow, $sortBy, $sortType) { // Raw value, since date casts would serialize as an object and break the // scalar comparison in applyCursorWhere(). $value = $lastRow->getRawOriginal($sortBy); return base64_encode(wp_json_encode([ 'id' => (int) $lastRow->id, 'v' => $value, 'sort_by' => $sortBy, 'sort_type' => $sortType, ])); } private static function decodeListCursor($cursor) { $decoded = json_decode(base64_decode($cursor, true), true); if (!is_array($decoded) || !isset($decoded['id'], $decoded['sort_by'], $decoded['sort_type']) || !array_key_exists('v', $decoded)) { return null; } return $decoded; } private static function applyFilters($query, $params) { $filters = []; if (!empty($params['status'])) { $filters['status_type'] = $params['status']; } else if (!empty($params['waiting_for_reply']) || !empty($params['waiting_on'])) { // Closed tickets aren't waiting on anyone, so default to open. $filters['status_type'] = 'open'; } if (!empty($params['agent_id'])) { $filters['agent_id'] = (int) $params['agent_id']; } if (!empty($params['product_id'])) { $filters['product_id'] = (int) $params['product_id']; } if (!empty($params['mailbox_id'])) { $filters['mailbox_id'] = (int) $params['mailbox_id']; } if (!empty($params['priority'])) { $filters['priority'] = MCPHelper::normalizePriority($params['priority']); } if (!empty($params['client_priority'])) { $filters['client_priority'] = MCPHelper::normalizePriority($params['client_priority']); } if (!empty($params['tags'])) { $filters['ticket_tags'] = array_map('intval', (array) $params['tags']); } $waitingOn = $params['waiting_on'] ?? ''; if (!empty($params['waiting_for_reply']) || $waitingOn === 'agent') { $filters['waiting_for_reply'] = 'yes'; } elseif ($waitingOn === 'customer') { // We replied last: the ticket waits on the customer (as my_queue.awaiting_customer). $query->whereColumn('last_customer_response', '<', 'last_agent_response'); } if (!empty($params['customer_id'])) { $query->where('customer_id', (int) $params['customer_id']); } if (!empty($params['unassigned'])) { $query->whereNull('agent_id'); } if (!empty($params['needs_first_response'])) { $query->where('response_count', 0)->whereNotIn('status', ['closed']); } $query->applyFilters($filters); foreach (['created_after' => '>=', 'created_before' => '<='] as $field => $op) { if (empty($params[$field])) { continue; } try { $d = new \DateTime(sanitize_text_field($params[$field])); $query->where('created_at', $op, $d->format('Y-m-d H:i:s')); } catch (\Exception $e) { return MCPHelper::error('invalid_param', sprintf(__("Invalid date format for '%s'", 'fluent-support'), $field), ['fields' => [$field], 'hint' => 'Use YYYY-MM-DD or ISO 8601']); } } if (!empty($params['search'])) { $search = sanitize_text_field($params['search']); $query->where(function ($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%") ->orWhere('id', '=', is_numeric($search) ? (int) $search : 0) ->orWhereHas('customer', function ($cq) use ($search) { $cq->where('email', 'LIKE', "%{$search}%") ->orWhere('first_name', 'LIKE', "%{$search}%") ->orWhere('last_name', 'LIKE', "%{$search}%"); }) ->orWhereHas('responses', function ($rq) use ($search) { $rq->where('content', 'LIKE', "%{$search}%"); }); }); } } public static function getTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); if (!$ticketId) { return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]); } $ticket = Ticket::with(['customer', 'agent', 'product', 'mailbox', 'tags'])->find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } $maxChars = max(0, (int) ($params['max_chars_per_response'] ?? 0)); // Re-sign inline image URLs before formatting (not saved), so the body is // converted to Markdown once. $ticket->content = Helper::refreshSignedAttachmentUrls($ticket->content, $ticket->id); $data = MCPHelper::formatTicketForMCP($ticket); $data = array_merge($data, MCPHelper::truncatedContent($data['content'], $maxChars)); // Attachments of the opening message have no conversation. $openingAttachments = \FluentSupport\App\Models\Attachment::where('ticket_id', $ticket->id) ->whereNull('conversation_id') ->where('status', 'active') ->get(); if (!$openingAttachments->isEmpty()) { $data['attachments'] = MCPHelper::formatAttachments($openingAttachments); } if ($ticket->customer) { $customer = $ticket->customer; $customerId = $customer->id; // Only count and list sibling tickets this agent could open directly. $customerTickets = (new AgentTicketAccess()) ->applyAccessScope(Ticket::where('customer_id', $customerId), $agent); $data['customer']['total_tickets'] = (clone $customerTickets)->count(); $data['customer']['open_tickets'] = (clone $customerTickets) ->whereNotIn('status', ['closed'])->count(); $data['customer']['first_seen'] = MCPHelper::toIso8601($customer->created_at); $prevTickets = (clone $customerTickets) ->where('id', '!=', $ticket->id) ->select(['id', 'title', 'status', 'priority', 'created_at']) ->orderBy('id', 'DESC') ->limit(10) ->get(); if ($prevTickets->count()) { $data['previous_tickets'] = $prevTickets->map(function ($t) { return [ 'id' => $t->id, 'title' => $t->title, 'status' => $t->status ?: 'new', 'priority' => MCPHelper::normalizePriority($t->priority), 'created_at' => MCPHelper::toIso8601($t->created_at), ]; })->toArray(); } // true/false, or a list of widget keys to keep. $withIntegrations = $params['with_integrations'] ?? true; $integrationKeys = null; if (is_array($withIntegrations)) { // Widget keys are compared as given; sanitize_key() would lowercase them. $integrationKeys = array_values(array_filter(array_map(function ($key) { return is_scalar($key) ? sanitize_text_field((string) $key) : ''; }, $withIntegrations))); $withIntegrations = (bool) $integrationKeys; } if ($withIntegrations !== false) { $extraWidgets = ProfileInfoService::getProfileExtraWidgets($customer, ['format' => 'mcp', 'keys' => $integrationKeys]); if ($extraWidgets) { // Filter again for integrations that ignore the keys hint. $data['integrations'] = MCPHelper::formatExtraWidgets($extraWidgets, $integrationKeys); } } $crmData = Helper::getFluentCrmContactData($customer); if ($crmData) { $data['crm'] = [ 'name' => $crmData['full_name'] ?? '', 'status' => $crmData['status'] ?? '', 'tags' => !empty($crmData['tags']) ? $crmData['tags']->pluck('title')->toArray() : [], 'lists' => !empty($crmData['lists']) ? $crmData['lists']->pluck('title')->toArray() : [], ]; } } // Email templates are per inbox: say what this ticket's reply email adds // around the agent's text, so the reply doesn't repeat it. if ($ticket->mailbox) { $data['reply_email'] = ManagementTools::describeAgentEmail($ticket->mailbox, 'ticket_replied_by_agent_email_to_customer', $agent); } $customFields = $ticket->customData('admin', true); if ($customFields) { $data['custom_fields'] = $customFields; } $withResponses = ($params['with_responses'] ?? true) !== false; if ($withResponses) { $responsePage = max(1, (int) ($params['response_page'] ?? 1)); $offset = ($responsePage - 1) * self::MAX_RESPONSES; $responseId = (int) ($params['response_id'] ?? 0); $sinceResponseId = (int) ($params['since_response_id'] ?? 0); // Snapshot the newest id first and read up to it, so a reply that lands // mid-call is not reported in latest_response_id without being returned. $latestId = (int) \FluentSupport\App\Models\Conversation::where('ticket_id', $ticket->id)->max('id'); $query = \FluentSupport\App\Models\Conversation::where('ticket_id', $ticket->id) ->where('id', '<=', $latestId) ->with(['person', 'attachments']); if ($responseId) { $query->where('id', $responseId); } elseif ($sinceResponseId) { $query->where('id', '>', $sinceResponseId); } // One extra row tells us whether an older page exists. $rows = $query->orderBy('id', 'desc') ->offset($offset) ->limit(self::MAX_RESPONSES + 1) ->get(); $hasMore = $rows->count() > self::MAX_RESPONSES; if ($hasMore) { $rows = $rows->slice(0, self::MAX_RESPONSES); } if ($responseId && $rows->isEmpty()) { return MCPHelper::error('not_found', __('Response not found on this ticket', 'fluent-support'), ['fields' => ['response_id']]); } // Inline images carry signed URLs that expire; re-sign them. $rows = $rows->reverse()->values(); $contents = Helper::refreshSignedAttachmentUrlsInContents($rows->pluck('content')->all(), $ticket->id); foreach ($rows as $index => $row) { $row->content = $contents[$index]; } $data['responses'] = MCPHelper::formatResponseThread($rows, $maxChars); $data['responses_meta'] = [ 'page' => $responsePage, 'per_page' => self::MAX_RESPONSES, 'has_more' => $hasMore, ]; if ($sinceResponseId) { $data['responses_meta']['since_response_id'] = $sinceResponseId; } // Pass this as since_response_id next time to fetch only new messages. $data['responses_meta']['latest_response_id'] = max($latestId, $sinceResponseId); } $summary = "Ticket #{$ticket->id}: {$ticket->title} [{$data['status']}, {$data['priority']}]"; return MCPHelper::envelope($summary, $data); } public static function createTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $title = sanitize_text_field($params['title'] ?? ''); $format = $params['content_format'] ?? 'markdown'; $content = wp_kses_post(MCPHelper::processContent($params['content'] ?? '', $format)); if (!$title || !$content) { return MCPHelper::error('invalid_param', __('title and content are required', 'fluent-support'), ['fields' => ['title', 'content']]); } $email = sanitize_email($params['customer_email'] ?? ''); if (!$email || !is_email($email)) { return MCPHelper::error('invalid_param', __('A valid customer_email is required', 'fluent-support'), ['fields' => ['customer_email']]); } $customer = Customer::where('email', $email)->first(); if (!$customer) { $customerData = [ 'email' => $email, 'first_name' => sanitize_text_field($params['customer_first_name'] ?? ''), 'last_name' => sanitize_text_field($params['customer_last_name'] ?? ''), ]; $wpUser = get_user_by('email', $email); if ($wpUser) { $customerData['user_id'] = $wpUser->ID; if (!$customerData['first_name']) { $customerData['first_name'] = $wpUser->first_name ?: $wpUser->display_name; } if (!$customerData['last_name']) { $customerData['last_name'] = $wpUser->last_name; } } $customerData = array_filter($customerData); $customer = Customer::create($customerData); if (!$customer || !$customer->exists) { return MCPHelper::error('customer_create_failed', __('Could not create a customer record for this email', 'fluent-support')); } do_action('fluent_support/customer_created', $customer); } $ticketData = [ 'title' => $title, 'content' => $content, 'customer_id' => $customer->id, 'source' => 'mcp', 'status' => 'new', 'priority' => 'normal', // Logged on the customer's behalf by default, so the customer gets the // created-by-agent email. See agent_initiated below for outreach. ]; if (!empty($params['priority'])) { $ticketData['priority'] = MCPHelper::normalizePriority($params['priority']); } if (!empty($params['product_id'])) { $productId = (int) $params['product_id']; if (!\FluentSupport\App\Models\Product::find($productId)) { 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']); } $ticketData['product_id'] = $productId; } if (!empty($params['mailbox_id'])) { $mailboxId = (int) $params['mailbox_id']; if ($err = TicketAccessGuard::assertMailboxWritable($mailboxId)) { return $err; } if (!\FluentSupport\App\Models\MailBox::find($mailboxId)) { 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']); } $ticketData['mailbox_id'] = $mailboxId; } if (!empty($params['agent_id'])) { // Check the assignee's mailbox restriction against the mailbox the // ticket will land in, which is the default when none is given. $effectiveMailboxId = (int) ($ticketData['mailbox_id'] ?? 0); if (!$effectiveMailboxId) { $defaultMailbox = Helper::getDefaultMailBox(); $effectiveMailboxId = $defaultMailbox ? (int) $defaultMailbox->id : 0; } $probeTicket = null; if ($effectiveMailboxId) { $probeTicket = new Ticket(); $probeTicket->mailbox_id = $effectiveMailboxId; } $agentRecord = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id'); if (is_wp_error($agentRecord)) { return $agentRecord; } $ticketData['agent_id'] = $agentRecord->id; } // Agent-initiated outreach: no creation emails, and `content` is posted as // the agent's opening reply. if (filter_var($params['agent_initiated'] ?? false, FILTER_VALIDATE_BOOLEAN)) { $ticketData['agent_initiated'] = 'yes'; } $ticket = (new TicketService())->storeTicket($ticketData, $customer); $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']); $data = ['ticket' => MCPHelper::formatTicketForMCP($ticket)]; if ( !$ticket->agent_id && !PermissionManager::currentUserCan('fst_manage_unassigned_tickets') && !PermissionManager::currentUserCan('fst_manage_other_tickets') ) { $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'); } return MCPHelper::envelope( "Ticket #{$ticket->id} created: {$ticket->title}", $data ); } public static function closeTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); if (!$ticketId) { return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]); } $ticket = Ticket::find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } if ($ticket->status === 'closed') { $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']); return MCPHelper::envelope( "Ticket #{$ticket->id} is already closed", ['ticket' => MCPHelper::formatTicketForMCP($ticket)] ); } $format = $params['content_format'] ?? 'markdown'; $replyContent = wp_kses_post(MCPHelper::processContent($params['reply_content'] ?? '', $format)); $internalNote = wp_kses_post(MCPHelper::processContent($params['internal_note'] ?? '', $format)); // close() takes 'yes'/'no', and only the exact string 'no' notifies. $silently = !empty($params['silent']) ? 'yes' : 'no'; // Atomic, so a failed close doesn't leave the reply on an open ticket. (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket, $silently) { if ($replyContent) { $data = [ 'content' => $replyContent, 'conversation_type' => 'response', 'source' => 'mcp', ]; (new ResponseService())->createResponse($data, $agent, $ticket); } (new TicketService())->close($ticket, $agent, $internalNote, $silently); }); $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']); $summary = $replyContent ? "Reply sent and ticket #{$ticket->id} closed" : "Ticket #{$ticket->id} closed"; if ($silently === 'yes') { $summary .= ' silently (no close notification or automations fired)'; } return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]); } public static function updateTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); if (!$ticketId) { return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]); } $ticket = Ticket::find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } $intFields = ['product_id']; $updatable = ['title', 'priority', 'status', 'product_id']; $reopen = false; $changed = false; // Validate the target mailbox (if any) up front, before any write, so // a bad mailbox_id/agent_id below never leaves a partial change behind. $targetMailboxId = null; if (isset($params['mailbox_id'])) { $targetMailboxId = (int) $params['mailbox_id']; // Same gate the mailbox switcher and the bulk-move endpoint use. // Picking a mailbox at creation stays open to every agent. if (!PermissionManager::currentUserCan('fst_manage_settings')) { return MCPHelper::error( 'forbidden', __('You do not have permission to move this ticket to another mailbox.', 'fluent-support'), ['fields' => ['mailbox_id'], 'retryable' => false] ); } if ($err = TicketAccessGuard::assertMailboxWritable($targetMailboxId)) { return $err; } if (!\FluentSupport\App\Models\MailBox::find($targetMailboxId)) { 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']); } } // Resolve the assignee before any write. When this call also moves the // mailbox, probe the restriction check against the destination mailbox // (not the ticket's current one) — same pattern as createTicket() above. $assignTarget = null; if (isset($params['agent_id'])) { $probeTicket = $ticket; if ($targetMailboxId !== null) { $probeTicket = clone $ticket; $probeTicket->mailbox_id = $targetMailboxId; } $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id'); if (is_wp_error($assignTarget)) { return $assignTarget; } } // A ticket must not land in a mailbox its eventual assignee is // restricted from — the incoming agent if this call also reassigns, // otherwise whoever is assigned now. if ($targetMailboxId !== null) { $assigneeToCheck = $assignTarget ?: $ticket->agent; if ($assigneeToCheck && TicketAccessGuard::assertAssignableAgent($ticket, $assigneeToCheck, $targetMailboxId)) { return MCPHelper::error( 'forbidden', __('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'), ['fields' => ['mailbox_id'], 'next_step' => 'Pass agent_id in the same call to reassign, or assign the ticket to an unrestricted agent first'] ); } } foreach ($updatable as $field) { if (!isset($params[$field])) { continue; } if ($field === 'product_id') { $pid = (int) $params['product_id']; if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) { 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']); } $ticket->product_id = $pid ?: null; $changed = true; continue; } $value = in_array($field, $intFields, true) ? (int) $params[$field] : sanitize_text_field($params[$field]); if ($field === 'priority') { $value = MCPHelper::normalizePriority($value); } if ($field === 'status') { $allowedStatuses = ['new', 'active']; if (!in_array($value, $allowedStatuses, true)) { return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]); } if ($ticket->status === 'closed') { // status=active reopens a closed ticket, as reopen in the ticket view does. if ($value !== 'active') { return MCPHelper::error( 'ticket_closed', __('This ticket is closed. Pass status=active to reopen it.', 'fluent-support'), ['fields' => ['status'], 'retryable' => false] ); } $reopen = true; continue; } } $ticket->{$field} = $value; $changed = true; } if ($reopen) { // Saves the field changes above too, and fires the reopen hooks. (new TicketService())->reopen($ticket, $agent); } if ($assignTarget) { // Also saves the field changes above. MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent); } elseif ($changed) { $ticket->save(); } // Move the mailbox last, after the assignment/scalar changes are // durable, so MailBoxService::moveTickets()'s own restriction check // (which reads the ticket's agent fresh from the DB) sees the final // agent rather than the one being replaced in this same call. if ($targetMailboxId !== null) { $currentMailboxId = (int) $ticket->mailbox_id; if ($currentMailboxId !== $targetMailboxId) { if ($currentMailboxId) { // Go through the move service so the move hooks fire, same as every other surface. try { (new MailBoxService())->moveTickets([ 'new_box_id' => $targetMailboxId, 'ticket_ids' => [$ticket->id], 'move_type' => 'Custom', ], $currentMailboxId); } catch (\Exception $e) { return MCPHelper::error('invalid_param', $e->getMessage(), ['fields' => ['mailbox_id']]); } } else { $ticket->mailbox_id = $targetMailboxId; $ticket->save(); } // Keep the in-memory ticket in sync with what was just persisted. $ticket->mailbox_id = $targetMailboxId; } } $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']); return MCPHelper::envelope( $reopen ? "Ticket #{$ticket->id} reopened and updated" : "Ticket #{$ticket->id} updated", ['ticket' => MCPHelper::formatTicketForMCP($ticket)] ); } public static function deleteTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); if (!$ticketId) { return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]); } $ticket = Ticket::find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } $ticketTitle = $ticket->title; (new TicketService())->deleteTicket($ticket, $agent); return MCPHelper::envelope( "Ticket #{$ticketId} permanently deleted: {$ticketTitle}", ['deleted_title' => sanitize_text_field($ticketTitle)] ); } public static function getTicketActivity($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); if (!$ticketId) { return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]); } $ticket = Ticket::find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } $activities = Activity::where('object_type', 'ticket') ->where('object_id', $ticketId) ->with('person') ->orderBy('created_at', 'desc') ->limit(50) ->get(); $count = $activities->count(); return MCPHelper::envelope( "Found {$count} activity entries for ticket #{$ticketId}", [ 'ticket_id' => $ticketId, 'activities' => $activities->map(function ($a) { return [ 'id' => $a->id, 'event' => $a->event_type, 'description' => MCPHelper::htmlToText($a->description), 'person' => MCPHelper::personName($a->person), 'person_type' => $a->person_type, 'created_at' => MCPHelper::toIso8601($a->created_at), ]; })->toArray(), ], ['total' => $count] ); } public static function mergeTickets($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) { return MCPHelper::error('not_available', __('Merge tickets requires Fluent Support Pro', 'fluent-support')); } $targetId = (int) ($params['target_ticket_id'] ?? 0); $mergeIds = array_map('intval', (array) ($params['merge_ticket_ids'] ?? [])); if (!$targetId || empty($mergeIds)) { return MCPHelper::error('invalid_param', __('target_ticket_id and merge_ticket_ids are required', 'fluent-support'), ['fields' => ['target_ticket_id', 'merge_ticket_ids']]); } $mergeIds = array_values(array_unique($mergeIds)); if (in_array($targetId, $mergeIds, true)) { return MCPHelper::error( 'invalid_param', __('target_ticket_id must not also appear in merge_ticket_ids', 'fluent-support'), ['fields' => ['target_ticket_id', 'merge_ticket_ids']] ); } $target = Ticket::find($targetId); if (!$target) { 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']); } if ($err = TicketAccessGuard::assert($target)) { return $err; } $inaccessible = []; $mergeTicketMap = Ticket::whereIn('id', $mergeIds)->get()->keyBy('id'); foreach ($mergeIds as $mergeId) { $source = $mergeTicketMap->get($mergeId); if (!$source || TicketAccessGuard::assert($source)) { $inaccessible[] = $mergeId; } } if ($inaccessible) { return MCPHelper::error( 'forbidden', sprintf( /* translators: %s is a comma-separated list of ticket IDs */ _n('You do not have access to this ticket: %s', 'You do not have access to these tickets: %s', count($inaccessible), 'fluent-support'), implode(', ', $inaccessible) ) ); } $proService = new \FluentSupportPro\App\Services\ProTicketService(); $result = $proService->mergeCustomerTickets($mergeIds, $targetId); if ($result === null) { return MCPHelper::error('merge_failed', __('Ticket merge failed or was only partially completed. Some tickets may be in an inconsistent state.', 'fluent-support')); } $target->load(['customer', 'agent', 'product', 'mailbox', 'tags']); $mergeCount = count($mergeIds); return MCPHelper::envelope( "{$mergeCount} ticket(s) merged into #{$targetId}", ['ticket' => MCPHelper::formatTicketForMCP($target)] ); } /** Customer emails a new ticket can trigger; split-ticket holds them back by default. */ const SPLIT_CUSTOMER_EMAILS = [ 'ticket_created_email_to_customer', 'ticket_created_by_agent_email_to_customer', 'ticket_created_by_agent_on_behalf_email_to_customer', ]; /** * Move one message out of a ticket into a new ticket (Pro), as the ticket view's * "Split Ticket" does: the message and its attachments leave the original ticket * and open the new one, and both tickets get an activity line. */ public static function splitTicket($params) { $agent = MCPHelper::resolveAgent(); if (!$agent) { return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); } if (!class_exists('\FluentSupportPro\App\Services\ProTicketService')) { return MCPHelper::error('not_available', __('Split ticket requires Fluent Support Pro', 'fluent-support')); } $ticketId = (int) ($params['ticket_id'] ?? 0); $responseId = (int) ($params['response_id'] ?? 0); if (!$ticketId || !$responseId) { return MCPHelper::error('invalid_param', __('ticket_id and response_id are required', 'fluent-support'), ['fields' => ['ticket_id', 'response_id']]); } $ticket = Ticket::find($ticketId); if (!$ticket) { return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']); } if ($err = TicketAccessGuard::assert($ticket)) { return $err; } // Only a message someone sent: notes, drafts and activity lines stay put. $response = Conversation::where('id', $responseId) ->where('ticket_id', $ticket->id) ->where('conversation_type', 'response') ->first(); if (!$response) { return MCPHelper::error('not_found', __('Message not found on this ticket', 'fluent-support'), [ 'fields' => ['response_id'], 'next_step' => 'Use get-ticket and pick a response of type "response"', ]); } $customerId = (int) ($params['customer_id'] ?? 0); if (!$customerId) { // The message's author when a customer wrote it, else the ticket's customer. $author = Person::find($response->person_id); $customerId = ($author && $author->person_type === 'customer') ? (int) $author->id : (int) $ticket->customer_id; } if (!Customer::find($customerId)) { return MCPHelper::error('not_found', __('Customer not found', 'fluent-support'), ['fields' => ['customer_id']]); } $content = $response->content; if (isset($params['content']) && $params['content'] !== '') { $content = MCPHelper::processContent($params['content'], $params['content_format'] ?? 'markdown'); } $title = sanitize_text_field((string) ($params['title'] ?? '')); if ($title === '') { /* translators: %s is the original ticket title */ $title = sprintf(__('Re: %s', 'fluent-support'), $ticket->title); } $mailboxId = (int) $ticket->mailbox_id; if (!empty($params['mailbox_id']) && (int) $params['mailbox_id'] !== $mailboxId) { $mailboxId = (int) $params['mailbox_id']; if ($err = TicketAccessGuard::assertMailboxWritable($mailboxId)) { return $err; } if (!\FluentSupport\App\Models\MailBox::find($mailboxId)) { 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']); } } $data = [ 'conversation_id' => $response->id, 'customer_id' => $customerId, 'mailbox_id' => $mailboxId, 'title' => $title, 'content' => wp_kses_post($content), 'client_priority' => $ticket->client_priority ?: 'normal', ]; $productId = (int) ($params['product_id'] ?? ($ticket->product_id ?: 0)); if ($productId) { $data['product_id'] = $productId; } // A split is housekeeping inside a conversation the customer is already in, so // the new ticket's "ticket created" emails to the customer are held back unless // asked for. Scoped to tickets created during this call; agent emails still go. $quiet = null; if (empty($params['notify_customer'])) { $originalId = (int) $ticket->id; $quiet = function ($shouldSend, $channel, $type, $created = null) use ($originalId) { if ($channel === 'email' && $created && (int) $created->id !== $originalId && in_array($type, self::SPLIT_CUSTOMER_EMAILS, true)) { return false; } return $shouldSend; }; add_filter('fluent_support/should_send_notification', $quiet, 10, 4); } try { $result = (new \FluentSupportPro\App\Services\ProTicketService())->splitToNewTicket($ticket->id, $data); } catch (\Throwable $e) { // Not Helper::getSafeErrorMessage(): it throws, which would surface as a retryable internal_error. return MCPHelper::error('split_failed', $e->getMessage() ?: __('Could not split the ticket', 'fluent-support'), ['retryable' => false]); } finally { if ($quiet) { remove_filter('fluent_support/should_send_notification', $quiet, 10); } } $newTicket = Ticket::with(['customer', 'agent', 'product', 'mailbox', 'tags'])->find((int) ($result['new_ticket_id'] ?? 0)); if (!$newTicket) { return MCPHelper::error('split_failed', __('The new ticket could not be loaded', 'fluent-support'), ['retryable' => false]); } return MCPHelper::envelope( /* translators: 1: response ID, 2: original ticket ID, 3: new ticket ID */ sprintf(__('Message #%1$d moved from ticket #%2$d to new ticket #%3$d', 'fluent-support'), $response->id, $ticket->id, $newTicket->id), ['ticket' => MCPHelper::formatTicketForMCP($newTicket), 'original_ticket_id' => (int) $ticket->id] ); } }