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>
This commit is contained in:
Kevin Adametz 2026-10-09 08:48:17 +00:00
parent ae59679150
commit 9ad69a3218
32 changed files with 2246 additions and 119 deletions

View file

@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserEmailChange;
use App\Services\CustomerEmailChangeService;
use App\Services\CustomerPriority;
use App\Services\CustomerPriorityOverviewService;
use App\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
/**
* Admin overview "Kundenhoheit": everything where the customer sovereignty still needs a
* decision, so the work does not depend on info mails that get lost.
*/
class CustomerPriorityController extends Controller
{
public const TABS = ['reviews', 'conflicts', 'email-changes'];
public function __construct(private CustomerPriorityOverviewService $overview)
{
$this->middleware('admin');
}
public function index(string $tab = 'reviews'): View
{
abort_unless(in_array($tab, self::TABS, true), 404);
return view('admin.customer_priority.index', [
'tab' => $tab,
'counts' => $this->overview->counts(),
]);
}
public function reviewsDatatable(): JsonResponse
{
$back = route('admin_customer_priority', ['reviews']);
return \DataTables::eloquent($this->overview->reviewsQuery())
->addColumn('created', fn (ShoppingUser $customer): string => $customer->created_at->format('d.m.Y H:i')
.'<br><small class="text-muted">'.$customer->created_at->diffForHumans().'</small>')
->addColumn('customer', fn (ShoppingUser $customer): string => $this->customerCell($customer))
->addColumn('member', fn (ShoppingUser $customer): string => $this->memberCell($customer->member))
->addColumn('shop', fn (ShoppingUser $customer): string => $this->memberCell($customer->shopping_order?->user_shop?->user))
->addColumn('matches', fn (ShoppingUser $customer): string => (string) count($customer->getNotice('like') ?: []))
->addColumn('action', function (ShoppingUser $customer) use ($back): string {
return '<button type="button" class="btn btn-sm btn-primary mb-1" data-toggle="modal" data-target="#modals-load-content"'
.' data-id="'.$customer->id.'" data-action="shopping-user-is-like-member" data-back="'.e($back).'"'
.' data-modal="modal-xl" data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Prüfen &amp; zuordnen</button> '
.'<form method="POST" action="'.route('admin_customer_priority_keep', [$customer->id]).'" class="d-inline"'
.' onsubmit="return confirm(\'Prüffall schließen? Berater und Kundennummer bleiben unverändert.\')">'
.csrf_field()
.'<button type="submit" class="btn btn-sm btn-outline-secondary mb-1">Zuordnung beibehalten</button></form>';
})
->filterColumn('customer', function ($query, $keyword): void {
$query->where(function ($query) use ($keyword) {
$query->where('billing_email', 'like', '%'.$keyword.'%')
->orWhere('billing_lastname', 'like', '%'.$keyword.'%')
->orWhere('billing_firstname', 'like', '%'.$keyword.'%')
->orWhere('billing_zipcode', 'like', '%'.$keyword.'%');
});
})
->orderColumn('created', 'shopping_users.created_at $1')
->rawColumns(['created', 'customer', 'member', 'shop', 'action'])
->make(true);
}
public function conflictsDatatable(): JsonResponse
{
$back = route('admin_customer_priority', ['conflicts']);
return \DataTables::of($this->overview->conflicts())
->addColumn('customer', fn (array $row): string => '<strong>'.e($row['name']).'</strong><br>'.e($row['email']))
->addColumn('groups', function (array $row) use ($back): string {
$html = '';
foreach ($row['groups'] as $group) {
$html .= '<div class="mb-2">'
.($group['oldest'] ? '<span class="badge badge-success">älteste Zuordnung</span> ' : '<span class="badge badge-warning">weitere Zuordnung</span> ')
.$this->memberCell($group['member'])
.' <span class="text-muted">· Kunden-Nr. '.e($group['number']).' · '.$group['entries'].' Einträge · zuletzt '.e(date('d.m.Y', strtotime($group['last_at']))).'</span> '
.'<a href="'.route('admin_customer_detail', [$group['latest_id']]).'" class="btn btn-xs btn-outline-primary"><i class="fa fa-eye"></i></a> '
.'<button type="button" class="btn btn-xs btn-outline-secondary" data-toggle="modal" data-target="#modals-load-content"'
.' data-id="'.$group['latest_id'].'" data-action="shopping-user-change-member" data-view="customer" data-back="'.e($back).'"'
.' data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Berater zuweisen</button>'
.'</div>';
}
return $html;
})
->addColumn('last', fn (array $row): string => date('d.m.Y', strtotime($row['last_at'])))
->filter(function ($instance): void {
$keyword = mb_strtolower((string) request('search.value'));
if ($keyword !== '') {
$instance->collection = $instance->collection->filter(
fn (array $row) => str_contains($row['email'], $keyword) || str_contains(mb_strtolower($row['name']), $keyword)
);
}
})
->rawColumns(['customer', 'groups'])
->make(true);
}
public function emailChangesDatatable(): JsonResponse
{
return \DataTables::eloquent($this->overview->emailChangesQuery())
->addColumn('created', fn (ShoppingUserEmailChange $change): string => $change->created_at->format('d.m.Y H:i'))
->addColumn('customer', fn (ShoppingUserEmailChange $change): string => $change->shopping_user
? $this->customerCell($change->shopping_user)
: '<span class="text-muted">Kunde #'.$change->shopping_user_id.' gelöscht</span>')
->addColumn('change', fn (ShoppingUserEmailChange $change): string => e($change->old_email).' <i class="fa fa-arrow-right"></i> <strong>'.e($change->new_email).'</strong>')
->addColumn('requested_by', fn (ShoppingUserEmailChange $change): string => $this->memberCell($change->requested_by))
->addColumn('status', function (ShoppingUserEmailChange $change): string {
if ($change->blocked_at) {
return '<span class="badge badge-danger">abgelehnt (Kundenhoheit)</span><br><small class="text-muted">Adresse gehört einem anderen Berater oder Beraterkonto</small>';
}
return '<span class="badge badge-info">wartet auf Bestätigung</span><br><small class="text-muted">Link gültig bis '.$change->expires_at->format('d.m.Y H:i').'</small>';
})
->addColumn('action', function (ShoppingUserEmailChange $change): string {
if ($change->blocked_at) {
return '<form method="POST" action="'.route('admin_customer_priority_email_resolve', [$change->id]).'" class="d-inline">'
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-success"><i class="fa fa-check"></i> Erledigt</button></form>';
}
return '<form method="POST" action="'.route('admin_customer_priority_email_discard', [$change->id]).'" class="d-inline"'
.' onsubmit="return confirm(\'Anfrage verwerfen? Der Link in der Bestätigungsmail funktioniert dann nicht mehr.\')">'
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-danger">Verwerfen</button></form>';
})
->orderColumn('created', 'shopping_user_email_changes.created_at $1')
->rawColumns(['customer', 'change', 'requested_by', 'status', 'action'])
->make(true);
}
public function keepAssignment(int $id): RedirectResponse
{
$customer = ShoppingUser::where('is_like', true)->findOrFail($id);
CustomerPriority::keepAssignment($customer);
\Session()->flash('alert-success', 'Prüffall geschlossen, Zuordnung beibehalten.');
return redirect(route('admin_customer_priority', ['reviews']));
}
public function resolveEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
{
$service->resolveBlocked(ShoppingUserEmailChange::blockedOpen()->findOrFail($id), \Auth::id());
\Session()->flash('alert-success', 'Als erledigt markiert.');
return redirect(route('admin_customer_priority', ['email-changes']));
}
public function discardEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
{
$service->discard(ShoppingUserEmailChange::pending()->findOrFail($id));
\Session()->flash('alert-success', 'Anfrage verworfen.');
return redirect(route('admin_customer_priority', ['email-changes']));
}
private function customerCell(ShoppingUser $customer): string
{
return '<a href="'.route('admin_customer_detail', [$customer->id]).'"><strong>'
.e(trim($customer->billing_firstname.' '.$customer->billing_lastname)).'</strong></a>'
.($customer->number ? ' <span class="text-muted">#'.e($customer->number).'</span>' : '')
.'<br>'.e($customer->billing_email)
.'<br><small class="text-muted">'.e($customer->billing_zipcode.' '.$customer->billing_city).'</small>';
}
private function memberCell(?User $member): string
{
if (! $member) {
return '<span class="text-muted">–</span>';
}
return '<a href="'.route('admin_lead_edit', [$member->id]).'">'.e($member->getFullName(false) ?: $member->email).'</a>'
.' <span class="text-muted">#'.e($member->number).'</span>';
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Exceptions\GdprDeletionException;
use App\Models\ShoppingUser;
use App\Repositories\CustomerRepository;
use App\Services\CustomerEmailChangeService;
use App\Services\CustomerPriority;
use App\Services\GdprDeletionService;
use App\Services\HTMLHelper;
@ -146,6 +147,12 @@ class CustomerController extends Controller
CustomerPriority::newMemberForCustomer($shopping_user, $data['change_member_id'], $data['customer_set_member_for']);
\Session()->flash('alert-save', true);
// Aus der Übersicht Kundenhoheit aufgerufen: dorthin zurück.
$back = $data['back'] ?? null;
if ($back && str_starts_with($back, route('admin_customer_priority'))) {
return redirect($back);
}
return redirect(route('admin_customer_detail', [$shopping_user->id]));
}
if ($data['action'] === 'shopping-user-store') {
@ -194,6 +201,14 @@ class CustomerController extends Controller
$data['same_as_billing'] = isset($data['same_as_billing']) ? true : false;
$data['shipping_country_id'] = isset($data['shipping_country_id']) ? $data['shipping_country_id'] : $data['billing_country_id'];
CustomerPriority::checkChangeOne($shopping_user, $data, true);
// Admin ändert direkt (Support); alle Einträge des Kunden und seine Abos ziehen mit.
$emailChangeService = app(CustomerEmailChangeService::class);
if ($emailChangeService->changesAddress($shopping_user, $data['billing_email'] ?? null)) {
$emailChangeService->changeDirectly($shopping_user, $data['billing_email'], Auth::id());
unset($data['billing_email']);
}
$shopping_user->fill($data);
$shopping_user->save();
Shop::newUserOrder($shopping_user->number);

View file

@ -48,9 +48,12 @@ class ModalController extends Controller
$current = ShoppingUser::find($data['id']); // current user form order
$possibles = [];
if ($current->is_like) {
$likes = $current->getNotice('like');
// Treffer können inzwischen gelöscht sein (alte Prüffälle).
$likes = $current->getNotice('like') ?: [];
foreach ($likes as $like_id => $number) {
$possibles[] = ShoppingUser::find($like_id);
if ($possible = ShoppingUser::find($like_id)) {
$possibles[] = $possible;
}
}
}
$ret = view('admin.modal.is_like_member', compact('current', 'possibles', 'data'))->render();

View file

@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\ShoppingUser;
use App\Repositories\CustomerRepository;
use App\Services\AboHelper;
use App\Services\CustomerEmailChangeService;
use App\Services\CustomerPriority;
use App\Services\HTMLHelper;
use App\Services\Shop;
@ -229,7 +230,24 @@ class CustomerController extends Controller
if ($shopping_user->isGdprDeleted()) {
return $this->redirectGdprLocked($shopping_user);
}
// Neue E-Mail wird erst nach Bestätigung durch den Kunden aktiv (Login + Abo) und nur,
// wenn sie keinem Beraterkonto oder Kunden eines anderen Beraters gehört (Kundenhoheit).
$emailChangeService = app(CustomerEmailChangeService::class);
if ($emailChangeService->requiresConfirmation($shopping_user, $data['billing_email'] ?? null)) {
$newEmail = trim($data['billing_email']);
unset($data['billing_email']);
if ($emailChangeService->ownershipConflict($shopping_user, $newEmail)) {
$emailChangeService->reportConflict($shopping_user, $newEmail, \Auth::id());
\Session()->flash('custom-error', __('customer.email_change_blocked', ['email' => $newEmail]));
} else {
$emailChangeService->request($shopping_user, $newEmail, \Auth::id());
\Session()->flash('custom-error', __('customer.email_change_requested', ['email' => $newEmail]));
}
}
// Adresse (Nachname + PLZ) läuft weiter über die bestehende Kundenhoheit-Prüfung.
CustomerPriority::checkChangeOne($shopping_user, $data, true);
$shopping_user->fill($data);
$shopping_user->save();

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\CustomerEmailChangeService;
use Illuminate\Contracts\View\View;
/**
* Login-free confirmation of a new customer e-mail address via the tokenized link in the
* confirmation mail. GET only shows the page; the change is applied on POST, so mail
* scanners that open links cannot confirm on the customer's behalf.
*/
class CustomerEmailChangeController extends Controller
{
public function show(string $token, CustomerEmailChangeService $service): View
{
$change = $service->findConfirmable($token);
if (! $change) {
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_INVALID]);
}
if ($change->shopping_user && $service->ownershipConflict($change->shopping_user, $change->new_email)) {
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_CONFLICT]);
}
return view('portal.customer.email_change_confirm', [
'change' => $change,
'token' => $token,
]);
}
public function store(string $token, CustomerEmailChangeService $service): View
{
$change = $service->findConfirmable($token);
$status = $change ? $service->confirm($change) : CustomerEmailChangeService::STATUS_INVALID;
return view('portal.customer.email_change_done', [
'status' => $status,
'email' => $status === CustomerEmailChangeService::STATUS_CONFIRMED ? $change->new_email : null,
]);
}
}