mivita/app/Services/CustomerEmailChangeService.php
Kevin Adametz 9ad69a3218 Kundenhoheit: E-Mail-Aenderung mit Bestaetigung, Zuordnungsfehler behoben, Admin-Uebersicht
- 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>
2026-10-09 08:48:17 +00:00

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));
}
}