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/Services/ProfileInfoService.php +372 -2 1.5.1 → 2.4.5 View file →
@@ -1,14 +1,384 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Services;
4 4
5 +use FluentSupport\App\Models\Activity;
6 +use FluentSupport\App\Models\Customer;
7 +use FluentSupport\App\Models\Ticket;
5 8
6 9 class ProfileInfoService
7 10 {
8 - public static function getProfileExtraWidgets($customer)
11 + /**
12 + * @param object|array $customer
13 + * @param array $context See widgetContext().
14 + * @return array
15 + */
16 + public static function getProfileExtraWidgets($customer, $context = [])
9 17 {
10 18 $widgets = [];
11 - $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer);
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));
12 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();
13 383 }
14 384 }