- Added UnsubscribeFromNewsletter action and input/output classes. - Introduced CrmHashcode value object and its cast. - Updated NewsletterController to use the new unsubscribe action. - Enhanced validation in UnsubscribeNewsletterRequest.
493 lines
18 KiB
PHP
493 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Events\CustomerPasswordUpdated;
|
|
use App\Exceptions\Crm\CrmAuthenticationFailedException;
|
|
use App\Exceptions\Crm\CrmCustomerExistsException;
|
|
use App\Exceptions\Crm\CrmCustomerNotActivatedException;
|
|
use App\Exceptions\CrmServiceException;
|
|
use App\Models\Customer as ModelsCustomer;
|
|
use Domain\Enums\Country;
|
|
use Domain\Models\ValueObjects\CrmCustomer;
|
|
use Domain\Models\ValueObjects\Email;
|
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Validation\ValidationException as IlluminateValidationException;
|
|
use LDC\LDCCRM\CrmApiV4;
|
|
use LDC\LDCCRM\Enums\ApiError;
|
|
use LDC\LDCCRM\Enums\Country as EnumsCountry;
|
|
use LDC\LDCCRM\Exceptions\ApiErrorException;
|
|
use LDC\LDCCRM\Exceptions\CrmException;
|
|
use LDC\LDCCRM\Exceptions\ValidationException;
|
|
use LDC\LDCCRM\Models\Customer;
|
|
use Shared\ValueObjects\CrmHashcode;
|
|
use UnexpectedValueException;
|
|
|
|
class CrmService
|
|
{
|
|
/**
|
|
* Request-level cache to avoid duplicate CRM calls for the same customer.
|
|
*
|
|
* @var array<string,CrmCustomer|CrmServiceException|null>
|
|
*/
|
|
private array $customerByHashcodeCache = [];
|
|
|
|
/**
|
|
* Request-level cache to avoid duplicate CRM calls for the same customer.
|
|
*
|
|
* @var array<string,CrmCustomer|CrmServiceException|null>
|
|
*/
|
|
private array $customerByEmailCache = [];
|
|
|
|
public function __construct(
|
|
private readonly CrmApiV4 $crmApi,
|
|
private readonly CacheRepository $cacheRepository
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @throws \App\Exceptions\CrmServiceException In case of an unhandled error
|
|
* @throws \App\Exceptions\Crm\CrmAuthenticationFailedException
|
|
* @throws \App\Exceptions\Crm\CrmCustomerNotActivatedException
|
|
*/
|
|
public function login(
|
|
#[\SensitiveParameter]
|
|
string $login,
|
|
#[\SensitiveParameter]
|
|
string $password
|
|
): void {
|
|
try {
|
|
$result = $this->crmApi->login($login, $password);
|
|
if ($result !== true) {
|
|
throw new CrmAuthenticationFailedException();
|
|
}
|
|
} catch (ApiErrorException $e) {
|
|
switch ($e->getApiError()) {
|
|
case ApiError::authFailed:
|
|
throw new CrmAuthenticationFailedException($e->getMessage());
|
|
case ApiError::customerNotActivated:
|
|
throw new CrmCustomerNotActivatedException($e->getMessage());
|
|
default:
|
|
report($e);
|
|
throw CrmServiceException::of(cause: $e);
|
|
}
|
|
} catch (CrmException | \Exception $e) {
|
|
report($e);
|
|
throw CrmServiceException::of(cause: $e);
|
|
}
|
|
}
|
|
|
|
public function resetPasswordFor(Email $email, string $newPassword): bool | IlluminateValidationException
|
|
{
|
|
try {
|
|
$this->login($email->value, $newPassword);
|
|
|
|
return IlluminateValidationException::withMessages([
|
|
trans('Your new password must be different from the old one'),
|
|
]);
|
|
// phpcs:ignore
|
|
} catch (CrmAuthenticationFailedException | CrmCustomerNotActivatedException) {
|
|
// Expected: the new password does not match the current one
|
|
}
|
|
|
|
$crmCustomer = $this->getCustomerFromEmail($email->value);
|
|
|
|
if ($crmCustomer === null) {
|
|
return IlluminateValidationException::withMessages([
|
|
trans('No account was found for this email address'),
|
|
]);
|
|
}
|
|
|
|
if ($crmCustomer instanceof CrmServiceException) {
|
|
throw $crmCustomer;
|
|
}
|
|
|
|
$updatedCrmCustomer = $this->newAppCustomerFrom(
|
|
$crmCustomer,
|
|
['password' => $newPassword]
|
|
);
|
|
|
|
try {
|
|
$this->persist($updatedCrmCustomer);
|
|
event(new CustomerPasswordUpdated($email));
|
|
|
|
return true;
|
|
} catch (IlluminateValidationException $e) {
|
|
return $e;
|
|
} catch (CrmCustomerExistsException $e) {
|
|
throw CrmServiceException::of('Unexpected CrmCustomerExistsException', $e);
|
|
}
|
|
}
|
|
|
|
public function changePassword(CrmCustomer $crmCustomer, string $oldPassword, string $newPassword): CrmCustomer | IlluminateValidationException
|
|
{
|
|
throw_if(
|
|
$crmCustomer->email === null,
|
|
new UnexpectedValueException('CRM customer email is required for password change')
|
|
);
|
|
|
|
try {
|
|
$this->login($crmCustomer->email, $oldPassword);
|
|
} catch (CrmAuthenticationFailedException | CrmCustomerNotActivatedException) {
|
|
return IlluminateValidationException::withMessages([
|
|
'password_old' => trans('Your old password does not match.'),
|
|
]);
|
|
}
|
|
|
|
$updatedCrmCustomer = $this->newAppCustomerFrom(
|
|
$crmCustomer,
|
|
['password' => $newPassword]
|
|
);
|
|
|
|
try {
|
|
$result = $this->persist($updatedCrmCustomer);
|
|
throw_if(
|
|
! $result->email,
|
|
new UnexpectedValueException('CRM customer email is required for password change event')
|
|
);
|
|
event(new CustomerPasswordUpdated(new Email($result->email)));
|
|
|
|
return $result;
|
|
} catch (IlluminateValidationException $e) {
|
|
return $e;
|
|
} catch (CrmCustomerExistsException $e) {
|
|
throw CrmServiceException::of('Unexpected CrmCustomerExistsException during password change', $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{firstname:string,lastname:string,birth_date:string,phone?:string,address?:string,postcode:string,city:string,opt_in?:bool} $data
|
|
*
|
|
* @throws \App\Exceptions\CrmServiceException In case of an unhandled error
|
|
*/
|
|
public function updateCustomerInfos(ModelsCustomer $customer, array $data): CrmCustomer | IlluminateValidationException
|
|
{
|
|
$crmCustomer = $this->getCustomerFromHashCode($customer->hashcode);
|
|
|
|
if (! $crmCustomer instanceof CrmCustomer) {
|
|
throw $crmCustomer instanceof \Throwable
|
|
? $crmCustomer
|
|
: new UnexpectedValueException('CRM customer not found for hashcode: ' . (string) $customer->hashcode);
|
|
}
|
|
|
|
$updatedData = [
|
|
'firstname' => $data['firstname'],
|
|
'lastname' => $data['lastname'],
|
|
'birthDate' => $data['birth_date'],
|
|
'address' => $data['address'] ?? null,
|
|
'postcode' => $data['postcode'],
|
|
'city' => $data['city'],
|
|
'phone' => ! empty($data['phone']) ? $data['phone'] : null,
|
|
'optIn' => $data['opt_in'] ?? false,
|
|
];
|
|
|
|
try {
|
|
$newCrmCustomer = $this->newAppCustomerFrom(
|
|
$crmCustomer,
|
|
$updatedData
|
|
);
|
|
|
|
return $this->persist($newCrmCustomer);
|
|
} catch (IlluminateValidationException $e) {
|
|
return $e;
|
|
} catch (CrmCustomerExistsException $e) {
|
|
throw CrmServiceException::of('Unexpected CrmCustomerExistsException during customer update', $e);
|
|
}
|
|
}
|
|
|
|
public function unsubscribeCustomer(CrmHashcode $hashcode): bool
|
|
{
|
|
$crmCustomer = $this->getCustomerFromHashCode($hashcode);
|
|
|
|
if (! $crmCustomer instanceof CrmCustomer) {
|
|
return false;
|
|
}
|
|
|
|
$updatedCrmCustomer = $this->newAppCustomerFrom(
|
|
$crmCustomer,
|
|
['optIn' => false]
|
|
);
|
|
|
|
try {
|
|
$this->persist($updatedCrmCustomer);
|
|
|
|
return true;
|
|
} catch (IlluminateValidationException) {
|
|
return false;
|
|
} catch (CrmCustomerExistsException | CrmServiceException $e) {
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function getCustomerFromHashCode(CrmHashcode $hashcode): null | CrmCustomer | CrmServiceException
|
|
{
|
|
if (array_key_exists($hashcode->value, $this->customerByHashcodeCache)) {
|
|
return $this->customerByHashcodeCache[$hashcode->value];
|
|
}
|
|
|
|
$cacheKey = $this->getCustomerHashcodeCacheKey($hashcode->value);
|
|
|
|
/** @var null|CrmCustomer|CrmServiceException $result */
|
|
$result = $this->cacheRepository->remember(
|
|
$cacheKey,
|
|
now()->addMinutes(15),
|
|
function () use ($hashcode): null | CrmCustomer | CrmServiceException {
|
|
try {
|
|
$customer = $this->crmApi->getCustomer($hashcode->value);
|
|
|
|
return $customer ?
|
|
$this->toAppCrmCustomer($customer) :
|
|
null;
|
|
} catch (ApiErrorException $e) {
|
|
if ($e->getApiError()->equals(ApiError::modelNotFound)) {
|
|
return null;
|
|
}
|
|
|
|
return CrmServiceException::of(cause: $e);
|
|
}
|
|
}
|
|
);
|
|
|
|
$this->customerByHashcodeCache[$hashcode->value] = $result;
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getCustomerFromEmail(string $email): null | CrmCustomer | CrmServiceException
|
|
{
|
|
$normalizedEmail = strtolower(trim($email));
|
|
|
|
if (array_key_exists($normalizedEmail, $this->customerByEmailCache)) {
|
|
return $this->customerByEmailCache[$normalizedEmail];
|
|
}
|
|
|
|
$cacheKey = $this->getCustomerEmailCacheKey($normalizedEmail);
|
|
|
|
/** @var null|CrmCustomer|CrmServiceException $result */
|
|
$result = $this->cacheRepository->remember(
|
|
$cacheKey,
|
|
now()->addMinutes(15),
|
|
function () use ($normalizedEmail): null | CrmCustomer | CrmServiceException {
|
|
try {
|
|
$customer = $this->crmApi->getCustomer($normalizedEmail);
|
|
|
|
return $customer ?
|
|
$this->toAppCrmCustomer($customer) :
|
|
null;
|
|
} catch (ApiErrorException $e) {
|
|
if ($e->getApiError()->equals(ApiError::modelNotFound)) {
|
|
return null;
|
|
}
|
|
|
|
return CrmServiceException::of(cause: $e);
|
|
}
|
|
}
|
|
);
|
|
|
|
$this->customerByEmailCache[$normalizedEmail] = $result;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return object{result:bool,message:string}
|
|
*/
|
|
public function activate(CrmCustomer $customer): object
|
|
{
|
|
if ($customer->hashcode) {
|
|
$this->cacheRepository->delete($this->getCustomerHashcodeCacheKey($customer->hashcode));
|
|
unset($this->customerByHashcodeCache[$customer->hashcode]);
|
|
}
|
|
if ($customer->email) {
|
|
$this->cacheRepository->delete($this->getCustomerEmailCacheKey($customer->email));
|
|
unset($this->customerByEmailCache[strtolower(trim($customer->email))]);
|
|
}
|
|
|
|
try {
|
|
$libCustomer = $this->toLibCrmCustomer($customer);
|
|
|
|
return $libCustomer->activate() ? (object) [
|
|
'result' => true,
|
|
'message' => trans('Your account has been successfully activated, you can now log in.'),
|
|
] : (object) [
|
|
'result' => false,
|
|
'message' => trans('This activation link is not valid'),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
if ($e instanceof ApiErrorException && $e->getApiError()->equals(ApiError::customerAlreadyActive)) {
|
|
return (object) [
|
|
'result' => false,
|
|
'message' => trans('Your account is already activated, you can log in with it now.'),
|
|
];
|
|
}
|
|
|
|
report($e);
|
|
|
|
return (object) [
|
|
'result' => false,
|
|
'message' => trans('An error has occurred. Please try again later'),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws \Illuminate\Validation\ValidationException
|
|
* @throws \App\Exceptions\Crm\CrmCustomerExistsException
|
|
* @throws \App\Exceptions\CrmServiceException
|
|
*/
|
|
public function persist(CrmCustomer $customer): CrmCustomer
|
|
{
|
|
if ($customer->hashcode) {
|
|
$this->cacheRepository->delete($this->getCustomerHashcodeCacheKey($customer->hashcode));
|
|
unset($this->customerByHashcodeCache[$customer->hashcode]);
|
|
}
|
|
if ($customer->email) {
|
|
$this->cacheRepository->delete($this->getCustomerEmailCacheKey($customer->email));
|
|
unset($this->customerByEmailCache[strtolower(trim($customer->email))]);
|
|
}
|
|
|
|
try {
|
|
$customer = $this->toLibCrmCustomer($customer);
|
|
|
|
if (! is_null($customer->hashcode)) {
|
|
$this->forceCrmCustomerNotNew($customer);
|
|
}
|
|
|
|
if (! $customer->save()) {
|
|
throw CrmServiceException::of('Failed to save customer into CRM');
|
|
}
|
|
|
|
return $this->toAppCrmCustomer($customer);
|
|
} catch (ApiErrorException $e) {
|
|
$apiError = $e->getApiError();
|
|
if ($apiError->equals(ApiError::validationFailed)) {
|
|
throw IlluminateValidationException::withMessages(['error' => $e->getMessage()]);
|
|
}
|
|
if ($apiError->equals(ApiError::customerExists)) {
|
|
throw new CrmCustomerExistsException($e->getMessage());
|
|
}
|
|
|
|
throw CrmServiceException::of(cause: $e);
|
|
} catch (ValidationException $e) {
|
|
throw IlluminateValidationException::withMessages(['error' => $e->getMessage()]);
|
|
} catch (CrmException $e) {
|
|
throw CrmServiceException::of(cause: $e);
|
|
}
|
|
}
|
|
|
|
/** @param array<string,int|bool|string|null> $andWith */
|
|
public function newAppCustomerFrom(CrmCustomer $existing, array $andWith = []): CrmCustomer
|
|
{
|
|
$data = array_merge($existing->toArray(), $andWith);
|
|
|
|
return new CrmCustomer(
|
|
hashcode: $this->stringOrNull($data, 'hashcode'),
|
|
email: $this->stringOrNull($data, 'email'),
|
|
password: $this->stringOrNull($data, 'password'),
|
|
firstname: $this->stringOrNull($data, 'firstname'),
|
|
lastname: $this->stringOrNull($data, 'lastname'),
|
|
birthDate: $this->stringOrNull($data, 'birthDate'),
|
|
phone: $this->stringOrNull($data, 'phone'),
|
|
address: $this->stringOrNull($data, 'address'),
|
|
address2: $this->stringOrNull($data, 'address2'),
|
|
city: $this->stringOrNull($data, 'city'),
|
|
postcode: $this->stringOrNull($data, 'postcode'),
|
|
country: $data['country'] instanceof Country ?
|
|
$data['country'] :
|
|
Country::from(Arr::string($data, 'country')),
|
|
optIn: Arr::boolean($data, 'optIn'),
|
|
status: Arr::boolean($data, 'status'),
|
|
lastConnection: $existing->lastConnection,
|
|
);
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
private function stringOrNull(array $data, string $key): ?string
|
|
{
|
|
$value = Arr::get($data, $key);
|
|
|
|
return is_string($value) ? $value : null;
|
|
}
|
|
|
|
private function toAppCrmCustomer(Customer $customer): CrmCustomer
|
|
{
|
|
return new CrmCustomer(
|
|
$customer->hashcode,
|
|
$customer->email,
|
|
$customer->password,
|
|
$customer->firstname,
|
|
$customer->lastname,
|
|
$customer->birth_date,
|
|
$customer->phone,
|
|
$customer->address,
|
|
$customer->address2,
|
|
$customer->city,
|
|
$customer->postcode,
|
|
$this->toAppCountry($customer->country),
|
|
$customer->opt_in,
|
|
$customer->status,
|
|
$customer->last_connection ?
|
|
new \DateTimeImmutable($customer->last_connection) : null
|
|
);
|
|
}
|
|
|
|
private function toLibCrmCustomer(CrmCustomer $crmCustomer): Customer
|
|
{
|
|
return new Customer(array_filter([
|
|
'hashcode' => $crmCustomer->hashcode,
|
|
'email' => $crmCustomer->email,
|
|
'password' => $crmCustomer->password,
|
|
'firstname' => $crmCustomer->firstname,
|
|
'lastname' => $crmCustomer->lastname,
|
|
'birth_date' => $crmCustomer->birthDate,
|
|
'phone' => $crmCustomer->phone,
|
|
'address' => $crmCustomer->address,
|
|
'address2' => $crmCustomer->address2,
|
|
'city' => $crmCustomer->city,
|
|
'postcode' => $crmCustomer->postcode,
|
|
'country' => $this->toLibCountry($crmCustomer->country),
|
|
'opt_in' => $crmCustomer->optIn,
|
|
'status' => $crmCustomer->status,
|
|
], fn ($value) => $value !== null));
|
|
}
|
|
|
|
private function toAppCountry(EnumsCountry $country): Country
|
|
{
|
|
return Country::from($country->value());
|
|
}
|
|
|
|
private function toLibCountry(Country $country): EnumsCountry
|
|
{
|
|
return EnumsCountry::from($country->value());
|
|
}
|
|
|
|
/**
|
|
* WORKAROUND: Force a Customer model from LDC CRM to not be considered "new"
|
|
* by modifying its internal state via Reflection.
|
|
* This is needed because the SDK does not provide an update() method
|
|
* or a way to mark an entity as existing.
|
|
*
|
|
* @see https://github.com/ldc/ldccrm-php-sdk/issues/XX
|
|
*/
|
|
private function forceCrmCustomerNotNew(Customer $customer): void
|
|
{
|
|
$refClass = new \ReflectionClass($customer::class);
|
|
$refProp = $refClass->getProperty('isNew');
|
|
$refProp->setValue($customer, false);
|
|
}
|
|
|
|
private function getCustomerHashcodeCacheKey(string $hashcode): string
|
|
{
|
|
return "crm_customer_hashcode_{$hashcode}";
|
|
}
|
|
|
|
private function getCustomerEmailCacheKey(string $email): string
|
|
{
|
|
$hashedEmail = hash('sha256', strtolower(trim($email)));
|
|
|
|
return "crm_customer_email_{$hashedEmail}";
|
|
}
|
|
}
|