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
← All changes | app/Modules/MCP/Tools/TicketTools.php +368 -163 2.4.0 → 2.4.5 View file →
@@ -2,9 +2,11 @@
2 2
3 3 namespace FluentSupport\App\Modules\MCP\Tools;
4 4
5 5 use FluentSupport\App\Models\Activity;
6 +use FluentSupport\App\Models\Conversation;
6 7 use FluentSupport\App\Models\Customer;
8 +use FluentSupport\App\Models\Person;
7 9 use FluentSupport\App\Models\Ticket;
8 10 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
9 11 use FluentSupport\App\Modules\MCP\Support\CustomerMetaEnricher;
10 12 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
@@ -9,9 +11,11 @@
9 11 use FluentSupport\App\Modules\MCP\Support\CustomerMetaEnricher;
10 12 use FluentSupport\App\Modules\MCP\Support\TicketAccessGuard;
11 13 use FluentSupport\App\Modules\PermissionManager;
12 14 use FluentSupport\App\Services\Helper;
15 +use FluentSupport\App\Services\MailerInbox\MailBoxService;
13 16 use FluentSupport\App\Services\ProfileInfoService;
17 +use FluentSupport\App\Services\Tickets\AgentTicketAccess;
14 18 use FluentSupport\App\Services\Tickets\ResponseService;
15 19 use FluentSupport\App\Services\Tickets\TicketService;
16 20
17 21 class TicketTools
@@ -42,20 +46,13 @@
42 46 }
43 47
44 48 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
45 49
46 - $restrictedMailboxes = PermissionManager::getRestrictedMailboxIds();
47 - if ($restrictedMailboxes) {
48 - $query->whereNotIn('mailbox_id', $restrictedMailboxes);
49 - }
50 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
50 51
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)
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.
58 55 $allowedSortColumns = ['id', 'created_at', 'updated_at', 'waiting_since', 'status', 'priority', 'client_priority', 'response_count'];
59 56 $sortBy = sanitize_text_field($params['sort_by'] ?? 'id');
60 57 if (!in_array($sortBy, $allowedSortColumns, true)) {
61 58 $sortBy = 'id';
@@ -63,12 +60,10 @@
63 60 $sortType = Helper::sanitizeOrderValue($params['sort_type'] ?? $params['order'] ?? 'DESC');
64 61
65 62 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
66 63
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.
64 + // The id tiebreaker follows the sort direction so the order is total,
65 + // which the keyset cursor needs to resume correctly.
71 66 $query->orderBy($sortBy, $sortType)->orderBy('id', $sortType);
72 67
73 68 $cursor = sanitize_text_field($params['cursor'] ?? '');
74 69 if ($cursor !== '') {
@@ -79,13 +74,9 @@
79 74
80 75 self::applyCursorWhere($query, $sortBy, $sortType, $decoded);
81 76 }
82 77
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.
78 + // Keyset mode: fetch one extra row to detect has_more without a COUNT.
88 79 if ($cursor !== '') {
89 80 $rows = $query->limit($perPage + 1)->get();
90 81 $hasMore = $rows->count() > $perPage;
91 82 $rows = $rows->slice(0, $perPage)->values();
@@ -111,14 +102,18 @@
111 102
112 103 $paginated = $query->paginate($perPage, ['*'], 'page', $page);
113 104
114 105 $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());
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 + );
117 113
118 114 $items = $paginated->items();
119 115
120 - // Optional integration-provided per-customer meta line (one batch call, gated by fst_sensitive_data).
121 116 $customerMeta = CustomerMetaEnricher::resolve($items, [
122 117 'surface' => 'ticket_list',
123 118 'agent_id' => $agent->id,
124 119 ]);
@@ -123,10 +118,9 @@
123 118 'agent_id' => $agent->id,
124 119 ]);
125 120
126 121 $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.
122 + // Lets a sweep switch from page/offset to keyset pagination.
129 123 $meta['paging']['next_cursor'] = !empty($items)
130 124 ? self::encodeListCursor(end($items), $sortBy, $sortType)
131 125 : null;
132 126
@@ -133,14 +127,11 @@
133 127 return MCPHelper::envelope($summary, ['tickets' => MCPHelper::formatTicketList($items, $customerMeta)], $meta);
134 128 }
135 129
136 130 /**
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.
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.
143 134 */
144 135 private static function applyCursorWhere($query, $sortBy, $sortType, $decoded)
145 136 {
146 137 $id = $decoded['id'];
@@ -154,9 +145,9 @@
154 145 $q2->whereNull($sortBy)->where('id', '>', $id);
155 146 })->orWhereNotNull($sortBy);
156 147 });
157 148 } else {
158 - // Past the NULL rows (they all sort first in ASC) — plain tuple compare.
149 + // Past the NULL rows, which sort first in ASC.
159 150 $query->where(function ($q) use ($sortBy, $v, $id) {
160 151 $q->where($sortBy, '>', $v)
161 152 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
162 153 $q2->where($sortBy, '=', $v)->where('id', '>', $id);
@@ -167,9 +158,9 @@
167 158 if ($v === null) {
168 159 // Already among the trailing NULL rows (they sort last in DESC).
169 160 $query->whereNull($sortBy)->where('id', '<', $id);
170 161 } else {
171 - // Not yet into the NULL rows — include them once past all non-null values.
162 + // NULL rows come after all non-null values.
172 163 $query->where(function ($q) use ($sortBy, $v, $id) {
173 164 $q->where($sortBy, '<', $v)
174 165 ->orWhere(function ($q2) use ($sortBy, $v, $id) {
175 166 $q2->where($sortBy, '=', $v)->where('id', '<', $id);
@@ -180,21 +171,16 @@
180 171 }
181 172 }
182 173
183 174 /**
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.
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.
190 178 */
191 179 private static function encodeListCursor($lastRow, $sortBy, $sortType)
192 180 {
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().
181 + // Raw value, since date casts would serialize as an object and break the
182 + // scalar comparison in applyCursorWhere().
197 183 $value = $lastRow->getRawOriginal($sortBy);
198 184 return base64_encode(wp_json_encode([
199 185 'id' => (int) $lastRow->id,
200 186 'v' => $value,
@@ -217,11 +203,10 @@
217 203 $filters = [];
218 204
219 205 if (!empty($params['status'])) {
220 206 $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.
207 + } else if (!empty($params['waiting_for_reply']) || !empty($params['waiting_on'])) {
208 + // Closed tickets aren't waiting on anyone, so default to open.
224 209 $filters['status_type'] = 'open';
225 210 }
226 211 if (!empty($params['agent_id'])) {
227 212 $filters['agent_id'] = (int) $params['agent_id'];
@@ -240,10 +225,14 @@
240 225 }
241 226 if (!empty($params['tags'])) {
242 227 $filters['ticket_tags'] = array_map('intval', (array) $params['tags']);
243 228 }
244 - if (!empty($params['waiting_for_reply'])) {
229 + $waitingOn = $params['waiting_on'] ?? '';
230 + if (!empty($params['waiting_for_reply']) || $waitingOn === 'agent') {
245 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');
246 235 }
247 236
248 237 if (!empty($params['customer_id'])) {
249 238 $query->where('customer_id', (int) $params['customer_id']);
@@ -310,19 +299,38 @@
310 299 if ($err = TicketAccessGuard::assert($ticket)) {
311 300 return $err;
312 301 }
313 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);
314 308 $data = MCPHelper::formatTicketForMCP($ticket);
309 + $data = array_merge($data, MCPHelper::truncatedContent($data['content'], $maxChars));
315 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 +
316 320 if ($ticket->customer) {
317 321 $customer = $ticket->customer;
318 322 $customerId = $customer->id;
319 - $data['customer']['total_tickets'] = Ticket::where('customer_id', $customerId)->count();
320 - $data['customer']['open_tickets'] = Ticket::where('customer_id', $customerId)
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)
321 329 ->whereNotIn('status', ['closed'])->count();
322 330 $data['customer']['first_seen'] = MCPHelper::toIso8601($customer->created_at);
323 331
324 - $prevTickets = Ticket::where('customer_id', $customerId)
332 + $prevTickets = (clone $customerTickets)
325 333 ->where('id', '!=', $ticket->id)
326 334 ->select(['id', 'title', 'status', 'priority', 'created_at'])
327 335 ->orderBy('id', 'DESC')
328 336 ->limit(10)
@@ -339,13 +347,24 @@
339 347 ];
340 348 })->toArray();
341 349 }
342 350
343 - $withIntegrations = ($params['with_integrations'] ?? true) !== false;
344 - if ($withIntegrations) {
345 - $extraWidgets = ProfileInfoService::getProfileExtraWidgets($customer);
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]);
346 364 if ($extraWidgets) {
347 - $data['integrations'] = MCPHelper::formatExtraWidgets($extraWidgets);
365 + // Filter again for integrations that ignore the keys hint.
366 + $data['integrations'] = MCPHelper::formatExtraWidgets($extraWidgets, $integrationKeys);
348 367 }
349 368 }
350 369
351 370 $crmData = Helper::getFluentCrmContactData($customer);
@@ -358,8 +377,14 @@
358 377 ];
359 378 }
360 379 }
361 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 +
362 387 $customFields = $ticket->customData('admin', true);
363 388 if ($customFields) {
364 389 $data['custom_fields'] = $customFields;
365 390 }
@@ -365,15 +390,29 @@
365 390 }
366 391
367 392 $withResponses = ($params['with_responses'] ?? true) !== false;
368 393 if ($withResponses) {
369 - $responsePage = max(1, (int) ($params['response_page'] ?? 1));
370 - $offset = ($responsePage - 1) * self::MAX_RESPONSES;
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);
371 398
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')
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')
376 415 ->offset($offset)
377 416 ->limit(self::MAX_RESPONSES + 1)
378 417 ->get();
379 418 $hasMore = $rows->count() > self::MAX_RESPONSES;
@@ -380,14 +419,32 @@
380 419 if ($hasMore) {
381 420 $rows = $rows->slice(0, self::MAX_RESPONSES);
382 421 }
383 422
384 - $data['responses'] = MCPHelper::formatResponseThread($rows->reverse()->values());
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);
385 435 $data['responses_meta'] = [
386 436 'page' => $responsePage,
387 437 'per_page' => self::MAX_RESPONSES,
388 438 'has_more' => $hasMore,
389 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);
390 447 }
391 448
392 449 $summary = "Ticket #{$ticket->id}: {$ticket->title} [{$data['status']}, {$data['priority']}]";
393 450
@@ -435,8 +492,13 @@
435 492 }
436 493
437 494 $customerData = array_filter($customerData);
438 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 +
439 501 do_action('fluent_support/customer_created', $customer);
440 502 }
441 503
442 504 $ticketData = [
@@ -445,14 +507,10 @@
445 507 'customer_id' => $customer->id,
446 508 'source' => 'mcp',
447 509 'status' => 'new',
448 510 '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.
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.
455 513 ];
456 514
457 515 if (!empty($params['priority'])) {
458 516 $ticketData['priority'] = MCPHelper::normalizePriority($params['priority']);
@@ -477,14 +535,10 @@
477 535 $ticketData['mailbox_id'] = $mailboxId;
478 536 }
479 537
480 538 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.
539 + // Check the assignee's mailbox restriction against the mailbox the
540 + // ticket will land in, which is the default when none is given.
487 541 $effectiveMailboxId = (int) ($ticketData['mailbox_id'] ?? 0);
488 542 if (!$effectiveMailboxId) {
489 543 $defaultMailbox = Helper::getDefaultMailBox();
490 544 $effectiveMailboxId = $defaultMailbox ? (int) $defaultMailbox->id : 0;
@@ -502,12 +556,10 @@
502 556 }
503 557 $ticketData['agent_id'] = $agentRecord->id;
504 558 }
505 559
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.
560 + // Agent-initiated outreach: no creation emails, and `content` is posted as
561 + // the agent's opening reply.
510 562 if (filter_var($params['agent_initiated'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
511 563 $ticketData['agent_initiated'] = 'yes';
512 564 }
513 565
@@ -562,16 +614,12 @@
562 614 $format = $params['content_format'] ?? 'markdown';
563 615 $replyContent = wp_kses_post(MCPHelper::processContent($params['reply_content'] ?? '', $format));
564 616 $internalNote = wp_kses_post(MCPHelper::processContent($params['internal_note'] ?? '', $format));
565 617
566 - // TicketService::close() takes the admin UI's 'yes'/'no' string, not a
567 - // bool - and it only treats the exact string 'no' as "notify", so an
568 - // empty/absent value would close silently. Always send one or the other.
618 + // close() takes 'yes'/'no', and only the exact string 'no' notifies.
569 619 $silently = !empty($params['silent']) ? 'yes' : 'no';
570 620
571 - // Reply + close must be atomic. Without a transaction, a failure in
572 - // close() after the reply was created would leave the reply visible on
573 - // a still-open ticket.
621 + // Atomic, so a failed close doesn't leave the reply on an open ticket.
574 622 (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket, $silently) {
575 623 if ($replyContent) {
576 624 $data = [
577 625 'content' => $replyContent,
@@ -596,9 +644,9 @@
596 644
597 645 return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]);
598 646 }
599 647
600 - public static function reopenTicket($params)
648 + public static function updateTicket($params)
601 649 {
602 650 $agent = MCPHelper::resolveAgent();
603 651 if (!$agent) {
604 652 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
@@ -617,86 +665,73 @@
617 665 if ($err = TicketAccessGuard::assert($ticket)) {
618 666 return $err;
619 667 }
620 668
621 - (new TicketService())->reopen($ticket, $agent);
622 - $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
669 + $intFields = ['product_id'];
670 + $updatable = ['title', 'priority', 'status', 'product_id'];
671 + $reopen = false;
672 + $changed = false;
623 673
624 - return MCPHelper::envelope(
625 - "Ticket #{$ticket->id} reopened",
626 - ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
627 - );
628 - }
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'];
629 679
630 - public static function updateTicket($params)
631 - {
632 - $agent = MCPHelper::resolveAgent();
633 - if (!$agent) {
634 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
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 + }
635 696 }
636 697
637 - $ticketId = (int) ($params['ticket_id'] ?? 0);
638 - if (!$ticketId) {
639 - return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
640 - }
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 + }
641 708
642 - $ticket = Ticket::find($ticketId);
643 - if (!$ticket) {
644 - return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
709 + $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id');
710 + if (is_wp_error($assignTarget)) {
711 + return $assignTarget;
712 + }
645 713 }
646 714
647 - if ($err = TicketAccessGuard::assert($ticket)) {
648 - return $err;
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 + }
649 727 }
650 728
651 - $intFields = ['product_id', 'mailbox_id'];
652 - $updatable = ['title', 'priority', 'status', 'product_id', 'mailbox_id'];
653 - $changed = false;
654 -
655 729 foreach ($updatable as $field) {
656 730 if (!isset($params[$field])) {
657 731 continue;
658 732 }
659 733
660 - if ($field === 'mailbox_id') {
661 - $mid = (int) $params['mailbox_id'];
662 -
663 - // Same gate the mailbox switcher and the REST property endpoint
664 - // use. Picking a mailbox at creation stays open to every agent.
665 - if (!PermissionManager::currentUserCan('fst_manage_settings')) {
666 - return MCPHelper::error(
667 - 'forbidden',
668 - __('You do not have permission to move this ticket to another mailbox.', 'fluent-support'),
669 - ['fields' => ['mailbox_id'], 'retryable' => false]
670 - );
671 - }
672 -
673 - if ($err = TicketAccessGuard::assertMailboxWritable($mid)) {
674 - return $err;
675 - }
676 - if (!\FluentSupport\App\Models\MailBox::find($mid)) {
677 - return MCPHelper::error('invalid_param', __('The specified mailbox does not exist', 'fluent-support'), ['fields' => ['mailbox_id'], 'next_step' => 'Use get-support-context to see available mailboxes and their IDs']);
678 - }
679 -
680 - // A ticket must not land in a mailbox its assignee is restricted
681 - // from. Skipped when agent_id is also set, since
682 - // resolveAssignmentTarget then checks the incoming agent instead.
683 - if ($ticket->agent_id && !isset($params['agent_id'])) {
684 - $assignedAgent = $ticket->agent;
685 - if ($assignedAgent && TicketAccessGuard::assertAssignableAgent($ticket, $assignedAgent, $mid)) {
686 - return MCPHelper::error(
687 - 'forbidden',
688 - __('The assigned agent is restricted from the selected mailbox. Reassign the ticket before moving it.', 'fluent-support'),
689 - ['fields' => ['mailbox_id'], 'next_step' => 'Pass agent_id in the same call to reassign, or assign the ticket to an unrestricted agent first']
690 - );
691 - }
692 - }
693 -
694 - $ticket->mailbox_id = $mid;
695 - $changed = true;
696 - continue;
697 - }
698 -
699 734 if ($field === 'product_id') {
700 735 $pid = (int) $params['product_id'];
701 736 if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) {
702 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']);
@@ -719,13 +754,18 @@
719 754 if (!in_array($value, $allowedStatuses, true)) {
720 755 return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]);
721 756 }
722 757 if ($ticket->status === 'closed') {
723 - return MCPHelper::error(
724 - 'ticket_closed',
725 - __('Cannot change the status of a closed ticket via update-ticket.', 'fluent-support'),
726 - ['next_step' => 'Use reopen-ticket to reopen the ticket first', 'retryable' => false]
727 - );
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;
728 768 }
729 769 }
730 770
731 771 $ticket->{$field} = $value;
@@ -731,28 +771,53 @@
731 771 $ticket->{$field} = $value;
732 772 $changed = true;
733 773 }
734 774
735 - $assignTarget = null;
736 - if (isset($params['agent_id'])) {
737 - $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $ticket, 'agent_id');
738 - if (is_wp_error($assignTarget)) {
739 - return $assignTarget;
740 - }
775 + if ($reopen) {
776 + // Saves the field changes above too, and fires the reopen hooks.
777 + (new TicketService())->reopen($ticket, $agent);
741 778 }
742 779
743 780 if ($assignTarget) {
744 - // Persists agent_id together with any scalar field changes above and
745 - // fires the assignment side effects when the assignee changes.
781 + // Also saves the field changes above.
746 782 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
747 783 } elseif ($changed) {
748 784 $ticket->save();
749 785 }
750 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 +
751 816 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
752 817
753 818 return MCPHelper::envelope(
754 - "Ticket #{$ticket->id} updated",
819 + $reopen ? "Ticket #{$ticket->id} reopened and updated" : "Ticket #{$ticket->id} updated",
755 820 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
756 821 );
757 822 }
758 823
@@ -883,9 +948,13 @@
883 948
884 949 if ($inaccessible) {
885 950 return MCPHelper::error(
886 951 'forbidden',
887 - sprintf(__('You do not have access to the following ticket(s): %s', 'fluent-support'), implode(', ', $inaccessible))
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 + )
888 957 );
889 958 }
890 959
891 960 $proService = new \FluentSupportPro\App\Services\ProTicketService();
@@ -901,7 +970,143 @@
901 970
902 971 return MCPHelper::envelope(
903 972 "{$mergeCount} ticket(s) merged into #{$targetId}",
904 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]
905 1110 );
906 1111 }
907 1112 }