| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | use FluentSupport\App\Models\Customer; |
| 6 | 6 | use FluentSupport\App\Models\Ticket; |
| 7 | 7 | use FluentSupport\App\Modules\MCP\Helpers\MCPHelper; |
| 8 | 8 | use FluentSupport\App\Modules\PermissionManager; |
| 9 | +use FluentSupport\App\Services\Tickets\AgentTicketAccess; | |
| 9 | 10 | |
| 10 | 11 | class CustomerTools |
| 11 | 12 | { |
| 12 | 13 | public static function searchCustomers($params) |
| @@ -36,12 +37,12 @@ | ||
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | // Exclude customers who only have tickets in restricted mailboxes. |
| 39 | 40 | if ($restricted) { |
| 40 | - $query->where(function ($q) use ($restricted) { | |
| 41 | + $query->where(function ($q) { | |
| 41 | 42 | $q->whereDoesntHave('tickets') |
| 42 | - ->orWhereHas('tickets', function ($tq) use ($restricted) { | |
| 43 | - $tq->whereNotIn('mailbox_id', $restricted); | |
| 43 | + ->orWhereHas('tickets', function ($tq) { | |
| 44 | + (new AgentTicketAccess())->applyMailboxRestrictionScope($tq); | |
| 44 | 45 | }); |
| 45 | 46 | }); |
| 46 | 47 | } |
| 47 | 48 | |
| @@ -51,12 +52,11 @@ | ||
| 51 | 52 | $customerIds = array_map(fn($c) => $c->id, $pageItems); |
| 52 | 53 | |
| 53 | 54 | $ticketCounts = []; |
| 54 | 55 | if ($customerIds) { |
| 55 | - $countQuery = Ticket::whereIn('customer_id', $customerIds); | |
| 56 | - if ($restricted) { | |
| 57 | - $countQuery->whereNotIn('mailbox_id', $restricted); | |
| 58 | - } | |
| 56 | + // Count only tickets this agent could open directly. | |
| 57 | + $countQuery = (new AgentTicketAccess()) | |
| 58 | + ->applyAccessScope(Ticket::whereIn('customer_id', $customerIds), $agent); | |
| 59 | 59 | $rows = $countQuery |
| 60 | 60 | ->selectRaw('customer_id, COUNT(*) as total_tickets, SUM(CASE WHEN status != \'closed\' THEN 1 ELSE 0 END) as open_tickets') |
| 61 | 61 | ->groupBy('customer_id') |
| 62 | 62 | ->get() |
| @@ -79,9 +79,11 @@ | ||
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | $total = $paginated->total(); |
| 82 | 82 | $summary = $search |
| 83 | - ? sprintf(_n("Found %d customer matching '%s'", "Found %d customers matching '%s'", $total, 'fluent-support'), $total, $search) | |
| 83 | + /* translators: %1$d is the number of customers found, %2$s is the search term */ | |
| 84 | + ? sprintf(_n('Found %1$d customer matching \'%2$s\'', 'Found %1$d customers matching \'%2$s\'', $total, 'fluent-support'), $total, $search) | |
| 85 | + /* translators: %d is the number of customers found */ | |
| 84 | 86 | : sprintf(_n('Found %d customer', 'Found %d customers', $total, 'fluent-support'), $total); |
| 85 | 87 | |
| 86 | 88 | return MCPHelper::envelope($summary, ['customers' => $customers], MCPHelper::pagingMeta($paginated)); |
| 87 | 89 | } |
| @@ -109,8 +111,12 @@ | ||
| 109 | 111 | if (!$customer) { |
| 110 | 112 | return MCPHelper::error('not_found', __('Customer not found. Provide customer_id, email, or name.', 'fluent-support'), ['next_step' => 'Use search-customers to find the customer first']); |
| 111 | 113 | } |
| 112 | 114 | |
| 115 | + if (!self::canSeeCustomer($customer)) { | |
| 116 | + return MCPHelper::error('forbidden', __('You do not have access to this customer', 'fluent-support')); | |
| 117 | + } | |
| 118 | + | |
| 113 | 119 | ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params); |
| 114 | 120 | |
| 115 | 121 | $query = Ticket::with([ |
| 116 | 122 | 'agent' => function ($q) { |
| @@ -126,12 +132,9 @@ | ||
| 126 | 132 | } |
| 127 | 133 | |
| 128 | 134 | do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]); |
| 129 | 135 | |
| 130 | - $restricted = PermissionManager::getRestrictedMailboxIds(); | |
| 131 | - if ($restricted) { | |
| 132 | - $query->whereNotIn('mailbox_id', $restricted); | |
| 133 | - } | |
| 136 | + (new AgentTicketAccess())->applyMailboxRestrictionScope($query); | |
| 134 | 137 | |
| 135 | 138 | $paginated = $query->orderBy('id', 'DESC')->paginate($perPage, ['*'], 'page', $page); |
| 136 | 139 | |
| 137 | 140 | $tickets = []; |
| @@ -141,8 +144,9 @@ | ||
| 141 | 144 | 'title' => $ticket->title, |
| 142 | 145 | 'status' => $ticket->status, |
| 143 | 146 | 'priority' => MCPHelper::normalizePriority($ticket->priority), |
| 144 | 147 | 'response_count' => (int) $ticket->response_count, |
| 148 | + 'waiting_on' => MCPHelper::waitingOn($ticket), | |
| 145 | 149 | 'created_at' => MCPHelper::toIso8601($ticket->created_at), |
| 146 | 150 | 'agent' => $ticket->agent ? MCPHelper::formatPersonSummary($ticket->agent) : null, |
| 147 | 151 | ]; |
| 148 | 152 | } |
| @@ -148,9 +152,10 @@ | ||
| 148 | 152 | } |
| 149 | 153 | |
| 150 | 154 | $total = $paginated->total(); |
| 151 | 155 | $identifier = $customer->email ?: MCPHelper::personName($customer); |
| 152 | - $summary = sprintf(_n('Found %d ticket for %s', 'Found %d tickets for %s', $total, 'fluent-support'), $total, $identifier); | |
| 156 | + /* translators: %1$d is the number of tickets found, %2$s is the customer email or name */ | |
| 157 | + $summary = sprintf(_n('Found %1$d ticket for %2$s', 'Found %1$d tickets for %2$s', $total, 'fluent-support'), $total, $identifier); | |
| 153 | 158 | |
| 154 | 159 | return MCPHelper::envelope( |
| 155 | 160 | $summary, |
| 156 | 161 | ['customer' => MCPHelper::formatCustomerForMCP($customer), 'tickets' => $tickets], |
| @@ -155,6 +160,111 @@ | ||
| 155 | 160 | $summary, |
| 156 | 161 | ['customer' => MCPHelper::formatCustomerForMCP($customer), 'tickets' => $tickets], |
| 157 | 162 | MCPHelper::pagingMeta($paginated) |
| 158 | 163 | ); |
| 164 | + } | |
| 165 | + | |
| 166 | + /** Fields update-customer may change, with their sanitizers. */ | |
| 167 | + const UPDATABLE_FIELDS = [ | |
| 168 | + 'first_name' => 'sanitize_text_field', | |
| 169 | + 'last_name' => 'sanitize_text_field', | |
| 170 | + 'title' => 'sanitize_text_field', | |
| 171 | + 'note' => 'sanitize_textarea_field', | |
| 172 | + 'address_line_1' => 'sanitize_textarea_field', | |
| 173 | + 'address_line_2' => 'sanitize_textarea_field', | |
| 174 | + 'city' => 'sanitize_text_field', | |
| 175 | + 'state' => 'sanitize_text_field', | |
| 176 | + 'zip' => 'sanitize_text_field', | |
| 177 | + 'country' => 'sanitize_text_field', | |
| 178 | + ]; | |
| 179 | + | |
| 180 | + /** | |
| 181 | + * Change a customer's profile: name, title, note and address. Only the fields | |
| 182 | + * passed change. Agents limited to some mailboxes can only update customers they | |
| 183 | + * could find with search-customers. | |
| 184 | + * | |
| 185 | + * The email address is left to people: a message asking to send a customer's | |
| 186 | + * email somewhere else is a common account-takeover trick, and the address also | |
| 187 | + * decides who can open the customer's tickets through signed links. | |
| 188 | + */ | |
| 189 | + public static function updateCustomer($params) | |
| 190 | + { | |
| 191 | + $agent = MCPHelper::resolveAgent(); | |
| 192 | + if (!$agent) { | |
| 193 | + return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support')); | |
| 194 | + } | |
| 195 | + | |
| 196 | + $customerId = (int) ($params['customer_id'] ?? 0); | |
| 197 | + $customer = $customerId ? Customer::find($customerId) : null; | |
| 198 | + if (!$customer) { | |
| 199 | + return MCPHelper::error('not_found', __('Customer not found', 'fluent-support'), ['fields' => ['customer_id'], 'next_step' => 'Use search-customers to find the customer ID']); | |
| 200 | + } | |
| 201 | + | |
| 202 | + if (!self::canSeeCustomer($customer)) { | |
| 203 | + return MCPHelper::error('forbidden', __('You do not have access to this customer', 'fluent-support')); | |
| 204 | + } | |
| 205 | + | |
| 206 | + if (array_key_exists('email', $params) && $params['email'] !== null && $params['email'] !== '') { | |
| 207 | + return MCPHelper::error( | |
| 208 | + 'not_allowed', | |
| 209 | + __('A customer\'s email address cannot be changed through MCP.', 'fluent-support'), | |
| 210 | + ['fields' => ['email'], 'next_step' => 'Ask a person on your team to change it in the Fluent Support admin, and add an internal note on the ticket with the request.'] | |
| 211 | + ); | |
| 212 | + } | |
| 213 | + | |
| 214 | + $changes = []; | |
| 215 | + foreach (self::UPDATABLE_FIELDS as $field => $sanitize) { | |
| 216 | + if (array_key_exists($field, $params) && $params[$field] !== null) { | |
| 217 | + $changes[$field] = call_user_func($sanitize, (string) $params[$field]); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + if (!$changes) { | |
| 222 | + return MCPHelper::error('invalid_param', __('Pass at least one field to change', 'fluent-support'), ['fields' => array_keys(self::UPDATABLE_FIELDS)]); | |
| 223 | + } | |
| 224 | + | |
| 225 | + if (array_key_exists('first_name', $changes) && $changes['first_name'] === '') { | |
| 226 | + return MCPHelper::error('invalid_param', __('first_name cannot be empty', 'fluent-support'), ['fields' => ['first_name']]); | |
| 227 | + } | |
| 228 | + | |
| 229 | + // updateCustomer() needs the email and first name, as the admin form always sends both. | |
| 230 | + $data = array_merge([ | |
| 231 | + 'email' => (string) $customer->email, | |
| 232 | + 'first_name' => (string) $customer->first_name, | |
| 233 | + ], $changes); | |
| 234 | + | |
| 235 | + try { | |
| 236 | + $updated = $customer->updateCustomer($customer->id, $data); | |
| 237 | + } catch (\Exception $e) { | |
| 238 | + return MCPHelper::error('failed', __('Failed to update the customer', 'fluent-support'), ['retryable' => true]); | |
| 239 | + } | |
| 240 | + | |
| 241 | + $item = MCPHelper::formatCustomerForMCP($updated); | |
| 242 | + foreach (['title', 'note', 'address_line_1', 'address_line_2', 'zip'] as $field) { | |
| 243 | + $item[$field] = $updated->{$field}; | |
| 244 | + } | |
| 245 | + | |
| 246 | + return MCPHelper::envelope( | |
| 247 | + /* translators: %d is the customer ID */ | |
| 248 | + sprintf(__('Customer #%d updated', 'fluent-support'), $updated->id), | |
| 249 | + ['customer' => $item, 'changed' => array_keys($changes)] | |
| 250 | + ); | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 254 | + * The search-customers rule: an agent limited to some mailboxes sees a customer | |
| 255 | + * with no tickets, or with at least one ticket in a mailbox they can access. | |
| 256 | + */ | |
| 257 | + private static function canSeeCustomer(Customer $customer) | |
| 258 | + { | |
| 259 | + if (!PermissionManager::getRestrictedMailboxIds()) { | |
| 260 | + return true; | |
| 261 | + } | |
| 262 | + | |
| 263 | + $tickets = Ticket::where('customer_id', $customer->id); | |
| 264 | + if (!(clone $tickets)->exists()) { | |
| 265 | + return true; | |
| 266 | + } | |
| 267 | + | |
| 268 | + return (new AgentTicketAccess())->applyMailboxRestrictionScope($tickets)->exists(); | |
| 159 | 269 | } |
| 160 | 270 | } |