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 / Services / ProfileInfoService.php

ProfileInfoService.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Services/ProfileInfoService.php

385 lines 13.8 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\Services;
4
5 use FluentSupport\App\Models\Activity;
6 use FluentSupport\App\Models\Customer;
7 use FluentSupport\App\Models\Ticket;
8
9 class ProfileInfoService
10 {
11 /**
12 * @param object|array $customer
13 * @param array $context See widgetContext().
14 * @return array
15 */
16 public static function getProfileExtraWidgets($customer, $context = [])
17 {
18 $widgets = [];
19 /*
20 * Filter customer profile widgets
21 *
22 * Integrations should build only what $context asks for:
23 * - format 'html' (agent UI): stats / sections (preferred) or body_html, no 'mcp' key.
24 * - format 'mcp' (AI agents via MCP): an 'mcp' array of plain data, no body_html.
25 * body_html still works; it is converted to Markdown.
26 * - keys: when set, only these widget keys are wanted. Skip the queries for any other.
27 * Use ProfileInfoService::wantsWidget() and isMcpWidgetRequest() to check.
28 *
29 * Structured widgets are drawn by the sidebar as badges, tiles, rows and lists,
30 * all as plain text. Every key is optional. Dates are ISO 8601 and shown
31 * relative ("9 months ago"); URLs must be absolute http(s):
32 *
33 * $widgets['my_shop'] = [
34 * 'title' => 'My Shop',
35 * 'icon' => 'ShoppingBag', // see WIDGET_ICONS in _StructuredWidget.vue
36 * 'badges' => [['text' => 'Subscribed', 'type' => 'success']], // success|warning|danger|info|primary
37 * 'stats' => [ // tiles, two per row
38 * ['label' => 'Lifetime Value', 'value' => '$748.00', 'url' => '...'],
39 * ['label' => 'Last Order', 'value' => '2026-01-09T08:30:00+00:00', 'type' => 'date'],
40 * ],
41 * 'facts' => [ // compact label / value rows
42 * ['label' => 'Location', 'value' => 'Berlin, Germany'],
43 * ],
44 * 'sections' => [
45 * ['label' => 'Recent Purchases', 'items' => [
46 * ['title' => 'Pro Plan', 'subtitle' => '5 Sites', 'detail' => '3 renewals', 'url' => '...',
47 * 'meta' => '2026-01-09T08:30:00+00:00', 'meta_type' => 'date',
48 * 'badge' => ['text' => 'active', 'type' => 'success']],
49 * ]],
50 * ['label' => 'Tags', 'tags' => ['VIP', 'Agency']],
51 * ],
52 * 'links' => [['label' => 'View in My Shop', 'url' => '...']],
53 * ];
54 *
55 * @since v1.0.0
56 * @since v2.4.1 $context, structured widgets
57 * @param array $widgets
58 * @param object|array $customer
59 * @param array $context ['format' => 'html'|'mcp', 'keys' => string[]|null]
60 *
61 * @return array
62 */
63 $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer, self::widgetContext($context));
64 return $widgets;
65 }
66
67 /**
68 * Normalized context for fluent_support/customer_extra_widgets.
69 *
70 * @param array $context
71 * @return array ['format' => 'html'|'mcp', 'keys' => string[]|null]
72 */
73 public static function widgetContext($context = [])
74 {
75 $format = isset($context['format']) && $context['format'] === 'mcp' ? 'mcp' : 'html';
76 $keys = isset($context['keys']) && is_array($context['keys'])
77 ? array_values(array_map('strval', $context['keys']))
78 : null;
79
80 return ['format' => $format, 'keys' => $keys];
81 }
82
83 /**
84 * Whether the caller wants this widget. Call it before running the widget's queries.
85 *
86 * @param array $context The filter's third argument.
87 * @param string $key Widget key, e.g. 'woo_purchases'.
88 * @return bool
89 */
90 public static function wantsWidget($context, $key)
91 {
92 return !is_array($context) || empty($context['keys']) || in_array((string) $key, $context['keys'], true);
93 }
94
95 /**
96 * Whether the caller wants structured 'mcp' data instead of body_html.
97 *
98 * @param array $context The filter's third argument.
99 * @return bool
100 */
101 public static function isMcpWidgetRequest($context)
102 {
103 return is_array($context) && isset($context['format']) && $context['format'] === 'mcp';
104 }
105
106 // Hooked to 'profile_update'.
107 public function onWPProfileUpdate($userId, $userOldData, $userUpdatedData)
108 {
109 if (!$userId || !is_array($userUpdatedData)) {
110 return false;
111 }
112
113 // Match on user_id only, so an account can only rewrite customer rows already linked to it.
114 $customers = Customer::where('user_id', $userId)->get();
115
116 if (!$customers || count($customers) === 0) {
117 return false;
118 }
119
120 $keys = ['first_name', 'last_name', 'user_email'];
121
122 if (array_diff_key(array_flip($keys), $userUpdatedData)) {
123 return false;
124 }
125
126 // profile_update passes wp_insert_user()'s slashed data (O\'Brien).
127 $userUpdatedData = wp_unslash($userUpdatedData);
128
129 $email = $userUpdatedData['user_email'];
130
131 // Only check authority when this update actually changed the account email.
132 // Otherwise an admin editing a name would carry over an unverified address
133 // the account already had. Core slashes the old email, so unslash it too.
134 $previousAccountEmail = is_object($userOldData) && isset($userOldData->user_email)
135 ? wp_unslash($userOldData->user_email)
136 : '';
137
138 // Unknown previous address: run the full check.
139 $accountEmailMoved = !$previousAccountEmail
140 || !self::isSameEmail($previousAccountEmail, $email);
141
142 // Only a proven change may move the email; names always sync.
143 $changeReason = $accountEmailMoved ? self::provenEmailChangeReason($userId, $email) : '';
144 $emailIsProven = (bool) $changeReason;
145
146 foreach ($customers as $customer) {
147 $data = [
148 'first_name' => $userUpdatedData['first_name'],
149 'last_name' => $userUpdatedData['last_name'],
150 ];
151
152 $previousEmail = $customer->email;
153
154 // Don't take an address another customer holds. Duplicates make
155 // email matching (e.g. mail piping) ambiguous.
156 $emailMoved = $emailIsProven
157 && !self::isSameEmail($previousEmail, $email)
158 && !self::isEmailHeldByAnotherCustomer($email, $customer->id);
159
160 if ($emailMoved) {
161 $data['email'] = $email;
162 }
163
164 $customer->fill($data);
165 $customer->save();
166
167 if ($emailMoved) {
168 self::onProvenEmailChange($customer, $previousEmail, $changeReason);
169 }
170 }
171
172 return true;
173 }
174
175 /**
176 * How the current email change was authorised: the owner confirmed it through
177 * WordPress's link, or someone who can edit the account made it. '' otherwise.
178 *
179 * @param int $userId
180 * @param string $newEmail
181 * @return string 'verified', 'administrator', or ''
182 */
183 public static function provenEmailChangeReason($userId, $newEmail)
184 {
185 if (self::isWpConfirmedEmailChange($userId, $newEmail)) {
186 return 'verified';
187 }
188
189 if (self::isAdminAuthorizedEmailChange($userId)) {
190 return 'administrator';
191 }
192
193 return '';
194 }
195
196 /**
197 * Whether someone else who can edit this WordPress account is changing its email.
198 * Editing your own account never counts.
199 *
200 * On WooCommerce, shop_manager has edit_user over 'customer' accounts, so it passes
201 * this without any Fluent Support permission. Requiring fst_sensitive_data would close that.
202 *
203 * @param int $userId
204 * @return bool
205 */
206 public static function isAdminAuthorizedEmailChange($userId)
207 {
208 $actorId = get_current_user_id();
209
210 if (!$actorId || $actorId === (int) $userId) {
211 return false;
212 }
213
214 return current_user_can('edit_user', (int) $userId);
215 }
216
217 /**
218 * Whether this email change came from WordPress's confirmation link.
219 *
220 * _new_email is still readable while profile_update fires, but the profile form
221 * writes it without any click. Only the matching hash in the request proves the
222 * link was opened.
223 *
224 * @param int $userId
225 * @param string $newEmail
226 * @return bool
227 */
228 public static function isWpConfirmedEmailChange($userId, $newEmail)
229 {
230 // Core only applies a confirmed change on the user's own profile page
231 // (IS_PROFILE_PAGE). This stops a replayed hash via REST, WP-CLI or another admin.
232 if (!defined('IS_PROFILE_PAGE') || !IS_PROFILE_PAGE) {
233 return false;
234 }
235
236 $pending = get_user_meta($userId, '_new_email', true);
237
238 if (!is_array($pending) || empty($pending['hash']) || empty($pending['newemail'])) {
239 return false;
240 }
241
242 if (!self::isSameEmail($pending['newemail'], $newEmail)) {
243 return false;
244 }
245
246 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this compares a secret delivered to the user's own inbox; it does not act on request state
247 $submitted = isset($_GET['newuseremail'])
248 ? sanitize_text_field(wp_unslash($_GET['newuseremail']))
249 : '';
250
251 return $submitted && hash_equals($pending['hash'], $submitted);
252 }
253
254 /**
255 * Side effects of a proven email change. Shared by every path that can move
256 * the address, including EmailClaimService.
257 *
258 * @param \FluentSupport\App\Models\Customer $customer
259 * @param string $previousEmail
260 * @param string $reason 'verified', 'administrator', 'claimed' or 'agent'
261 * @return void
262 */
263 public static function onProvenEmailChange($customer, $previousEmail, $reason = 'verified')
264 {
265 // Rotate hashes so signed links sent to the old inbox stop working.
266 $tickets = Ticket::where('customer_id', $customer->id)->get();
267
268 foreach ($tickets as $ticket) {
269 $ticket->hash = bin2hex(random_bytes(16));
270 $ticket->save();
271 }
272
273 Activity::create([
274 'event_type' => 'fluent_support/customer_email_changed',
275 'person_id' => $customer->id,
276 'person_type' => 'customer',
277 'object_id' => $customer->id,
278 'object_type' => 'customer',
279 'description' => sprintf(
280 self::emailChangeDescription($reason),
281 $previousEmail,
282 $customer->email
283 )
284 ]);
285
286 /*
287 * Fires after a customer's contact address moves to a newly proven one.
288 *
289 * @since v2.4.1
290 * @param \FluentSupport\App\Models\Customer $customer
291 * @param string $previousEmail
292 * @param string $reason
293 */
294 do_action('fluent_support/customer_email_verified_change', $customer, $previousEmail, $reason);
295 }
296
297 /**
298 * Activity log wording for each way an address can be proven.
299 *
300 * @param string $reason
301 * @return string A sprintf format taking the previous and new addresses
302 */
303 protected static function emailChangeDescription($reason)
304 {
305 if ($reason === 'administrator') {
306 // translators: 1: previous email address, 2: new email address
307 return __('Contact email changed from %1$s to %2$s by an administrator editing the WordPress account.', 'fluent-support');
308 }
309
310 if ($reason === 'agent') {
311 // translators: 1: previous email address, 2: new email address
312 return __('Contact email changed from %1$s to %2$s by a support agent editing the customer.', 'fluent-support');
313 }
314
315 if ($reason === 'claimed') {
316 // translators: 1: previous email address, 2: new email address
317 return __('Contact email changed from %1$s to %2$s, confirmed by the account holder from the support portal.', 'fluent-support');
318 }
319
320 // translators: 1: previous email address, 2: new email address
321 return __('Contact email changed from %1$s to %2$s, confirmed through WordPress email verification.', 'fluent-support');
322 }
323
324 /**
325 * @param string $left
326 * @param string $right
327 * @return bool
328 */
329 protected static function isSameEmail($left, $right)
330 {
331 return strtolower(trim((string) $left)) === strtolower(trim((string) $right));
332 }
333
334 /**
335 * Link unclaimed customer rows with this email to the new account.
336 *
337 * Safe at registration because WordPress sends the credentials to that inbox.
338 * A later email change has no such proof, so we don't link then.
339 *
340 * @param int $userId
341 * @return void
342 */
343 public function onWPUserRegister($userId)
344 {
345 $user = $userId ? get_user_by('ID', $userId) : false;
346
347 if (!$user || !$user->user_email) {
348 return;
349 }
350
351 /*
352 * Filter whether a newly registered WordPress account adopts unlinked
353 * customer rows that already carry its email address. Sites that let
354 * visitors register with a self-chosen password may want this off.
355 *
356 * @since v2.4.1
357 * @param bool $shouldLink
358 * @param \WP_User $user
359 */
360 if (!apply_filters('fluent_support/link_customer_on_user_register', true, $user)) {
361 return;
362 }
363
364 Customer::where('email', $user->user_email)
365 ->unclaimed()
366 ->update(['user_id' => $user->ID]);
367 }
368
369 /**
370 * @param string $email
371 * @param int $customerId
372 * @return bool
373 */
374 protected static function isEmailHeldByAnotherCustomer($email, $customerId)
375 {
376 if (!$email) {
377 return true;
378 }
379
380 return (bool) Customer::where('email', $email)
381 ->where('id', '!=', $customerId)
382 ->first();
383 }
384 }
385