| @@ -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 | } |