Kunden loeschen: DSGVO-geloeschte Kunden ausblenden und sperren; Checkout-Herkunft freiwillig

Kunden loeschen (Rueckmeldung "es wird nichts geloescht"):
- DSGVO-geloeschte Eintraege blieben mit Name in den Listen stehen; Kunde
  61009 wurde deshalb achtmal geloescht, bei Eintrag 45013 wurde die E-Mail
  danach ueber ein offenes Bearbeiten-Formular zurueckgeschrieben
- neue Spalte shopping_users.gdpr_deleted_at (Bestand per Migration markiert)
- Admin-Kundenliste blendet geloeschte Kunden aus (Filter: ausblenden /
  mit anzeigen / nur geloeschte), Berater-Kundenliste immer
- Bearbeiten/Speichern im Admin und beim Berater gesperrt; ShoppingUser
  verweigert das Zurueckschreiben von E-Mail und Telefon
- Modal und Erfolgsmeldung (de/en/es/fr) sagen klar, dass Name und
  Rechnungsadresse wegen der Aufbewahrungspflicht bleiben
- gdpr:repair-customers [--execute] zieht protokollierte Loeschungen nach
- DSGVO-Loeschung prueft Superadmin serverseitig; Berater duerfen eine
  Kunden-E-Mail nur beim eigenen Kunden nachtragen
- setFakerMail setzt faker_mail bei geloeschten Eintraegen nicht zurueck

Checkout: "Wie bist du auf uns aufmerksam geworden?" ist kein Pflichtfeld
mehr und wird farbig hervorgehoben.

Deploy: php artisan migrate && php artisan gdpr:repair-customers --execute

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-10-06 12:36:02 +00:00
parent 083d643220
commit 3d0864713b
19 changed files with 635 additions and 90 deletions

View file

@ -4,6 +4,7 @@ namespace App\Services;
use App\Exceptions\GdprDeletionException;
use App\Models\Customer;
use App\Models\Logger;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
@ -13,6 +14,7 @@ use App\Models\UserShop;
use App\Models\UserUpdateEmail;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@ -220,7 +222,10 @@ class GdprDeletionService
* orders_count: int,
* orders_by_row: array<int, int>,
* active_abos: int,
* can_purge: bool
* can_purge: bool,
* gdpr_deleted_rows: int,
* gdpr_deleted_at: Carbon|null,
* all_gdpr_deleted: bool
* }
*/
public function analyzeShoppingUser(ShoppingUser $shoppingUser): array
@ -239,6 +244,7 @@ class GdprDeletionService
$isMember = $rows->contains(fn (ShoppingUser $row) => (int) $row->auth_user_id > 0);
$ordersCount = array_sum($ordersByRow);
$activeAbos = UserAbo::whereIn('shopping_user_id', $rowIds)->where('active', true)->count();
$gdprDeletedRows = $rows->filter(fn (ShoppingUser $row) => $row->isGdprDeleted());
return [
'rows' => $rows,
@ -248,9 +254,76 @@ class GdprDeletionService
'orders_by_row' => $ordersByRow,
'active_abos' => $activeAbos,
'can_purge' => ! $isMember && $ordersCount === 0 && $activeAbos === 0,
'gdpr_deleted_rows' => $gdprDeletedRows->count(),
'gdpr_deleted_at' => $gdprDeletedRows->max('gdpr_deleted_at'),
'all_gdpr_deleted' => $rows->isNotEmpty() && $gdprDeletedRows->count() === $rows->count(),
];
}
/**
* Protokollierte DSGVO-Löschungen von Endkunden nachziehen: Einträge, die vor
* der Löschung angelegt waren und deren Kontaktdaten danach wieder befüllt
* wurden (z. B. durch ein offenes Bearbeiten-Formular), werden erneut
* anonymisiert und markiert. Später angelegte Einträge (neue Bestellung nach
* der Löschung) bleiben unberührt.
*
* @return array<int, array{id: int, number: int|null, deletion_logged_at: string}>
*/
public function repairLoggedShoppingUserDeletions(bool $dryRun = true): array
{
$deletions = Logger::query()
->whereIn('action', ['gdpr-delete-customer', 'gdpr-purge-customer'])
->where('model', ShoppingUser::class)
->selectRaw('model_id, MAX(created_at) as deleted_at')
->groupBy('model_id')
->get();
$repaired = [];
foreach ($deletions as $deletion) {
$target = ShoppingUser::withTrashed()->find($deletion->model_id);
if (! $target || (int) $target->auth_user_id > 0) {
continue;
}
$rows = ShoppingUser::withTrashed()
->where(function ($query) use ($target) {
$query->where('id', $target->id);
if ($target->number > 0) {
$query->orWhere('number', $target->number);
}
})
->where(function ($query) {
$query->whereNull('auth_user_id')->orWhere('auth_user_id', 0);
})
->where('created_at', '<=', $deletion->deleted_at)
->get()
->filter(fn (ShoppingUser $row) => ! $row->isGdprDeleted() || ! str_ends_with((string) $row->billing_email, '@dsgvo.invalid'))
->reject(fn (ShoppingUser $row) => isset($repaired[$row->id]));
if ($rows->isEmpty()) {
continue;
}
foreach ($rows as $row) {
$repaired[$row->id] = [
'id' => $row->id,
'number' => $row->number,
'deletion_logged_at' => (string) $deletion->deleted_at,
];
}
if (! $dryRun) {
DB::transaction(fn () => $this->anonymizeShoppingUserRows($rows));
$this->logDeletion('gdpr-repair-customer', $target->id, ShoppingUser::class, null, null, [
'shopping_user_id' => $target->id,
'shopping_users_anonymized' => $rows->count(),
]);
}
}
return array_values($repaired);
}
/**
* Alle Datensätze eines Endkunden, inkl. soft-deleted: der Eintrag selbst,
* alle Einträge mit derselben Kundennummer und alle Endkunden-Einträge mit
@ -494,6 +567,7 @@ class GdprDeletionService
$row->notice = [];
$row->subscribed = false;
$row->faker_mail = true;
$row->gdpr_deleted_at ??= now();
$row->save();
}