| @@ -3,11 +3,14 @@ | ||
| 3 | 3 | namespace FluentSupport\App\Models; |
| 4 | 4 | |
| 5 | 5 | use FluentSupport\Framework\Database\Orm\Builder; |
| 6 | 6 | use FluentSupport\Framework\Support\Arr; |
| 7 | +use FluentSupport\App\Models\Traits\CustomerTrait; | |
| 7 | 8 | |
| 8 | 9 | class Customer extends Person |
| 9 | 10 | { |
| 11 | + use CustomerTrait; | |
| 12 | + | |
| 10 | 13 | protected static $type = 'customer'; |
| 11 | 14 | |
| 12 | 15 | protected $searchable = [ |
| 13 | 16 | 'id', |
| @@ -18,8 +21,16 @@ | ||
| 18 | 21 | 'address_line_2', |
| 19 | 22 | 'country' |
| 20 | 23 | ]; |
| 21 | 24 | |
| 25 | + /** | |
| 26 | + * @return array | |
| 27 | + */ | |
| 28 | + public function getSearchableFields() | |
| 29 | + { | |
| 30 | + return $this->searchable; | |
| 31 | + } | |
| 32 | + | |
| 22 | 33 | public static function boot() |
| 23 | 34 | { |
| 24 | 35 | parent::boot(); |
| 25 | 36 | |
| @@ -24,9 +35,9 @@ | ||
| 24 | 35 | parent::boot(); |
| 25 | 36 | |
| 26 | 37 | static::creating(function ($model) { |
| 27 | 38 | $model->person_type = static::$type; |
| 28 | - $model->hash = md5(time().wp_generate_uuid4()); | |
| 39 | + $model->hash = md5(time() . wp_generate_uuid4()); | |
| 29 | 40 | }); |
| 30 | 41 | |
| 31 | 42 | static::addGlobalScope(function (Builder $builder) { |
| 32 | 43 | $builder->where('person_type', 'customer'); |
| @@ -55,26 +66,30 @@ | ||
| 55 | 66 | */ |
| 56 | 67 | public static function maybeCreateCustomer($customerData) |
| 57 | 68 | { |
| 58 | 69 | $customer = self::getCustomerFromData($customerData); |
| 59 | - $user = get_user_by('email', $customerData['email']); | |
| 60 | - if($user) { | |
| 61 | - if($user->first_name) { | |
| 70 | + $email = Arr::get($customerData, 'email'); | |
| 71 | + $user = $email ? get_user_by('email', $email) : false; | |
| 72 | + if ($user) { | |
| 73 | + if ($user->first_name) { | |
| 62 | 74 | $customerData['first_name'] = $user->first_name; |
| 63 | 75 | } |
| 64 | - if($user->last_name) { | |
| 76 | + if ($user->last_name) { | |
| 65 | 77 | $customerData['last_name'] = $user->last_name; |
| 66 | 78 | } |
| 67 | - if(empty($customerData['first_name']) && empty($customerData['last_name'])) { | |
| 79 | + if (empty($customerData['first_name']) && empty($customerData['last_name'])) { | |
| 68 | 80 | $customerData['first_name'] = $user->display_name; |
| 69 | 81 | } |
| 70 | 82 | $customerData['user_id'] = $user->ID; |
| 71 | 83 | } |
| 72 | 84 | |
| 73 | - if(!$customer) { | |
| 74 | - if(!empty($customerData['last_ip_address'])) { | |
| 85 | + if (!$customer) { | |
| 86 | + if (!empty($customerData['last_ip_address'])) { | |
| 75 | 87 | $customerData['ip_address'] = $customerData['last_ip_address']; |
| 76 | 88 | } |
| 89 | + | |
| 90 | + $customerData = self::explodeFullName($customerData); | |
| 91 | + | |
| 77 | 92 | // we have to create customer |
| 78 | 93 | $customer = self::create($customerData); |
| 79 | 94 | |
| 80 | 95 | /* |
| @@ -84,16 +99,49 @@ | ||
| 84 | 99 | * @param object $customer |
| 85 | 100 | */ |
| 86 | 101 | do_action('fluent_support/customer_created', $customer); |
| 87 | 102 | |
| 88 | - } else { | |
| 89 | - if(!empty($customerData['user_id']) || !empty($customerData['remote_uid'])) { | |
| 90 | - $customerData = array_filter($customerData); | |
| 91 | - $customer->fill($customerData); | |
| 92 | - $customer->save(); | |
| 93 | - } | |
| 103 | + return $customer; | |
| 94 | 104 | } |
| 95 | 105 | |
| 106 | + // An existing row may never be claimed for a WordPress account it is not | |
| 107 | + // already linked to. Binding happens when the row is first created, when | |
| 108 | + // the account registers (ProfileInfoService::onWPUserRegister), or when | |
| 109 | + // an agent sets it explicitly from the customer profile. Allowing an | |
| 110 | + // email match to write user_id here is what made a portal takeover | |
| 111 | + // persistent. | |
| 112 | + $incomingUserId = (int) Arr::get($customerData, 'user_id'); | |
| 113 | + | |
| 114 | + if ($incomingUserId && $incomingUserId !== (int) $customer->user_id) { | |
| 115 | + unset($customerData['user_id']); | |
| 116 | + } | |
| 117 | + | |
| 118 | + // Intake may not move the contact address of a record that belongs to a | |
| 119 | + // WordPress account. Every caller here builds its payload from an | |
| 120 | + // address it has not verified -- most of them from the signed-in | |
| 121 | + // account's own email, which WordPress lets that account change with no | |
| 122 | + // confirmation at all. | |
| 123 | + // | |
| 124 | + // Without this, ProfileInfoService::onWPProfileUpdate holding the | |
| 125 | + // address is worth nothing: it refuses the unverified change, and then | |
| 126 | + // the next POST to /customer-portal/tickets resolves the same record | |
| 127 | + // through here and writes the new address anyway. It also produced | |
| 128 | + // duplicate rows on an address belonging to somebody else, without the | |
| 129 | + // collision check the profile_update path applies. | |
| 130 | + // | |
| 131 | + // The address moves through the two paths that prove something instead: | |
| 132 | + // EmailClaimService, where the customer opens a link sent to the address | |
| 133 | + // being claimed, and an agent editing the record directly. | |
| 134 | + if ((int) $customer->user_id) { | |
| 135 | + unset($customerData['email']); | |
| 136 | + } | |
| 137 | + | |
| 138 | + if (!empty($customerData['user_id']) || !empty($customerData['remote_uid'])) { | |
| 139 | + $customerData = array_filter($customerData); | |
| 140 | + $customer->fill($customerData); | |
| 141 | + $customer->save(); | |
| 142 | + } | |
| 143 | + | |
| 96 | 144 | return $customer; |
| 97 | 145 | } |
| 98 | 146 | |
| 99 | 147 | |
| @@ -119,25 +167,38 @@ | ||
| 119 | 167 | */ |
| 120 | 168 | public static function getCustomerFromData($customerData) |
| 121 | 169 | { |
| 122 | 170 | $remoteUid = Arr::get($customerData, 'remote_uid'); |
| 123 | - $email = $customerData['email']; | |
| 171 | + $email = Arr::get($customerData, 'email'); | |
| 172 | + $userId = (int) Arr::get($customerData, 'user_id'); | |
| 124 | 173 | |
| 125 | - $customer = false; | |
| 126 | - if($remoteUid) { | |
| 174 | + if ($remoteUid) { | |
| 127 | 175 | $customer = self::where('remote_uid', $remoteUid)->first(); |
| 176 | + | |
| 177 | + if ($customer) { | |
| 178 | + return $customer; | |
| 179 | + } | |
| 128 | 180 | } |
| 129 | 181 | |
| 130 | - if(!$customer) { | |
| 131 | - if(!empty($customerData['user_id'])) { | |
| 132 | - $customer = self::where('user_id', $customerData['user_id'])->first(); | |
| 133 | - } | |
| 134 | - if(!$customer) { | |
| 135 | - $customer = self::where('email', $email)->first(); | |
| 136 | - } | |
| 182 | + // A WordPress user id is the only identity that can be trusted here. An | |
| 183 | + // account email is editable by its own holder with no verification: the | |
| 184 | + // REST users endpoint calls wp_update_user() directly and skips the | |
| 185 | + // confirmation flow that profile.php runs. An email must therefore never | |
| 186 | + // be able to reach a customer row that belongs to somebody else. | |
| 187 | + // Both lookups order by id so the row that wins is always the oldest | |
| 188 | + // match rather than whatever the storage engine returns first. The email | |
| 189 | + // column carries a plain index, not a unique one, so duplicates are | |
| 190 | + // possible and an inbound reply must keep threading onto the original | |
| 191 | + // record. Helper::getCurrentPerson() orders for the same reason. | |
| 192 | + if ($userId) { | |
| 193 | + return self::where('user_id', $userId)->orderBy('id', 'ASC')->first() ?: false; | |
| 137 | 194 | } |
| 138 | 195 | |
| 139 | - return $customer; | |
| 196 | + if ($email) { | |
| 197 | + return self::where('email', $email)->orderBy('id', 'ASC')->first() ?: false; | |
| 198 | + } | |
| 199 | + | |
| 200 | + return false; | |
| 140 | 201 | } |
| 141 | 202 | |
| 142 | 203 | /** |
| 143 | 204 | * getTicketCounts method will return the number of tickets by a customer |