Feature: Handling Cases for User to Customer Relationships

This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
This commit is contained in:
2026-08-24 21:41:23 +03:00
parent 9f540cbaa4
commit af380a7fa0
8 changed files with 256 additions and 15 deletions
+43 -4
View File
@@ -4,9 +4,11 @@ namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -80,11 +82,21 @@ class PrivacyService
* privacy:process-erasure-requests picks this up once scheduled_for has
* passed, unless cancelErasure() is called first.
*
* $requestedBy is either the Customer themselves (self-service deletion) or a
* Staff member acting on their behalf.
* $requestedBy is the Customer themselves (self-service deletion), a Staff
* member acting on their behalf, or a User — the User case is for
* Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener, where erasing
* a User leaves a Customer with no remaining user: the User is a real,
* meaningful "who caused this," even though they didn't directly request the
* Customer's own erasure. $causedByRequestId links a cascade-created request
* back to the User erasure request that triggered it, so
* CancelErasureOnLoginListener can revert exactly that cascade on login,
* without touching an unrelated, independently-requested Customer erasure.
*/
public function requestErasureForCustomer(Customer $customer, Customer|Staff $requestedBy): DataErasureRequest
{
public function requestErasureForCustomer(
Customer $customer,
Customer|Staff|(Authenticatable&LunarUser) $requestedBy,
?int $causedByRequestId = null,
): DataErasureRequest {
return DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
@@ -93,6 +105,7 @@ class PrivacyService
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
'caused_by_request_id' => $causedByRequestId,
]);
}
@@ -117,6 +130,13 @@ class PrivacyService
$this->setUserDeactivated($user->id, true);
// CascadeCustomerErasureListener implements ShouldQueue, so this just
// enqueues a job rather than running inline — no transaction wrapping
// needed here, since the cascade check happens as an independent,
// separately-retryable unit of work after this request is already
// committed, not as part of this same call.
Event::dispatch(new UserErasureRequested($request));
return $request;
}
@@ -148,6 +168,15 @@ class PrivacyService
/**
* Erases a User's data right now, bypassing the grace period entirely.
* Staff-only by construction — see requestImmediateErasureForCustomer().
*
* Still fires UserErasureRequested — and deliberately BEFORE completeErasure()
* runs, not after — so Modules\Core\Privacy\Listeners\
* CascadeCustomerErasureListener sees the User still linked to their Customers
* (completeErasure() -> CustomerDataProvider::eraseForUser() is what detaches
* the pivot). The User's own erasure is immediate, but any Customer left
* orphaned by it still gets a normal grace-period erasure request, not an
* immediate one — an orphaned Customer isn't itself the subject of the
* original urgent request.
*/
public function requestImmediateErasureForUser(Authenticatable&LunarUser $user, Staff $requestedBy): ErasureReport
{
@@ -163,6 +192,16 @@ class PrivacyService
$this->setUserDeactivated($user->id, true);
// Queued (see requestErasureForUser()) — the cascade job may run before
// or after completeErasure() below detaches the pivot. Either is fine:
// CascadeCustomerErasureListener re-reads $user->customers fresh when it
// runs, so it only cascades if this User is still linked at that point.
// If completeErasure() detaches first, the queued job simply finds no
// Customers left to check and no-ops — never a wrong cascade, at worst a
// missed one on a race that immediate (staff-triggered, rare) erasure
// doesn't need to guard against as tightly as the grace-period path.
Event::dispatch(new UserErasureRequested($request));
return $this->completeErasure($request);
}