- E-Mail-Aenderung durch Berater wird erst nach Bestaetigung durch den Kunden aktiv (Link an die neue Adresse, Portal kunde/email-bestaetigen); danach ziehen alle Eintraege des Kunden und seine Abos mit. Admins aendern direkt. - Kundenhoheit: E-Mail eines Beraterkontos oder eines Kunden eines anderen Beraters wird abgelehnt (beim Speichern und bei der Bestaetigung). - Fix CustomerPriority::entryExists: aelteste gepruefte Zuordnung gilt, Eintraege in Pruefung (is_like) dienen nicht mehr als Vorlage. - "Berater zuweisen" voreingestellt auf alle Bestellungen des Kunden. - Neue Admin-Seite Kundenhoheit: offene Pruefungen, Kunden bei mehreren Beratern, abgelehnte/offene E-Mail-Aenderungen; Zaehler im Menue. - Neue Tabelle shopping_user_email_changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
11 KiB
PHP
291 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Mail\CustomerEmailChangeBlockedMail;
|
|
use App\Mail\CustomerEmailChangeMail;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\ShoppingUserEmailChange;
|
|
use App\Models\UserAbo;
|
|
use App\User;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Changing a customer's e-mail address. The address is the customer's portal login and
|
|
* the key the portal uses to find their abo, so a consultant may not switch it silently:
|
|
* the new address only becomes active after the customer confirmed it via the link sent
|
|
* to it. On activation all entries of the customer with the old address and their abos
|
|
* move to the new address together.
|
|
*/
|
|
class CustomerEmailChangeService
|
|
{
|
|
public const TTL_HOURS = 168;
|
|
|
|
public const STATUS_CONFIRMED = 'confirmed';
|
|
|
|
public const STATUS_INVALID = 'invalid';
|
|
|
|
public const STATUS_CONFLICT = 'conflict';
|
|
|
|
public const CONFLICT_CONSULTANT_ACCOUNT = 'consultant_account';
|
|
|
|
public const CONFLICT_OTHER_CONSULTANT = 'other_consultant';
|
|
|
|
public function changesAddress(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
|
{
|
|
return filled($newEmail) && ! $this->sameEmail($shoppingUser->billing_email, $newEmail);
|
|
}
|
|
|
|
/**
|
|
* A customer without a real address yet (placeholder) has no login to lose, so only
|
|
* replacing a real address needs the customer's confirmation.
|
|
*/
|
|
public function requiresConfirmation(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
|
{
|
|
return ! $this->hasPlaceholderEmail($shoppingUser) && $this->changesAddress($shoppingUser, $newEmail);
|
|
}
|
|
|
|
/**
|
|
* Customer sovereignty: the e-mail is the main indicator who a customer belongs to. A
|
|
* consultant may not take over an address that already belongs to an active consultant
|
|
* account or to a customer of another consultant.
|
|
*
|
|
* @return self::CONFLICT_*|null
|
|
*/
|
|
public function ownershipConflict(ShoppingUser $shoppingUser, string $newEmail): ?string
|
|
{
|
|
$newEmail = trim($newEmail);
|
|
|
|
if (User::where('email', $newEmail)->where('active', 1)->exists()) {
|
|
return self::CONFLICT_CONSULTANT_ACCOUNT;
|
|
}
|
|
|
|
return $this->customersOfOtherConsultants($shoppingUser, $newEmail)->isNotEmpty()
|
|
? self::CONFLICT_OTHER_CONSULTANT
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Records a change rejected because of customer sovereignty, so it stays visible in the
|
|
* admin overview until handled, and informs the customer sovereignty inbox.
|
|
*/
|
|
public function reportConflict(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null, ?ShoppingUserEmailChange $change = null): ShoppingUserEmailChange
|
|
{
|
|
$newEmail = trim($newEmail);
|
|
$owners = $this->customersOfOtherConsultants($shoppingUser, $newEmail)
|
|
->map(fn (ShoppingUser $customer) => $customer->member?->email)
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$attributes = ['token_hash' => null, 'blocked_at' => now()];
|
|
if ($change) {
|
|
$change->forceFill($attributes)->save();
|
|
} else {
|
|
$change = ShoppingUserEmailChange::create($attributes + [
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'number' => $shoppingUser->number,
|
|
'old_email' => $shoppingUser->billing_email,
|
|
'new_email' => $newEmail,
|
|
'requested_by_user_id' => $requestedBy,
|
|
]);
|
|
}
|
|
|
|
Log::warning('CustomerEmailChange: Änderung wegen Kundenhoheit abgelehnt', [
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'number' => $shoppingUser->number,
|
|
'new_email' => $newEmail,
|
|
'requested_by_user_id' => $requestedBy,
|
|
'owners' => $owners,
|
|
]);
|
|
|
|
Mail::to(config('app.priority_mail'))->send(new CustomerEmailChangeBlockedMail(
|
|
$shoppingUser,
|
|
$newEmail,
|
|
$requestedBy ? User::find($requestedBy)?->email : null,
|
|
$owners,
|
|
));
|
|
|
|
return $change;
|
|
}
|
|
|
|
public function resolveBlocked(ShoppingUserEmailChange $change, ?int $resolvedBy = null): void
|
|
{
|
|
$change->forceFill(['resolved_at' => now(), 'resolved_by_user_id' => $resolvedBy])->save();
|
|
}
|
|
|
|
/**
|
|
* Withdraws an open request; the link in the mail stops working.
|
|
*/
|
|
public function discard(ShoppingUserEmailChange $change): void
|
|
{
|
|
$change->forceFill(['token_hash' => null, 'expires_at' => now()])->save();
|
|
}
|
|
|
|
/**
|
|
* Starts the confirmation for a new address and sends the link to it. Older open
|
|
* requests of the same customer are replaced.
|
|
*/
|
|
public function request(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null): ShoppingUserEmailChange
|
|
{
|
|
$newEmail = trim($newEmail);
|
|
$token = Str::random(48);
|
|
|
|
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
|
|
|
$change = ShoppingUserEmailChange::create([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'number' => $shoppingUser->number,
|
|
'old_email' => $shoppingUser->billing_email,
|
|
'new_email' => $newEmail,
|
|
'token_hash' => ShoppingUserEmailChange::hashToken($token),
|
|
'expires_at' => now()->addHours(self::TTL_HOURS),
|
|
'requested_by_user_id' => $requestedBy,
|
|
]);
|
|
|
|
Mail::to($newEmail)
|
|
->locale($shoppingUser->language ?: 'de')
|
|
->send(new CustomerEmailChangeMail($shoppingUser, $token));
|
|
|
|
return $change;
|
|
}
|
|
|
|
public function findConfirmable(string $token): ?ShoppingUserEmailChange
|
|
{
|
|
$change = ShoppingUserEmailChange::where('token_hash', ShoppingUserEmailChange::hashToken($token))->first();
|
|
|
|
return $change && $change->isConfirmable() ? $change : null;
|
|
}
|
|
|
|
/**
|
|
* Activates the requested address. Fails when the customer's address was changed in
|
|
* the meantime (an outdated link must not overwrite a newer address) or when the new
|
|
* address has meanwhile been assigned to another consultant (customer sovereignty).
|
|
*
|
|
* @return self::STATUS_*
|
|
*/
|
|
public function confirm(ShoppingUserEmailChange $change): string
|
|
{
|
|
$shoppingUser = $change->shopping_user;
|
|
if (! $change->isConfirmable() || ! $shoppingUser || ! $this->sameEmail($shoppingUser->billing_email, $change->old_email)) {
|
|
return self::STATUS_INVALID;
|
|
}
|
|
|
|
if ($this->ownershipConflict($shoppingUser, $change->new_email)) {
|
|
$this->reportConflict($shoppingUser, $change->new_email, $change->requested_by_user_id, $change);
|
|
|
|
return self::STATUS_CONFLICT;
|
|
}
|
|
|
|
DB::transaction(function () use ($change, $shoppingUser) {
|
|
$this->apply($shoppingUser, $change->old_email, $change->new_email);
|
|
$change->forceFill(['confirmed_at' => now(), 'token_hash' => null])->save();
|
|
});
|
|
|
|
return self::STATUS_CONFIRMED;
|
|
}
|
|
|
|
/**
|
|
* Direct change without confirmation (admin). Logged like a confirmed request.
|
|
*/
|
|
public function changeDirectly(ShoppingUser $shoppingUser, string $newEmail, ?int $changedBy = null): void
|
|
{
|
|
$oldEmail = $shoppingUser->billing_email;
|
|
$newEmail = trim($newEmail);
|
|
|
|
DB::transaction(function () use ($shoppingUser, $oldEmail, $newEmail, $changedBy) {
|
|
$this->apply($shoppingUser, $oldEmail, $newEmail);
|
|
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
|
ShoppingUserEmailChange::create([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'number' => $shoppingUser->number,
|
|
'old_email' => $oldEmail,
|
|
'new_email' => $newEmail,
|
|
'confirmed_at' => now(),
|
|
'requested_by_user_id' => $changedBy,
|
|
]);
|
|
});
|
|
}
|
|
|
|
public function pendingFor(ShoppingUser $shoppingUser): ?ShoppingUserEmailChange
|
|
{
|
|
return ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->latest('id')->first();
|
|
}
|
|
|
|
/**
|
|
* Moves all entries of the customer that still carry the old address, plus their
|
|
* customer abos, to the new address.
|
|
*
|
|
* @return array{shopping_users: int, abos: int}
|
|
*/
|
|
private function apply(ShoppingUser $shoppingUser, string $oldEmail, string $newEmail): array
|
|
{
|
|
$shoppingUserIds = ShoppingUser::query()
|
|
->where('billing_email', $oldEmail)
|
|
->when(
|
|
$shoppingUser->number > 0,
|
|
fn ($query) => $query->where('number', $shoppingUser->number),
|
|
fn ($query) => $query->whereKey($shoppingUser->id),
|
|
)
|
|
->pluck('id')
|
|
->push($shoppingUser->id)
|
|
->unique()
|
|
->values();
|
|
|
|
$updatedShoppingUsers = ShoppingUser::whereIn('id', $shoppingUserIds)->update(['billing_email' => $newEmail]);
|
|
|
|
$updatedAbos = UserAbo::query()
|
|
->whereIn('shopping_user_id', $shoppingUserIds)
|
|
->where('is_for', 'ot')
|
|
->where('email', $oldEmail)
|
|
->update(['email' => $newEmail]);
|
|
|
|
$shoppingUser->billing_email = $newEmail;
|
|
|
|
Log::info('CustomerEmailChange: E-Mail geändert', [
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'number' => $shoppingUser->number,
|
|
'old_email' => $oldEmail,
|
|
'new_email' => $newEmail,
|
|
'shopping_users' => $updatedShoppingUsers,
|
|
'abos' => $updatedAbos,
|
|
]);
|
|
|
|
return ['shopping_users' => $updatedShoppingUsers, 'abos' => $updatedAbos];
|
|
}
|
|
|
|
/**
|
|
* Customers (with a customer number) of other consultants that use the address.
|
|
*
|
|
* @return Collection<int, ShoppingUser>
|
|
*/
|
|
private function customersOfOtherConsultants(ShoppingUser $shoppingUser, string $email): Collection
|
|
{
|
|
return ShoppingUser::query()
|
|
->with('member')
|
|
->whereNull('auth_user_id')
|
|
->whereNotNull('number')
|
|
->whereNotNull('member_id')
|
|
->when($shoppingUser->member_id, fn ($query) => $query->where('member_id', '!=', $shoppingUser->member_id))
|
|
->where('billing_email', $email)
|
|
->withoutGdprDeleted()
|
|
->get();
|
|
}
|
|
|
|
private function hasPlaceholderEmail(ShoppingUser $shoppingUser): bool
|
|
{
|
|
return (bool) $shoppingUser->faker_mail
|
|
|| blank($shoppingUser->billing_email)
|
|
|| str_ends_with((string) $shoppingUser->billing_email, '-faker@mivita.care');
|
|
}
|
|
|
|
private function sameEmail(?string $a, ?string $b): bool
|
|
{
|
|
return mb_strtolower(trim((string) $a)) === mb_strtolower(trim((string) $b));
|
|
}
|
|
}
|