| @@ -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'); |
| @@ -33,42 +44,104 @@ | ||
| 33 | 44 | }); |
| 34 | 45 | |
| 35 | 46 | } |
| 36 | 47 | |
| 48 | + public static function mappables() | |
| 49 | + { | |
| 50 | + return [ | |
| 51 | + 'title' => __('Customer Title', 'fluent-support'), | |
| 52 | + 'address_line_1' => __('Address Line 1', 'fluent-support'), | |
| 53 | + 'address_line_2' => __('Address Line 2', 'fluent-support'), | |
| 54 | + 'city' => __('City', 'fluent-support'), | |
| 55 | + 'state' => __('State', 'fluent-support'), | |
| 56 | + 'zip' => __('Zip Code', 'fluent-support'), | |
| 57 | + 'country' => __('Country', 'fluent-support'), | |
| 58 | + ]; | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * maybeCreateCustomer method will update existing customer or create new | |
| 63 | + * This method will get request to create customer, this will check existence, if exist it will update otherwise it will create new. | |
| 64 | + * @param $customerData | |
| 65 | + * @return false|Customer | |
| 66 | + */ | |
| 37 | 67 | public static function maybeCreateCustomer($customerData) |
| 38 | 68 | { |
| 39 | 69 | $customer = self::getCustomerFromData($customerData); |
| 40 | - $user = get_user_by('email', $customerData['email']); | |
| 41 | - if($user) { | |
| 42 | - 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) { | |
| 43 | 74 | $customerData['first_name'] = $user->first_name; |
| 44 | 75 | } |
| 45 | - if($user->last_name) { | |
| 76 | + if ($user->last_name) { | |
| 46 | 77 | $customerData['last_name'] = $user->last_name; |
| 47 | 78 | } |
| 48 | - if(empty($customerData['first_name']) && empty($customerData['last_name'])) { | |
| 79 | + if (empty($customerData['first_name']) && empty($customerData['last_name'])) { | |
| 49 | 80 | $customerData['first_name'] = $user->display_name; |
| 50 | 81 | } |
| 51 | 82 | $customerData['user_id'] = $user->ID; |
| 52 | 83 | } |
| 53 | 84 | |
| 54 | - if(!$customer) { | |
| 55 | - if(!empty($customerData['last_ip_address'])) { | |
| 85 | + if (!$customer) { | |
| 86 | + if (!empty($customerData['last_ip_address'])) { | |
| 56 | 87 | $customerData['ip_address'] = $customerData['last_ip_address']; |
| 57 | 88 | } |
| 89 | + | |
| 90 | + $customerData = self::explodeFullName($customerData); | |
| 91 | + | |
| 58 | 92 | // we have to create customer |
| 59 | 93 | $customer = self::create($customerData); |
| 60 | 94 | |
| 95 | + /* | |
| 96 | + * Action on customer create | |
| 97 | + * | |
| 98 | + * @since v1.0.0 | |
| 99 | + * @param object $customer | |
| 100 | + */ | |
| 61 | 101 | do_action('fluent_support/customer_created', $customer); |
| 62 | 102 | |
| 63 | - } else { | |
| 64 | - if(!empty($customerData['user_id']) || !empty($customerData['remote_uid'])) { | |
| 65 | - $customerData = array_filter($customerData); | |
| 66 | - $customer->fill($customerData); | |
| 67 | - $customer->save(); | |
| 68 | - } | |
| 103 | + return $customer; | |
| 69 | 104 | } |
| 70 | 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 | + | |
| 71 | 144 | return $customer; |
| 72 | 145 | } |
| 73 | 146 | |
| 74 | 147 | |
| @@ -86,35 +159,61 @@ | ||
| 86 | 159 | $class, $foreign_key, 'id' |
| 87 | 160 | ); |
| 88 | 161 | } |
| 89 | 162 | |
| 163 | + /** | |
| 164 | + * getCustomerFromData method will return customer information by user id or email address | |
| 165 | + * @param $customerData | |
| 166 | + * @return false | |
| 167 | + */ | |
| 90 | 168 | public static function getCustomerFromData($customerData) |
| 91 | 169 | { |
| 92 | 170 | $remoteUid = Arr::get($customerData, 'remote_uid'); |
| 93 | - $email = $customerData['email']; | |
| 171 | + $email = Arr::get($customerData, 'email'); | |
| 172 | + $userId = (int) Arr::get($customerData, 'user_id'); | |
| 94 | 173 | |
| 95 | - $customer = false; | |
| 96 | - if($remoteUid) { | |
| 174 | + if ($remoteUid) { | |
| 97 | 175 | $customer = self::where('remote_uid', $remoteUid)->first(); |
| 176 | + | |
| 177 | + if ($customer) { | |
| 178 | + return $customer; | |
| 179 | + } | |
| 98 | 180 | } |
| 99 | 181 | |
| 100 | - if(!$customer) { | |
| 101 | - if(!empty($customerData['user_id'])) { | |
| 102 | - $customer = self::where('user_id', $customerData['user_id'])->first(); | |
| 103 | - } | |
| 104 | - if(!$customer) { | |
| 105 | - $customer = self::where('email', $email)->first(); | |
| 106 | - } | |
| 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; | |
| 107 | 194 | } |
| 108 | 195 | |
| 109 | - return $customer; | |
| 196 | + if ($email) { | |
| 197 | + return self::where('email', $email)->orderBy('id', 'ASC')->first() ?: false; | |
| 198 | + } | |
| 199 | + | |
| 200 | + return false; | |
| 110 | 201 | } |
| 111 | 202 | |
| 203 | + /** | |
| 204 | + * getTicketCounts method will return the number of tickets by a customer | |
| 205 | + * @return mixed | |
| 206 | + */ | |
| 112 | 207 | public function getTicketCounts() |
| 113 | 208 | { |
| 114 | 209 | return Ticket::where('customer_id', $this->id)->count(); |
| 115 | 210 | } |
| 116 | 211 | |
| 212 | + /** | |
| 213 | + * getResponseCounts will return the number of responses by a customer | |
| 214 | + * @return mixed | |
| 215 | + */ | |
| 117 | 216 | public function getResponseCounts() |
| 118 | 217 | { |
| 119 | 218 | return Conversation::where('person_id', $this->id)->count(); |
| 120 | 219 | } |