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 +376 -136 2.3.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,12 +614,13 @@
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 - // Reply + close must be atomic. Without a transaction, a failure in
567 - // close() after the reply was created would leave the reply visible on
568 - // a still-open ticket.
569 - (new Ticket())->getConnection()->transaction(function () use ($replyContent, $internalNote, $agent, $ticket) {
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) {
570 623 if ($replyContent) {
571 624 $data = [
572 625 'content' => $replyContent,
573 626 'conversation_type' => 'response',
@@ -575,9 +628,9 @@
575 628 ];
576 629 (new ResponseService())->createResponse($data, $agent, $ticket);
577 630 }
578 631
579 - (new TicketService())->close($ticket, $agent, $internalNote);
632 + (new TicketService())->close($ticket, $agent, $internalNote, $silently);
580 633 });
581 634
582 635 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
583 636
@@ -584,12 +637,16 @@
584 637 $summary = $replyContent
585 638 ? "Reply sent and ticket #{$ticket->id} closed"
586 639 : "Ticket #{$ticket->id} closed";
587 640
641 + if ($silently === 'yes') {
642 + $summary .= ' silently (no close notification or automations fired)';
643 + }
644 +
588 645 return MCPHelper::envelope($summary, ['ticket' => MCPHelper::formatTicketForMCP($ticket)]);
589 646 }
590 647
591 - public static function reopenTicket($params)
648 + public static function updateTicket($params)
592 649 {
593 650 $agent = MCPHelper::resolveAgent();
594 651 if (!$agent) {
595 652 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
@@ -608,60 +665,73 @@
608 665 if ($err = TicketAccessGuard::assert($ticket)) {
609 666 return $err;
610 667 }
611 668
612 - (new TicketService())->reopen($ticket, $agent);
613 - $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
669 + $intFields = ['product_id'];
670 + $updatable = ['title', 'priority', 'status', 'product_id'];
671 + $reopen = false;
672 + $changed = false;
614 673
615 - return MCPHelper::envelope(
616 - "Ticket #{$ticket->id} reopened",
617 - ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
618 - );
619 - }
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'];
620 679
621 - public static function updateTicket($params)
622 - {
623 - $agent = MCPHelper::resolveAgent();
624 - if (!$agent) {
625 - return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
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 + }
626 696 }
627 697
628 - $ticketId = (int) ($params['ticket_id'] ?? 0);
629 - if (!$ticketId) {
630 - return MCPHelper::error('invalid_param', __('ticket_id is required', 'fluent-support'), ['fields' => ['ticket_id']]);
631 - }
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 + }
632 708
633 - $ticket = Ticket::find($ticketId);
634 - if (!$ticket) {
635 - return MCPHelper::error('not_found', __('Ticket not found', 'fluent-support'), ['next_step' => 'Use list-tickets to find valid ticket IDs']);
709 + $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $probeTicket, 'agent_id');
710 + if (is_wp_error($assignTarget)) {
711 + return $assignTarget;
712 + }
636 713 }
637 714
638 - if ($err = TicketAccessGuard::assert($ticket)) {
639 - 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 + }
640 727 }
641 728
642 - $intFields = ['product_id', 'mailbox_id'];
643 - $updatable = ['title', 'priority', 'status', 'product_id', 'mailbox_id'];
644 - $changed = false;
645 -
646 729 foreach ($updatable as $field) {
647 730 if (!isset($params[$field])) {
648 731 continue;
649 732 }
650 733
651 - if ($field === 'mailbox_id') {
652 - $mid = (int) $params['mailbox_id'];
653 - if ($err = TicketAccessGuard::assertMailboxWritable($mid)) {
654 - return $err;
655 - }
656 - if (!\FluentSupport\App\Models\MailBox::find($mid)) {
657 - return MCPHelper::error('invalid_param', __('The specified mailbox does not exist', 'fluent-support'), ['fields' => ['mailbox_id'], 'next_step' => 'Use get-support-context to see available mailboxes and their IDs']);
658 - }
659 - $ticket->mailbox_id = $mid;
660 - $changed = true;
661 - continue;
662 - }
663 -
664 734 if ($field === 'product_id') {
665 735 $pid = (int) $params['product_id'];
666 736 if ($pid > 0 && !\FluentSupport\App\Models\Product::find($pid)) {
667 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']);
@@ -684,13 +754,18 @@
684 754 if (!in_array($value, $allowedStatuses, true)) {
685 755 return MCPHelper::error('invalid_param', sprintf(__("Invalid status '%s'", 'fluent-support'), $value), ['fields' => ['status'], 'allowed' => $allowedStatuses]);
686 756 }
687 757 if ($ticket->status === 'closed') {
688 - return MCPHelper::error(
689 - 'ticket_closed',
690 - __('Cannot change the status of a closed ticket via update-ticket.', 'fluent-support'),
691 - ['next_step' => 'Use reopen-ticket to reopen the ticket first', 'retryable' => false]
692 - );
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;
693 768 }
694 769 }
695 770
696 771 $ticket->{$field} = $value;
@@ -696,28 +771,53 @@
696 771 $ticket->{$field} = $value;
697 772 $changed = true;
698 773 }
699 774
700 - $assignTarget = null;
701 - if (isset($params['agent_id'])) {
702 - $assignTarget = MCPHelper::resolveAssignmentTarget($params['agent_id'], $ticket, 'agent_id');
703 - if (is_wp_error($assignTarget)) {
704 - return $assignTarget;
705 - }
775 + if ($reopen) {
776 + // Saves the field changes above too, and fires the reopen hooks.
777 + (new TicketService())->reopen($ticket, $agent);
706 778 }
707 779
708 780 if ($assignTarget) {
709 - // Persists agent_id together with any scalar field changes above and
710 - // fires the assignment side effects when the assignee changes.
781 + // Also saves the field changes above.
711 782 MCPHelper::applyAgentAssignment($ticket, $assignTarget, $agent);
712 783 } elseif ($changed) {
713 784 $ticket->save();
714 785 }
715 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 +
716 816 $ticket->load(['customer', 'agent', 'product', 'mailbox', 'tags']);
717 817
718 818 return MCPHelper::envelope(
719 - "Ticket #{$ticket->id} updated",
819 + $reopen ? "Ticket #{$ticket->id} reopened and updated" : "Ticket #{$ticket->id} updated",
720 820 ['ticket' => MCPHelper::formatTicketForMCP($ticket)]
721 821 );
722 822 }
723 823
@@ -848,9 +948,13 @@
848 948
849 949 if ($inaccessible) {
850 950 return MCPHelper::error(
851 951 'forbidden',
852 - 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 + )
853 957 );
854 958 }
855 959
856 960 $proService = new \FluentSupportPro\App\Services\ProTicketService();
@@ -866,7 +970,143 @@
866 970
867 971 return MCPHelper::envelope(
868 972 "{$mergeCount} ticket(s) merged into #{$targetId}",
869 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]
870 1110 );
871 1111 }
872 1112 }