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/CustomerTools.php +123 -13 2.4.0 → 2.4.5 View file →
@@ -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 }