| @@ -66,9 +66,10 @@ | ||
| 66 | 66 | */ |
| 67 | 67 | public static function maybeCreateCustomer($customerData) |
| 68 | 68 | { |
| 69 | 69 | $customer = self::getCustomerFromData($customerData); |
| 70 | - $user = get_user_by('email', $customerData['email']); | |
| 70 | + $email = Arr::get($customerData, 'email'); | |
| 71 | + $user = $email ? get_user_by('email', $email) : false; | |
| 71 | 72 | if ($user) { |
| 72 | 73 | if ($user->first_name) { |
| 73 | 74 | $customerData['first_name'] = $user->first_name; |
| 74 | 75 | } |
| @@ -98,16 +99,49 @@ | ||
| 98 | 99 | * @param object $customer |
| 99 | 100 | */ |
| 100 | 101 | do_action('fluent_support/customer_created', $customer); |
| 101 | 102 | |
| 102 | - } else { | |
| 103 | - if (!empty($customerData['user_id']) || !empty($customerData['remote_uid'])) { | |
| 104 | - $customerData = array_filter($customerData); | |
| 105 | - $customer->fill($customerData); | |
| 106 | - $customer->save(); | |
| 107 | - } | |
| 103 | + return $customer; | |
| 108 | 104 | } |
| 109 | 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 | + | |
| 110 | 144 | return $customer; |
| 111 | 145 | } |
| 112 | 146 | |
| 113 | 147 | |
| @@ -133,25 +167,38 @@ | ||
| 133 | 167 | */ |
| 134 | 168 | public static function getCustomerFromData($customerData) |
| 135 | 169 | { |
| 136 | 170 | $remoteUid = Arr::get($customerData, 'remote_uid'); |
| 137 | - $email = $customerData['email']; | |
| 171 | + $email = Arr::get($customerData, 'email'); | |
| 172 | + $userId = (int) Arr::get($customerData, 'user_id'); | |
| 138 | 173 | |
| 139 | - $customer = false; | |
| 140 | 174 | if ($remoteUid) { |
| 141 | 175 | $customer = self::where('remote_uid', $remoteUid)->first(); |
| 176 | + | |
| 177 | + if ($customer) { | |
| 178 | + return $customer; | |
| 179 | + } | |
| 142 | 180 | } |
| 143 | 181 | |
| 144 | - if (!$customer) { | |
| 145 | - if (!empty($customerData['user_id'])) { | |
| 146 | - $customer = self::where('user_id', $customerData['user_id'])->first(); | |
| 147 | - } | |
| 148 | - if (!$customer) { | |
| 149 | - $customer = self::where('email', $email)->first(); | |
| 150 | - } | |
| 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; | |
| 151 | 194 | } |
| 152 | 195 | |
| 153 | - return $customer; | |
| 196 | + if ($email) { | |
| 197 | + return self::where('email', $email)->orderBy('id', 'ASC')->first() ?: false; | |
| 198 | + } | |
| 199 | + | |
| 200 | + return false; | |
| 154 | 201 | } |
| 155 | 202 | |
| 156 | 203 | /** |
| 157 | 204 | * getTicketCounts method will return the number of tickets by a customer |