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:
parent
ae59679150
commit
9ad69a3218
32 changed files with 2246 additions and 119 deletions
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal file
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal 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 & 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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal file
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue