| @@ -1,105 +1,384 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentSupport\App\Services; |
| 4 | 4 | |
| 5 | +use FluentSupport\App\Models\Activity; | |
| 5 | 6 | use FluentSupport\App\Models\Customer; |
| 6 | -use FluentSupport\App\Models\Product; | |
| 7 | -use FluentSupport\App\Modules\PermissionManager; | |
| 8 | -use FluentSupport\App\Services\EmailNotification\Settings; | |
| 7 | +use FluentSupport\App\Models\Ticket; | |
| 9 | 8 | |
| 10 | 9 | class ProfileInfoService |
| 11 | 10 | { |
| 12 | - 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 = []) | |
| 13 | 17 | { |
| 14 | 18 | $widgets = []; |
| 15 | 19 | /* |
| 16 | 20 | * Filter customer profile widgets |
| 17 | 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 | + * | |
| 18 | 55 | * @since v1.0.0 |
| 56 | + * @since v2.4.1 $context, structured widgets | |
| 19 | 57 | * @param array $widgets |
| 20 | 58 | * @param object|array $customer |
| 59 | + * @param array $context ['format' => 'html'|'mcp', 'keys' => string[]|null] | |
| 21 | 60 | * |
| 22 | - * @return void | |
| 61 | + * @return array | |
| 23 | 62 | */ |
| 24 | - $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer); | |
| 63 | + $widgets = apply_filters('fluent_support/customer_extra_widgets', $widgets, $customer, self::widgetContext($context)); | |
| 25 | 64 | return $widgets; |
| 26 | 65 | } |
| 27 | 66 | |
| 28 | - // This method is linked with 'profile_update' action & it will trigger when user update profile | |
| 29 | - public function onWPProfileUpdate( $userId, $userOldData, $userUpdatedData ) | |
| 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 = []) | |
| 30 | 74 | { |
| 31 | - if ( !$userId ) { | |
| 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)) { | |
| 32 | 110 | return false; |
| 33 | 111 | } |
| 34 | 112 | |
| 35 | - $customer = Customer::where( 'user_id', $userId ); | |
| 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(); | |
| 36 | 115 | |
| 37 | - if ( $customer->count() == 0 ) { | |
| 116 | + if (!$customers || count($customers) === 0) { | |
| 38 | 117 | return false; |
| 39 | 118 | } |
| 40 | 119 | |
| 41 | 120 | $keys = ['first_name', 'last_name', 'user_email']; |
| 42 | - $data = []; | |
| 43 | 121 | |
| 44 | - if( !array_diff_key( array_flip($keys), $userUpdatedData ) ) { | |
| 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) { | |
| 45 | 147 | $data = [ |
| 46 | 148 | 'first_name' => $userUpdatedData['first_name'], |
| 47 | - 'last_name' => $userUpdatedData['last_name'], | |
| 48 | - 'email' => $userUpdatedData['user_email'], | |
| 149 | + 'last_name' => $userUpdatedData['last_name'], | |
| 49 | 150 | ]; |
| 50 | - }; | |
| 51 | 151 | |
| 52 | - if ( count($data) > 0 ) { | |
| 53 | - $customer->update($data); | |
| 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 | + } | |
| 54 | 170 | } |
| 55 | 171 | |
| 172 | + return true; | |
| 56 | 173 | } |
| 57 | 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 | + } | |
| 58 | 188 | |
| 189 | + if (self::isAdminAuthorizedEmailChange($userId)) { | |
| 190 | + return 'administrator'; | |
| 191 | + } | |
| 192 | + | |
| 193 | + return ''; | |
| 194 | + } | |
| 195 | + | |
| 59 | 196 | /** |
| 60 | - * This `me` method will return the current user profile info | |
| 61 | - * @param array $settings | |
| 62 | - * @param arrau withPortalSettings | |
| 63 | - * @return array $settings | |
| 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 | |
| 64 | 205 | */ |
| 65 | - public function me( $settings, $withPortalSettings ) | |
| 206 | + public static function isAdminAuthorizedEmailChange($userId) | |
| 66 | 207 | { |
| 67 | - if ( $withPortalSettings ) { | |
| 68 | - $this->portalSettings( $settings ); | |
| 208 | + $actorId = get_current_user_id(); | |
| 209 | + | |
| 210 | + if (!$actorId || $actorId === (int) $userId) { | |
| 211 | + return false; | |
| 69 | 212 | } |
| 70 | 213 | |
| 71 | - return $settings; | |
| 214 | + return current_user_can('edit_user', (int) $userId); | |
| 72 | 215 | } |
| 73 | 216 | |
| 74 | 217 | /** |
| 75 | - * This `portalSettings` method is supporting the `me` method | |
| 76 | - * @param array $settings | |
| 77 | - * @return array $settings | |
| 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 | |
| 78 | 227 | */ |
| 79 | - private function portalSettings ( $settings ) | |
| 228 | + public static function isWpConfirmedEmailChange($userId, $newEmail) | |
| 80 | 229 | { |
| 81 | - $mimeHeadings = Helper::getAcceptedMimeHeadings(); | |
| 82 | - $businessSettings = (new Settings())->globalBusinessSettings(); | |
| 83 | - $maxFileSize = absint($businessSettings['max_file_size']); | |
| 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 | + } | |
| 84 | 235 | |
| 85 | - $portalSettings = [ | |
| 86 | - 'support_products' => Product::select(['id', 'title'])->get(), | |
| 87 | - 'customer_ticket_priorities' => Helper::customerTicketPriorities(), | |
| 88 | - 'has_file_upload' => !!Helper::ticketAcceptedFileMiles(), | |
| 89 | - 'has_rich_text_editor' => true, | |
| 90 | - 'max_file_size' => $maxFileSize, | |
| 91 | - 'mime_headings' => $mimeHeadings | |
| 92 | - ]; | |
| 93 | - /** | |
| 94 | - * Filter customer portal settings | |
| 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. | |
| 95 | 288 | * |
| 96 | - * @since v1.0.0 | |
| 97 | - * @param array $portalSettings | |
| 289 | + * @since v2.4.1 | |
| 290 | + * @param \FluentSupport\App\Models\Customer $customer | |
| 291 | + * @param string $previousEmail | |
| 292 | + * @param string $reason | |
| 98 | 293 | */ |
| 99 | - $portalSettings = apply_filters('fluent_support/customer_portal_vars', $portalSettings); | |
| 294 | + do_action('fluent_support/customer_email_verified_change', $customer, $previousEmail, $reason); | |
| 295 | + } | |
| 100 | 296 | |
| 101 | - $settings['portal_settings'] = $portalSettings; | |
| 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 | + } | |
| 102 | 309 | |
| 103 | - return $settings; | |
| 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(); | |
| 104 | 383 | } |
| 105 | 384 | } |