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
fluent-support / app / Modules / MCP / Tools / CustomerTools.php

CustomerTools.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Modules/MCP/Tools/CustomerTools.php

271 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\MCP\Tools;
4
5 use FluentSupport\App\Models\Customer;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Modules\MCP\Helpers\MCPHelper;
8 use FluentSupport\App\Modules\PermissionManager;
9 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
10
11 class CustomerTools
12 {
13 public static function searchCustomers($params)
14 {
15 $agent = MCPHelper::resolveAgent();
16 if (!$agent) {
17 return MCPHelper::error('unauthorized', __('No agent found for current user', 'fluent-support'));
18 }
19
20 $search = sanitize_text_field($params['search'] ?? '');
21
22 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
23
24 $restricted = PermissionManager::getRestrictedMailboxIds();
25
26 $query = Customer::select([
27 'id', 'first_name', 'last_name', 'email', 'status', 'city', 'state', 'country', 'created_at',
28 ]);
29
30 if ($search) {
31 $query->where(function ($q) use ($search) {
32 $q->where('first_name', 'LIKE', "%{$search}%")
33 ->orWhere('last_name', 'LIKE', "%{$search}%")
34 ->orWhere('email', 'LIKE', "%{$search}%")
35 ->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$search}%"]);
36 });
37 }
38
39 // Exclude customers who only have tickets in restricted mailboxes.
40 if ($restricted) {
41 $query->where(function ($q) {
42 $q->whereDoesntHave('tickets')
43 ->orWhereHas('tickets', function ($tq) {
44 (new AgentTicketAccess())->applyMailboxRestrictionScope($tq);
45 });
46 });
47 }
48
49 $paginated = $query->orderBy('id', 'DESC')->paginate($perPage, ['*'], 'page', $page);
50
51 $pageItems = $paginated->items();
52 $customerIds = array_map(fn($c) => $c->id, $pageItems);
53
54 $ticketCounts = [];
55 if ($customerIds) {
56 // Count only tickets this agent could open directly.
57 $countQuery = (new AgentTicketAccess())
58 ->applyAccessScope(Ticket::whereIn('customer_id', $customerIds), $agent);
59 $rows = $countQuery
60 ->selectRaw('customer_id, COUNT(*) as total_tickets, SUM(CASE WHEN status != \'closed\' THEN 1 ELSE 0 END) as open_tickets')
61 ->groupBy('customer_id')
62 ->get()
63 ->keyBy('customer_id');
64
65 foreach ($rows as $cid => $row) {
66 $ticketCounts[$cid] = [
67 'total_tickets' => (int) $row->total_tickets,
68 'open_tickets' => (int) $row->open_tickets,
69 ];
70 }
71 }
72
73 $customers = [];
74 foreach ($pageItems as $customer) {
75 $item = MCPHelper::formatCustomerForMCP($customer);
76 $item['total_tickets'] = $ticketCounts[$customer->id]['total_tickets'] ?? 0;
77 $item['open_tickets'] = $ticketCounts[$customer->id]['open_tickets'] ?? 0;
78 $customers[] = $item;
79 }
80
81 $total = $paginated->total();
82 $summary = $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 */
86 : sprintf(_n('Found %d customer', 'Found %d customers', $total, 'fluent-support'), $total);
87
88 return MCPHelper::envelope($summary, ['customers' => $customers], MCPHelper::pagingMeta($paginated));
89 }
90
91 public static function getCustomerTickets($params)
92 {
93 $customerId = (int) ($params['customer_id'] ?? 0);
94 $email = sanitize_email($params['email'] ?? '');
95 $name = sanitize_text_field($params['name'] ?? '');
96
97 $customer = null;
98
99 if ($customerId) {
100 $customer = Customer::find($customerId);
101 } elseif ($email) {
102 $customer = Customer::where('email', $email)->first();
103 } elseif ($name) {
104 $customer = Customer::where(function ($q) use ($name) {
105 $q->where('first_name', 'LIKE', '%' . $name . '%')
106 ->orWhere('last_name', 'LIKE', '%' . $name . '%')
107 ->orWhere('email', 'LIKE', '%' . $name . '%');
108 })->first();
109 }
110
111 if (!$customer) {
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']);
113 }
114
115 if (!self::canSeeCustomer($customer)) {
116 return MCPHelper::error('forbidden', __('You do not have access to this customer', 'fluent-support'));
117 }
118
119 ['page' => $page, 'per_page' => $perPage] = MCPHelper::pagination($params);
120
121 $query = Ticket::with([
122 'agent' => function ($q) {
123 $q->select(['id', 'first_name', 'last_name', 'email']);
124 },
125 ])->where('customer_id', $customer->id);
126
127 if (!empty($params['status'])) {
128 $statuses = \FluentSupport\App\Services\Helper::getTkStatusesByGroupName($params['status']);
129 if ($statuses) {
130 $query->whereIn('status', $statuses);
131 }
132 }
133
134 do_action_ref_array('fluent_support/tickets_query_by_permission_ref', [&$query]);
135
136 (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
137
138 $paginated = $query->orderBy('id', 'DESC')->paginate($perPage, ['*'], 'page', $page);
139
140 $tickets = [];
141 foreach ($paginated->items() as $ticket) {
142 $tickets[] = [
143 'id' => $ticket->id,
144 'title' => $ticket->title,
145 'status' => $ticket->status,
146 'priority' => MCPHelper::normalizePriority($ticket->priority),
147 'response_count' => (int) $ticket->response_count,
148 'waiting_on' => MCPHelper::waitingOn($ticket),
149 'created_at' => MCPHelper::toIso8601($ticket->created_at),
150 'agent' => $ticket->agent ? MCPHelper::formatPersonSummary($ticket->agent) : null,
151 ];
152 }
153
154 $total = $paginated->total();
155 $identifier = $customer->email ?: MCPHelper::personName($customer);
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);
158
159 return MCPHelper::envelope(
160 $summary,
161 ['customer' => MCPHelper::formatCustomerForMCP($customer), 'tickets' => $tickets],
162 MCPHelper::pagingMeta($paginated)
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();
269 }
270 }
271