2026-08-24 21:06:11 +03:00
|
|
|
<?php
|
|
|
|
|
|
2026-09-16 01:21:22 +03:00
|
|
|
namespace Modules\Core\Privacy\Services;
|
2026-08-24 21:06:11 +03:00
|
|
|
|
|
|
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
2026-08-24 21:41:23 +03:00
|
|
|
use Illuminate\Support\Facades\Event;
|
2026-09-16 18:51:20 +03:00
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-08-24 21:06:11 +03:00
|
|
|
use Lunar\Base\LunarUser;
|
|
|
|
|
use Lunar\Models\Customer;
|
|
|
|
|
use Modules\Core\Auth\Models\Staff;
|
2026-09-16 18:51:20 +03:00
|
|
|
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
2026-09-16 01:21:22 +03:00
|
|
|
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
|
|
|
|
use Modules\Core\Privacy\DTOs\ErasureReport;
|
|
|
|
|
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
|
|
|
|
use Modules\Core\Privacy\DTOs\UserSubject;
|
2026-09-16 18:51:20 +03:00
|
|
|
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
2026-09-16 01:21:22 +03:00
|
|
|
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
|
|
|
|
|
use Modules\Core\Privacy\Enums\ExportRequestStatus;
|
2026-08-24 21:41:23 +03:00
|
|
|
use Modules\Core\Privacy\Events\UserErasureRequested;
|
2026-08-24 21:06:11 +03:00
|
|
|
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
|
|
|
|
|
use Modules\Core\Privacy\Models\DataErasureRequest;
|
|
|
|
|
use Modules\Core\Privacy\Models\DataExportRequest;
|
2026-09-16 18:51:20 +03:00
|
|
|
use Throwable;
|
2026-08-24 21:06:11 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Entry point for right-of-access and right-of-erasure requests, split into two
|
|
|
|
|
* independent scopes — see docs/privacy.md "User-scope vs Customer-scope":
|
|
|
|
|
*
|
|
|
|
|
* - *ForCustomer(): erases/exports one business account's own data (orders,
|
|
|
|
|
* addresses, the account record itself). Never touches any linked User's
|
|
|
|
|
* login or personal identity — a Customer erasure request must not deactivate
|
|
|
|
|
* or destroy access for anyone who works there.
|
|
|
|
|
* - *ForUser(): erases/exports one individual's own identity (login, name,
|
|
|
|
|
* email) wherever it appears, and detaches them from every Customer account
|
|
|
|
|
* they're linked to as part of erasure — without touching any Customer
|
|
|
|
|
* account's own data or any other User still linked to it.
|
|
|
|
|
*
|
|
|
|
|
* A Customer (business account) can have many linked Users, and one User can be
|
|
|
|
|
* linked to many Customer accounts (B2B multi-seat access — see docs/modules.md
|
|
|
|
|
* "Customer/User Pairing"), so these are genuinely different operations with
|
|
|
|
|
* different blast radii, not one flow with an optional cascade.
|
|
|
|
|
*
|
|
|
|
|
* Both directions are handled as requests, not immediate synchronous actions:
|
|
|
|
|
* requestExport*() queues the (potentially slow) work of gathering every
|
|
|
|
|
* provider's data and writing a file, rather than blocking whatever triggered it.
|
|
|
|
|
* requestErasure*() opens a cancellable grace-period request (deactivating the
|
|
|
|
|
* account for a User-scoped request only — see below) — the same shape as
|
|
|
|
|
* Shopify's own account-deletion flow: a window where the subject can change
|
|
|
|
|
* their mind before anything is actually erased.
|
|
|
|
|
*/
|
|
|
|
|
class PrivacyService
|
|
|
|
|
{
|
|
|
|
|
public function __construct(private readonly PrivacyManager $manager) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a DataExportRequest (fast — one insert) and dispatches
|
|
|
|
|
* ExportDataSubjectJob to do the actual gathering/writing work. The job fires
|
|
|
|
|
* PersonalDataGathered once every provider's data is collected;
|
|
|
|
|
* Modules\Core\Privacy\Listeners\WriteExportToCsvListener turns that into a file
|
|
|
|
|
* and fires PersonalDataExportFileWritten — a consuming app registers its own
|
|
|
|
|
* notification against that event (see docs/privacy.md).
|
|
|
|
|
*/
|
|
|
|
|
public function requestExportForCustomer(Customer $customer): DataExportRequest
|
|
|
|
|
{
|
|
|
|
|
return $this->createExportRequest($customer->getMorphClass(), $customer->id, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function requestExportForUser(Authenticatable&LunarUser $user): DataExportRequest
|
|
|
|
|
{
|
|
|
|
|
return $this->createExportRequest($user->getMorphClass(), $user->id, $user->email);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function createExportRequest(string $subjectType, int $subjectId, ?string $email): DataExportRequest
|
|
|
|
|
{
|
|
|
|
|
$request = DataExportRequest::create([
|
|
|
|
|
'subject_type' => $subjectType,
|
|
|
|
|
'subject_id' => $subjectId,
|
|
|
|
|
'email' => $email,
|
|
|
|
|
'status' => ExportRequestStatus::Pending,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
ExportDataSubjectJob::dispatch($request);
|
|
|
|
|
|
|
|
|
|
return $request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Opens a grace-period erasure request for the Customer's own data. Deactivates
|
|
|
|
|
* NO User — erasing a business account must not destroy anyone's login access,
|
|
|
|
|
* even the account's own primary contact. Nothing is actually erased until
|
|
|
|
|
* privacy:process-erasure-requests picks this up once scheduled_for has
|
|
|
|
|
* passed, unless cancelErasure() is called first.
|
|
|
|
|
*
|
2026-08-24 21:41:23 +03:00
|
|
|
* $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.
|
2026-08-24 21:06:11 +03:00
|
|
|
*/
|
2026-08-24 21:41:23 +03:00
|
|
|
public function requestErasureForCustomer(
|
|
|
|
|
Customer $customer,
|
|
|
|
|
Customer|Staff|(Authenticatable&LunarUser) $requestedBy,
|
|
|
|
|
?int $causedByRequestId = null,
|
|
|
|
|
): DataErasureRequest {
|
2026-08-24 21:06:11 +03:00
|
|
|
return DataErasureRequest::create([
|
|
|
|
|
'subject_type' => $customer->getMorphClass(),
|
|
|
|
|
'subject_id' => $customer->id,
|
|
|
|
|
'email' => null,
|
|
|
|
|
'requested_by_type' => $requestedBy->getMorphClass(),
|
|
|
|
|
'requested_by_id' => $requestedBy->getKey(),
|
|
|
|
|
'status' => ErasureRequestStatus::Pending,
|
|
|
|
|
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
|
2026-08-24 21:41:23 +03:00
|
|
|
'caused_by_request_id' => $causedByRequestId,
|
2026-08-24 21:06:11 +03:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Opens a grace-period erasure request for one individual and deactivates
|
|
|
|
|
* their login immediately (blocks it — see Modules\Core\Auth\Services\
|
|
|
|
|
* UserOtpService — without touching any Customer account's data). $requestedBy
|
|
|
|
|
* is either the User themselves (self-service deletion) or a Staff member
|
|
|
|
|
* acting on their behalf.
|
|
|
|
|
*/
|
|
|
|
|
public function requestErasureForUser(Authenticatable&LunarUser $user, (Authenticatable&LunarUser)|Staff $requestedBy): DataErasureRequest
|
|
|
|
|
{
|
|
|
|
|
$request = DataErasureRequest::create([
|
|
|
|
|
'subject_type' => $user->getMorphClass(),
|
|
|
|
|
'subject_id' => $user->id,
|
|
|
|
|
'email' => $user->email,
|
|
|
|
|
'requested_by_type' => $requestedBy->getMorphClass(),
|
|
|
|
|
'requested_by_id' => $requestedBy->getKey(),
|
|
|
|
|
'status' => ErasureRequestStatus::Pending,
|
|
|
|
|
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->setUserDeactivated($user->id, true);
|
|
|
|
|
|
2026-08-24 21:41:23 +03:00
|
|
|
// 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));
|
|
|
|
|
|
2026-08-24 21:06:11 +03:00
|
|
|
return $request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Erases a Customer's data right now, bypassing the grace period entirely.
|
|
|
|
|
* Staff-only by construction — $requestedBy is typed to Staff specifically,
|
|
|
|
|
* so a self-service/customer-facing code path cannot reach this method at
|
|
|
|
|
* all, only accidentally call it with the wrong actor type and get a
|
|
|
|
|
* compile-time error. This exists for a formal legal request or regulator
|
|
|
|
|
* inquiry that genuinely requires immediate action — not a convenience
|
|
|
|
|
* option for an impatient customer. The grace period is deliberately not
|
|
|
|
|
* skippable from any customer-facing flow; see docs/privacy.md.
|
|
|
|
|
*/
|
|
|
|
|
public function requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport
|
|
|
|
|
{
|
|
|
|
|
$request = DataErasureRequest::create([
|
|
|
|
|
'subject_type' => $customer->getMorphClass(),
|
|
|
|
|
'subject_id' => $customer->id,
|
|
|
|
|
'email' => null,
|
|
|
|
|
'requested_by_type' => $requestedBy->getMorphClass(),
|
|
|
|
|
'requested_by_id' => $requestedBy->getKey(),
|
|
|
|
|
'status' => ErasureRequestStatus::Pending,
|
|
|
|
|
'scheduled_for' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->completeErasure($request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Erases a User's data right now, bypassing the grace period entirely.
|
|
|
|
|
* Staff-only by construction — see requestImmediateErasureForCustomer().
|
2026-08-24 21:41:23 +03:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-08-24 21:06:11 +03:00
|
|
|
*/
|
|
|
|
|
public function requestImmediateErasureForUser(Authenticatable&LunarUser $user, Staff $requestedBy): ErasureReport
|
|
|
|
|
{
|
|
|
|
|
$request = DataErasureRequest::create([
|
|
|
|
|
'subject_type' => $user->getMorphClass(),
|
|
|
|
|
'subject_id' => $user->id,
|
|
|
|
|
'email' => $user->email,
|
|
|
|
|
'requested_by_type' => $requestedBy->getMorphClass(),
|
|
|
|
|
'requested_by_id' => $requestedBy->getKey(),
|
|
|
|
|
'status' => ErasureRequestStatus::Pending,
|
|
|
|
|
'scheduled_for' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->setUserDeactivated($user->id, true);
|
|
|
|
|
|
2026-08-24 21:41:23 +03:00
|
|
|
// 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));
|
|
|
|
|
|
2026-08-24 21:06:11 +03:00
|
|
|
return $this->completeErasure($request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cancels a pending request. For a User-scoped request, reactivates the
|
|
|
|
|
* account (see requestErasureForUser()). A Customer-scoped request never
|
|
|
|
|
* deactivated anything, so there's nothing to reactivate for it. No-op
|
|
|
|
|
* (returns false) if the request isn't pending — e.g. already completed or
|
|
|
|
|
* cancelled.
|
|
|
|
|
*/
|
|
|
|
|
public function cancelErasure(DataErasureRequest $request): bool
|
|
|
|
|
{
|
|
|
|
|
if (! $request->isPending()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$request->update([
|
|
|
|
|
'status' => ErasureRequestStatus::Cancelled,
|
|
|
|
|
'cancelled_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (! $request->isForCustomer()) {
|
|
|
|
|
$this->setUserDeactivated($request->subject_id, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Actually erases the data for a due request: runs every registered
|
|
|
|
|
* provider's *ForCustomer() or *ForUser() method (whichever matches the
|
|
|
|
|
* request's subject), records the outcome on the request, and marks it
|
|
|
|
|
* completed. Called by privacy:process-erasure-requests — not meant to be
|
|
|
|
|
* called directly for a request that hasn't passed its grace period, since
|
|
|
|
|
* that defeats the point of the window; ProcessErasureRequestsCommand
|
|
|
|
|
* enforces isDue() before calling this.
|
2026-09-16 18:51:20 +03:00
|
|
|
*
|
|
|
|
|
* Each provider call is caught individually — a provider throwing (a bug,
|
|
|
|
|
* an unexpected DB state) converts to ErasureOutcome::Failed rather than
|
|
|
|
|
* aborting the whole array_map, so one broken provider never discards
|
|
|
|
|
* every OTHER provider's already-completed erasure for this same request.
|
|
|
|
|
* Without this, the $request->update() below would never run at all on a
|
|
|
|
|
* throw, silently leaving providers that already succeeded unrecorded and
|
|
|
|
|
* the request stuck Pending forever. Logged via Log::error() so a thrown
|
|
|
|
|
* provider is still visible to staff, not just swallowed into "Failed."
|
2026-08-24 21:06:11 +03:00
|
|
|
*/
|
|
|
|
|
public function completeErasure(DataErasureRequest $request): ErasureReport
|
|
|
|
|
{
|
|
|
|
|
if ($request->isForCustomer()) {
|
|
|
|
|
$subject = new CustomerSubject(customerId: $request->subject_id);
|
2026-09-16 18:51:20 +03:00
|
|
|
$results = array_map(
|
|
|
|
|
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForCustomer', $subject),
|
|
|
|
|
$this->manager->providers()
|
|
|
|
|
);
|
2026-08-24 21:06:11 +03:00
|
|
|
} else {
|
|
|
|
|
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
|
2026-09-16 18:51:20 +03:00
|
|
|
$results = array_map(
|
|
|
|
|
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject),
|
|
|
|
|
$this->manager->providers()
|
|
|
|
|
);
|
2026-08-24 21:06:11 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$report = new ErasureReport($subject, $results);
|
|
|
|
|
|
|
|
|
|
$request->update([
|
|
|
|
|
'status' => ErasureRequestStatus::Completed,
|
|
|
|
|
'completed_at' => now(),
|
|
|
|
|
'report' => array_map(
|
|
|
|
|
fn (ProviderErasureResult $result) => [
|
|
|
|
|
'provider' => $result->provider,
|
|
|
|
|
'outcome' => $result->outcome->value,
|
|
|
|
|
'reason' => $result->reason,
|
|
|
|
|
],
|
|
|
|
|
$results
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $report;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function setUserDeactivated(int $userId, bool $deactivated): void
|
|
|
|
|
{
|
|
|
|
|
$model = config('auth.providers.users.model');
|
|
|
|
|
|
|
|
|
|
/** @var class-string<Model> $model */
|
|
|
|
|
$model::where('id', $userId)->update([
|
|
|
|
|
'deactivated_at' => $deactivated ? now() : null,
|
|
|
|
|
]);
|
|
|
|
|
}
|
2026-09-16 18:51:20 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param 'eraseForCustomer'|'eraseForUser' $method
|
|
|
|
|
*/
|
|
|
|
|
private function safeErase(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderErasureResult
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
return $provider->{$method}($subject);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
Log::error("Privacy provider {$provider->name()}::{$method}() threw during erasure", [
|
|
|
|
|
'provider' => $provider->name(),
|
|
|
|
|
'exception' => $e,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return new ProviderErasureResult($provider->name(), ErasureOutcome::Failed, $e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-24 21:06:11 +03:00
|
|
|
}
|