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. * * $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|(Authenticatable&LunarUser) $requestedBy, ?int $causedByRequestId = null, ): DataErasureRequest { 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)), 'caused_by_request_id' => $causedByRequestId, ]); } /** * 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); // 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; } /** * 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(). * * 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 { $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); // 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); } /** * 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. * * 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." */ public function completeErasure(DataErasureRequest $request): ErasureReport { if ($request->isForCustomer()) { $subject = new CustomerSubject(customerId: $request->subject_id); $results = array_map( fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForCustomer', $subject), $this->manager->providers() ); } else { $subject = new UserSubject(userId: $request->subject_id, email: $request->email); $results = array_map( fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject), $this->manager->providers() ); } $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::where('id', $userId)->update([ 'deactivated_at' => $deactivated ? now() : null, ]); } /** * @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()); } } }