diff --git a/composer.json b/composer.json index 29cc4b6..6d6b840 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", - "Modules\\Core\\Providers\\ReviewServiceProvider" + "Modules\\Core\\Providers\\ReviewServiceProvider", + "Modules\\Core\\Providers\\PrivacyServiceProvider" ] } }, diff --git a/config/core.php b/config/core.php index 5e0f027..2c96443 100644 --- a/config/core.php +++ b/config/core.php @@ -16,4 +16,33 @@ return [ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Privacy / GDPR data-subject requests + |-------------------------------------------------------------------------- + | + | 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider + | that should be consulted for right-of-access/right-of-erasure requests. A + | module never needs to be known to core in advance — it just adds its own + | provider class here, the same way config('lunar.search.indexers') maps a + | model to its indexer. See docs/privacy.md. + | + | 'grace_period_days' is how long an erasure request stays cancellable + | (account deactivated, not yet erased) before it's actually processed by + | the privacy:process-erasure-requests scheduled command. + | + */ + + 'privacy' => [ + 'providers' => [ + \Modules\Core\Privacy\Providers\CustomerDataProvider::class, + \Modules\Core\Privacy\Providers\AddressDataProvider::class, + \Modules\Core\Privacy\Providers\OrderDataProvider::class, + \Modules\Core\Privacy\Providers\CartDataProvider::class, + \Modules\Core\Privacy\Providers\ReviewDataProvider::class, + ], + + 'grace_period_days' => 30, + ], + ]; diff --git a/database/migrations/2026_08_24_000001_add_deactivated_at_to_users_table.php b/database/migrations/2026_08_24_000001_add_deactivated_at_to_users_table.php new file mode 100644 index 0000000..10ed932 --- /dev/null +++ b/database/migrations/2026_08_24_000001_add_deactivated_at_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('deactivated_at')->nullable()->after('otp_expires_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('deactivated_at'); + }); + } +}; diff --git a/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php b/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php new file mode 100644 index 0000000..361fa19 --- /dev/null +++ b/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php @@ -0,0 +1,47 @@ +id(); + // Polymorphic, not a fixed customer_id — a request targets either a + // Lunar Customer (business account) or a User (individual), never + // both at once. See docs/privacy.md "User-scope vs Customer-scope". + $table->string('subject_type'); + $table->unsignedBigInteger('subject_id'); + // Snapshot, not a live-looked-up value — the subject's email may + // change or the record may be gone by the time this is read. + $table->string('email')->nullable(); + // Who asked for this: the subject themselves (self-service deletion) + // or a staff member acting on their behalf. Plain nullable type+id + // columns rather than morphs() — only ever one of two concrete actor + // types, not an open-ended polymorphic set. + $table->string('requested_by_type'); + $table->unsignedBigInteger('requested_by_id'); + $table->string('status')->default('pending'); + // now() + config('core.privacy.grace_period_days') at creation time — + // when privacy:process-erasure-requests will actually run this. + $table->timestamp('scheduled_for'); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + // Every provider's outcome, written once the request completes — + // see Modules\Core\Privacy\ErasureReport. Null until then. + $table->json('report')->nullable(); + $table->timestamps(); + + $table->index(['status', 'scheduled_for']); + $table->index(['subject_type', 'subject_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('data_erasure_requests'); + } +}; diff --git a/database/migrations/2026_08_24_000003_create_data_export_requests_table.php b/database/migrations/2026_08_24_000003_create_data_export_requests_table.php new file mode 100644 index 0000000..66b79d8 --- /dev/null +++ b/database/migrations/2026_08_24_000003_create_data_export_requests_table.php @@ -0,0 +1,36 @@ +id(); + // Polymorphic, not a fixed customer_id — see data_erasure_requests + // for the same shape and reasoning. + $table->string('subject_type'); + $table->unsignedBigInteger('subject_id'); + // Snapshot, not a live lookup — same reasoning as + // data_erasure_requests.email (see that migration). + $table->string('email')->nullable(); + $table->string('status')->default('pending'); + // Storage path of the assembled export .zip, set once the queued job + // finishes. Null while pending. + $table->string('file_path')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index('status'); + $table->index(['subject_type', 'subject_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('data_export_requests'); + } +}; diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..e0b9fe8 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,318 @@ +# Privacy / GDPR Data-Subject Requests + +`Modules\Core\Privacy` implements the right of access (export) and right of erasure for +customers, as an extensible contract rather than a fixed list of tables — any module (core, +or a future ERP/banking/etc. module) can register its own data without core knowing it exists. + +--- + +## User-scope vs Customer-scope — two genuinely different operations + +A Lunar `Customer` (business account: orders, addresses, buyer record) and a `User` (individual +login identity) are linked many-to-many via the `customer_user` pivot (see `docs/modules.md` +"Customer/User Pairing") — **one User can belong to many Customer accounts, and one Customer +account can have many linked Users.** This is the real shape of B2B multi-seat access: a person +can have login access to several separate business accounts, and a business account can have +several employees each with their own login. + +That means "delete my personal data" and "delete this business account" are not the same request, +and conflating them is actively wrong: + +- **Erasing a Customer must never touch any linked User's login or identity.** Erasing "Acme + Corp" must not deactivate or destroy access for the employees who work there — and must not + touch any *other* Customer account, even one sharing some of the same Users. +- **Erasing a User must never touch any Customer account's own data.** John asking to delete + *his* account must clear his name/email/login wherever it appears — and correctly end his + membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's + orders or addresses, and must not affect any other employee still linked to Acme Corp. + +Every part of this module is split along that line — a `PersonalDataProvider`, a `PrivacyService` +method, a request record — is always explicitly **for a Customer** or **for a User**, never both +at once, and never one with an implicit cascade into the other. + +--- + +## Why an extensible contract, not a hardcoded script + +A GDPR erasure/export request has to touch every module that holds personal data, but core can't +know in advance what future modules will exist or what data they'll hold — and different data +needs fundamentally different handling (freely erasable PII vs. financial records that must be +pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's +deliberately no central taxonomy for this in the contract — each module owns its own retention +judgment, since only the module that owns a table actually knows its legal requirements. + +`Modules\Core\Privacy\Contracts\PersonalDataProvider` is the whole contract: + +```php +interface PersonalDataProvider +{ + public function name(): string; + + public function exportForUser(UserSubject $subject): ProviderExportResult; + public function exportForCustomer(CustomerSubject $subject): ProviderExportResult; + + public function eraseForUser(UserSubject $subject): ProviderErasureResult; + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult; +} +``` + +Every provider implements all four methods. A provider with nothing relevant to one scope +implements that method as a no-op — `ErasureOutcome::Skipped` with a reason for erase, an empty +payload for export (e.g. `AddressDataProvider::eraseForUser()`, since addresses belong to a +Customer, not an individual). + +A module registers by adding its provider class to `config('core.privacy.providers')` — the +same shape as Lunar's own `config('lunar.search.indexers')` model→indexer map: + +```php +// config/core.php +'privacy' => [ + 'providers' => [ + \Modules\Core\Privacy\Providers\CustomerDataProvider::class, + \Modules\Core\Privacy\Providers\AddressDataProvider::class, + \Modules\Core\Privacy\Providers\OrderDataProvider::class, + \Modules\Core\Privacy\Providers\CartDataProvider::class, + \Modules\Core\Privacy\Providers\ReviewDataProvider::class, + // A future module just adds its own provider here. + ], +], +``` + +`PrivacyManager` resolves each class via the container and asserts every `name()` is unique — +two providers registering the same name throws, so a naming collision fails loudly at +resolution time rather than silently overwriting one provider's data in an export/report. + +--- + +## `UserSubject` and `CustomerSubject` — identifying "the person" vs "the account" + +Two separate value objects, not one — each deliberately carries only what its own scope needs, so +a provider can't accidentally reach across the boundary: + +```php +class CustomerSubject +{ + public readonly int $customerId; + // No userIds, no email — Customer-scope has no business knowing about logins. +} + +class UserSubject +{ + public readonly int $userId; + public readonly ?string $email; + // No customerId — one User can be linked to many Customers; a provider that + // needs to know which ones looks that up itself (e.g. to detach the pivot), + // rather than this value object assuming or privileging any single one. +} +``` + +`CustomerSubject::forCustomer(Customer $customer)` and `UserSubject::forUser($user)` build one +from the record staff (or the person themselves) look up. + +--- + +## Providers shipped in core + +| Provider | `name()` | Covers | Customer-scope | User-scope | +|---|---|---|---|---| +| `CustomerDataProvider` | `customer` | `lunar_customers`, and separately the `User`'s own name/email | Erases the account's own fields only | Erases that User's name/email only, and detaches them from every linked Customer | +| `AddressDataProvider` | `addresses` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual | +| `OrderDataProvider` | `orders` | `lunar_orders`, `lunar_order_addresses` | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual | +| `CartDataProvider` | `carts` | `lunar_cart_addresses` | Erased | Skipped — belongs to a Customer, not an individual | +| `ReviewDataProvider` | `reviews` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept | + +`CustomerDataProvider` is the one provider that implements both scopes meaningfully, and keeps +them from touching each other — see the class docblock for the full reasoning. + +**`ReviewDataProvider` needs review.** It moved from Customer-scope to User-scope on the +reasoning that authorship is a personal attribute, not a business-account attribute — but this +hasn't been fully validated against how reviews are actually attributed in this codebase. The +class carries a `NEEDS REVIEW` note; revisit before relying on it for a real request. + +### Orders are pseudonymized, not deleted + +GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover, +when a legal obligation requires it — tax/accounting law generally requires invoices be kept for +several years. `OrderDataProvider::eraseForCustomer()` clears the free-text PII fields on `Order`/ +`OrderAddress` (`customer_reference`, `notes`, name/address/contact fields) but leaves the order +row, totals, line items, and tax data fully intact. Its `ProviderErasureResult` reports +`ErasureOutcome::Pseudonymized`, not `Erased` — a compliance report or admin UI can see exactly +why an order wasn't deleted without reading `OrderDataProvider`'s source. + +### Reviews are matched by email — a real, documented limitation + +`ProductReview` has no FK to Customer/User at all (see `docs/product-listing.md` "Reviews") — +it's deliberately anonymous, just free-text `reviewer_name`/`reviewer_email`. `ReviewDataProvider` +matches by `reviewer_email` against `UserSubject::$email`; a review submitted under a different +email than the one on file simply won't be found. There's no stronger signal available without +changing `ProductReview`'s schema. + +### Staff/employee data is out of scope + +`Staff` (admin/panel employees) is never a `UserSubject`/`CustomerSubject` at all — this feature +is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's +own data (a different HR/access-management concern) isn't reachable through this flow. + +--- + +## Erasure isn't immediate — a cancellable grace period + +`PrivacyService` has parallel methods for each scope: `requestErasureForCustomer()` / +`requestErasureForUser()`. Neither erases anything immediately. Each opens a `DataErasureRequest` +(`pending`, `scheduled_for` = now + `config('core.privacy.grace_period_days')`, default 30). This +mirrors Shopify's own account-deletion flow: a window where the subject can change their mind +before anything is actually erased. + +**Only the User-scoped request deactivates a login.** `requestErasureForCustomer()` deactivates +no one — a business-account erasure must never block anyone's access. +`requestErasureForUser()` deactivates that one User's login (blocks it — see +`Modules\Core\Auth\Services\UserOtpService` — nothing else changes). + +```php +use Modules\Core\Privacy\PrivacyService; + +$service = app(PrivacyService::class); + +// Customer-scoped: either the Customer itself (self-service) or a Staff member. +$request = $service->requestErasureForCustomer($customer, $requestedBy); + +// User-scoped: either the User itself (self-service) or a Staff member. +$request = $service->requestErasureForUser($user, $requestedBy); + +// Cancel before scheduled_for — for a User-scoped request, reactivates the +// account. A Customer-scoped request never deactivated anything, so there's +// nothing to reactivate for it. +$service->cancelErasure($request); +``` + +### Logging back in during the grace period cancels the request automatically + +Authentication is never blocked by deactivation — `UserOtpService::validate()` still requires +the correct OTP code. Once validated, it dispatches `Modules\Core\Auth\Events\UserAuthenticated`; +`Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener` (registered in +`PrivacyServiceProvider`) looks for a pending request keyed on *that User's own id* — never a +Customer-scoped one, since Customer-scope never deactivates a login in the first place — and +calls `cancelErasure()` on it. Logging back in **is** the "I changed my mind" action — no +separate UI/flow needed for reactivation. + +### Processing due requests — one job per request + +`php artisan boboko:privacy:process-erasure-requests` finds every `pending` request whose +`scheduled_for` has passed and dispatches one `Modules\Core\Privacy\Jobs\EraseDataSubjectJob` per +request — it does not run `completeErasure()` inline itself. Each job independently calls +`PrivacyService::completeErasure()`, which checks the request's polymorphic `subject` and calls +either every registered provider's `eraseForCustomer()` or `eraseForUser()`, writing the full +per-provider outcome onto the request's `report` column and marking it `completed`. One job per +request means one request's failure (a provider throwing, a DB error) doesn't block or crash +processing of the others, and Laravel's normal per-job retry/failure handling applies to each +request independently. This package doesn't register a schedule itself; each consuming app wires +the command into its own scheduler (daily is reasonable), the same way it owns any other +scheduled task. + +### Immediate erasure — staff-only, not self-service + +`requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport` and +`requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport` bypass the grace +period entirely and erase right away. Both are `Staff`-only **by type**, not just by convention — +their signatures take `Staff $requestedBy` specifically (not the union type the grace-period +methods accept), so a self-service/customer-facing code path can't reach either one even by +accident; calling with a `Customer`/`User` actor is a compile-time type error, not a runtime +check to remember. + +This exists for a formal legal request or regulator inquiry that genuinely requires immediate +action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without +undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed, +cancellable hold before executing a self-service request is a widely-used, generally accepted +pattern (the same one Shopify and most major platforms use) — it is **not** offered as a +same-click alternative on the self-service deletion flow, since doing so would mostly defeat the +grace period's purpose (protecting an impulsive requester from themselves). If a subject +explicitly insists on immediate deletion, that's a staff/support decision to make on the record +via one of these methods, not a checkbox exposed to every customer. + +```php +$report = $service->requestImmediateErasureForCustomer($customer, $staffMember); +$report = $service->requestImmediateErasureForUser($user, $staffMember); +// Both run synchronously — no queueing, no grace period. $report is the same +// ErasureReport completeErasure() would produce. +``` + +--- + +## Export — queued, not synchronous + +Export gathers real data across every registered provider — potentially slow, and there's no +reason to block whatever request triggered it (a customer clicking "export my data," an API +call). `requestExportForCustomer()`/`requestExportForUser()` are fast synchronous calls that only +create a `DataExportRequest` row and dispatch the actual work: + +```php +$request = $service->requestExportForCustomer($customer); +$request = $service->requestExportForUser($user); +// $request->status is 'pending'; nothing has been gathered yet. +``` + +### The event chain + +1. **`ExportDataSubjectJob`** (queued) checks the request's polymorphic `subject` and calls every + registered provider's `exportForCustomer()` or `exportForUser()` — all sequentially, in this + one job, not fanned out into one job per provider. Per-subject export work is small (a handful + of indexed queries per provider), so there's no real parallelism win, and one job means + "finished" is just "`handle()` returned," with no `Bus::batch()`/completion-counting needed. If + a future provider ever does something genuinely slow (an external API call, a generated PDF), + that's the point to reconsider a per-provider batch — not before. +2. Once every provider's data is gathered, the job fires **`PersonalDataGathered`** + (carries the request and the assembled `ExportReport`) — no file exists yet. +3. **`Modules\Core\Privacy\Listeners\WriteExportToCsvListener`** (registered in + `PrivacyServiceProvider`) handles that event: turns each provider's data into its own CSV (via + the generic `Modules\Core\Export\CsvWriter` — see below), zips them together, writes the zip to + `storage/app/exports/privacy/`, and updates the request (`status: completed`, `file_path`). + This is its own listener — not inline in the job — so the export *format* is swappable (an app + could unregister this and register a JSON-only listener instead) without touching how data is + gathered. +4. Once the file exists, that listener fires **`PersonalDataExportFileWritten`**. +5. Core has no opinion on how the subject is told. A consuming app registers its own notification + against `PersonalDataExportFileWritten` via `Modules\Core\Notification\NotificationRegistry` — + the same pattern as `App\Notifications\QuestionnaireResultsSentNotification` listening on + `App\Events\QuestionnaireResultsSent` (see `boboko-test` for a working example). Core + deliberately does not send an email itself. + +### CSV shape + +Every provider's `data` is either a list of associative arrays (addresses, orders, reviews — each +item becomes a row) or a single associative array (customer — becomes one row). Any nested array +value within a row (e.g. an order's `addresses` sub-array) is JSON-encoded into that one cell +rather than exploded into further columns — a generic, provider-agnostic rule in +`WriteExportToCsvListener`, not something each provider has to think about. + +### `Modules\Core\Export\CsvWriter` — a generic, reusable piece + +`CsvWriter::write(array $columns, iterable $rows, string $path)` has no knowledge of GDPR, +customers, or Lunar at all — a caller supplies a schema (`CsvColumn[]`, each just a header plus a +closure that pulls that column's value out of one record) and any iterable data source. It's used +here by `WriteExportToCsvListener`, but is equally usable for an unrelated future need — an admin +bulk catalog export, an accounting handoff — by supplying a different schema and row source; +nothing about it is GDPR-specific. + +--- + +## Audit trail + +`DataErasureRequest` (`data_erasure_requests`) and `DataExportRequest` (`data_export_requests`) +are the audit records for erasure and export respectively. Both have a polymorphic `subject` +(`subject_type`/`subject_id`, pointing at either a Lunar `Customer` or a `User` — never both) — +`subject_type`/`subject_id`/`email` are stored as a **snapshot**, not looked up live, since the +whole point is for these tables to remain readable after the record they're about has been +erased. `DataErasureRequest::isForCustomer()` tells you which scope a given request is. + +`DataErasureRequest.requested_by_type`/`requested_by_id` capture who asked for it (the subject +themselves, self-service, or `Staff` acting on their behalf) at request time. +`DataErasureRequest.report` holds the full per-provider outcome once `completeErasure()` runs; +`DataExportRequest.file_path` points at the generated zip once `WriteExportToCsvListener` +finishes. + +**Not yet built**: a standalone "leave/remove from a Customer account" action — unlinking a User +from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account +admin removing someone) — is a related but separate, smaller feature, deliberately out of scope +for this module so far. It shares the same pivot-detach primitive `CustomerDataProvider:: +eraseForUser()` already uses as part of a full erasure, but as a standalone action it doesn't +exist yet. diff --git a/src/Auth/Events/UserAuthenticated.php b/src/Auth/Events/UserAuthenticated.php new file mode 100644 index 0000000..2aab2d4 --- /dev/null +++ b/src/Auth/Events/UserAuthenticated.php @@ -0,0 +1,25 @@ +customers to resolve any pending request. Typed as + * Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling + * UserCreated event) specifically because that listener depends on it — every real + * User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"), + * and User is the only Authenticatable entity in this project (Customer is not — + * see docs/modules.md "Customer/User Pairing"). + */ +class UserAuthenticated +{ + public function __construct( + public readonly Authenticatable&LunarUser $user, + ) {} +} diff --git a/src/Auth/Services/UserOtpService.php b/src/Auth/Services/UserOtpService.php index b5a2b1d..aaa8b18 100644 --- a/src/Auth/Services/UserOtpService.php +++ b/src/Auth/Services/UserOtpService.php @@ -2,7 +2,9 @@ namespace Modules\Core\Auth\Services; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; +use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Mail\UserOtpMail; class UserOtpService @@ -43,6 +45,8 @@ class UserOtpService $user->otp_expires_at = null; $user->save(); + Event::dispatch(new UserAuthenticated($user)); + return $user; } } diff --git a/src/Command/ProcessErasureRequestsCommand.php b/src/Command/ProcessErasureRequestsCommand.php new file mode 100644 index 0000000..ff13523 --- /dev/null +++ b/src/Command/ProcessErasureRequestsCommand.php @@ -0,0 +1,47 @@ +where('scheduled_for', '<=', now()) + ->get(); + + if ($due->isEmpty()) { + $this->info('No due erasure requests.'); + + return; + } + + foreach ($due as $request) { + EraseDataSubjectJob::dispatch($request); + + $scope = $request->isForCustomer() ? 'customer' : 'user'; + $this->info("Dispatched erasure job for {$scope} #{$request->subject_id} (request #{$request->id})"); + } + + $this->info('Dispatched '.$due->count().' erasure job(s).'); + } +} diff --git a/src/Export/CsvColumn.php b/src/Export/CsvColumn.php new file mode 100644 index 0000000..4d796c3 --- /dev/null +++ b/src/Export/CsvColumn.php @@ -0,0 +1,21 @@ + CSV file writer. No knowledge of any domain (GDPR, + * catalog, accounting, ...) — a caller supplies the schema (CsvColumn[]) and the + * data source (any iterable of records), and this writes one CSV. Reusable for + * any future bulk-export need without modification. + */ +class CsvWriter +{ + /** + * @param array $columns + * @param iterable $rows + */ + public function write(array $columns, iterable $rows, string $path): void + { + $handle = fopen($path, 'w'); + + fputcsv($handle, array_map(fn (CsvColumn $column) => $column->header, $columns)); + + foreach ($rows as $row) { + fputcsv($handle, array_map( + fn (CsvColumn $column) => $this->stringify(($column->value)($row)), + $columns + )); + } + + fclose($handle); + } + + private function stringify(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_array($value)) { + return json_encode($value); + } + + return (string) $value; + } +} diff --git a/src/Privacy/Contracts/PersonalDataProvider.php b/src/Privacy/Contracts/PersonalDataProvider.php new file mode 100644 index 0000000..02e24b7 --- /dev/null +++ b/src/Privacy/Contracts/PersonalDataProvider.php @@ -0,0 +1,55 @@ +id); + } +} diff --git a/src/Privacy/ErasureOutcome.php b/src/Privacy/ErasureOutcome.php new file mode 100644 index 0000000..e2532e0 --- /dev/null +++ b/src/Privacy/ErasureOutcome.php @@ -0,0 +1,16 @@ + $results + */ + public function __construct( + public readonly UserSubject|CustomerSubject $subject, + public readonly array $results, + ) {} + + /** + * @return array + */ + public function retained(): array + { + return array_values(array_filter( + $this->results, + fn (ProviderErasureResult $result) => $result->outcome === ErasureOutcome::Retained + )); + } +} diff --git a/src/Privacy/ErasureRequestStatus.php b/src/Privacy/ErasureRequestStatus.php new file mode 100644 index 0000000..7b6507a --- /dev/null +++ b/src/Privacy/ErasureRequestStatus.php @@ -0,0 +1,10 @@ + $results + */ + public function __construct( + public readonly UserSubject|CustomerSubject $subject, + public readonly array $results, + ) {} + + /** + * @return array> keyed by provider name + */ + public function toArray(): array + { + $data = []; + + foreach ($this->results as $result) { + $data[$result->provider] = $result->data; + } + + return $data; + } +} diff --git a/src/Privacy/ExportRequestStatus.php b/src/Privacy/ExportRequestStatus.php new file mode 100644 index 0000000..1c966da --- /dev/null +++ b/src/Privacy/ExportRequestStatus.php @@ -0,0 +1,10 @@ +completeErasure($this->request); + } +} diff --git a/src/Privacy/Jobs/ExportDataSubjectJob.php b/src/Privacy/Jobs/ExportDataSubjectJob.php new file mode 100644 index 0000000..12cf170 --- /dev/null +++ b/src/Privacy/Jobs/ExportDataSubjectJob.php @@ -0,0 +1,67 @@ +request->isForCustomer()) { + $subject = new CustomerSubject(customerId: $this->request->subject_id); + $results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers()); + } else { + $subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email); + $results = array_map(fn ($provider) => $provider->exportForUser($subject), $manager->providers()); + } + + Event::dispatch(new PersonalDataGathered( + $this->request, + new ExportReport($subject, $results) + )); + } + + public function failed(\Throwable $exception): void + { + $this->request->update(['status' => ExportRequestStatus::Failed]); + } +} diff --git a/src/Privacy/Listeners/CancelErasureOnLoginListener.php b/src/Privacy/Listeners/CancelErasureOnLoginListener.php new file mode 100644 index 0000000..b79b813 --- /dev/null +++ b/src/Privacy/Listeners/CancelErasureOnLoginListener.php @@ -0,0 +1,40 @@ +user->getMorphClass()) + ->where('subject_id', $event->user->id) + ->where('status', ErasureRequestStatus::Pending) + ->latest() + ->first(); + + if ($request) { + $this->privacyService->cancelErasure($request); + } + } +} diff --git a/src/Privacy/Listeners/WriteExportToCsvListener.php b/src/Privacy/Listeners/WriteExportToCsvListener.php new file mode 100644 index 0000000..6c053cf --- /dev/null +++ b/src/Privacy/Listeners/WriteExportToCsvListener.php @@ -0,0 +1,92 @@ +path('exports/privacy'); + + if (! is_dir($exportDir)) { + mkdir($exportDir, 0755, true); + } + + $stamp = now()->format('Y_m_d_His'); + $zipPath = "{$exportDir}/export_{$event->request->id}_{$stamp}.zip"; + + $zip = new ZipArchive; + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + foreach ($event->report->results as $result) { + $csvPath = "{$exportDir}/{$result->provider}_{$stamp}.csv"; + + $this->writer->write($this->columnsFor($result->data), $this->rowsFor($result->data), $csvPath); + + $zip->addFile($csvPath, "{$result->provider}.csv"); + } + + $zip->close(); + + foreach ($event->report->results as $result) { + @unlink("{$exportDir}/{$result->provider}_{$stamp}.csv"); + } + + $event->request->update([ + 'status' => ExportRequestStatus::Completed, + 'file_path' => $zipPath, + 'completed_at' => now(), + ]); + + Event::dispatch(new PersonalDataExportFileWritten($event->request)); + } + + /** + * @return array + */ + private function rowsFor(array $data): array + { + // A list of records (addresses, orders, reviews) -> those are the rows. + // A single associative record (customer) -> one row. + return array_is_list($data) ? $data : [$data]; + } + + /** + * @return array + */ + private function columnsFor(array $data): array + { + $sample = array_is_list($data) ? ($data[0] ?? []) : $data; + + return array_map( + fn (string $key) => new CsvColumn($key, fn (array $row) => $row[$key] ?? null), + array_keys($sample) + ); + } +} diff --git a/src/Privacy/Models/DataErasureRequest.php b/src/Privacy/Models/DataErasureRequest.php new file mode 100644 index 0000000..7a7a5a0 --- /dev/null +++ b/src/Privacy/Models/DataErasureRequest.php @@ -0,0 +1,61 @@ + ErasureRequestStatus::class, + 'scheduled_for' => 'datetime', + 'cancelled_at' => 'datetime', + 'completed_at' => 'datetime', + 'report' => 'array', + ]; + + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id'); + } + + public function requestedBy(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'requested_by_type', 'requested_by_id'); + } + + public function isForCustomer(): bool + { + return $this->subject_type === (new Customer)->getMorphClass(); + } + + public function isPending(): bool + { + return $this->status === ErasureRequestStatus::Pending; + } + + public function isDue(): bool + { + return $this->isPending() && $this->scheduled_for->isPast(); + } +} diff --git a/src/Privacy/Models/DataExportRequest.php b/src/Privacy/Models/DataExportRequest.php new file mode 100644 index 0000000..74158de --- /dev/null +++ b/src/Privacy/Models/DataExportRequest.php @@ -0,0 +1,37 @@ + ExportRequestStatus::class, + 'completed_at' => 'datetime', + ]; + + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id'); + } + + public function isForCustomer(): bool + { + return $this->subject_type === (new Customer)->getMorphClass(); + } +} diff --git a/src/Privacy/PrivacyManager.php b/src/Privacy/PrivacyManager.php new file mode 100644 index 0000000..fcca184 --- /dev/null +++ b/src/Privacy/PrivacyManager.php @@ -0,0 +1,50 @@ +indexer map, just + * a plain list since a provider isn't keyed to one model. Core never references a + * specific provider class; a future ERP/banking/etc. module just adds its own + * provider class to that config array and PrivacyService picks it up automatically. + */ +class PrivacyManager +{ + public function __construct(private readonly Container $container) {} + + /** + * @return array + */ + public function providers(): array + { + $providers = array_map( + fn (string $class) => $this->container->make($class), + config('core.privacy.providers', []) + ); + + $this->assertUniqueNames($providers); + + return $providers; + } + + /** + * @param array $providers + */ + private function assertUniqueNames(array $providers): void + { + $names = array_map(fn (PersonalDataProvider $provider) => $provider->name(), $providers); + $duplicates = array_diff_assoc($names, array_unique($names)); + + if ($duplicates !== []) { + throw new \LogicException( + 'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates)) + .'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().' + ); + } + } +} diff --git a/src/Privacy/PrivacyService.php b/src/Privacy/PrivacyService.php new file mode 100644 index 0000000..b7f2ddb --- /dev/null +++ b/src/Privacy/PrivacyService.php @@ -0,0 +1,240 @@ +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 either the Customer themselves (self-service deletion) or a + * Staff member acting on their behalf. + */ + public function requestErasureForCustomer(Customer $customer, Customer|Staff $requestedBy): 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)), + ]); + } + + /** + * 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); + + 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(). + */ + 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); + + 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. + */ + public function completeErasure(DataErasureRequest $request): ErasureReport + { + if ($request->isForCustomer()) { + $subject = new CustomerSubject(customerId: $request->subject_id); + $results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers()); + } else { + $subject = new UserSubject(userId: $request->subject_id, email: $request->email); + $results = array_map(fn ($provider) => $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, + ]); + } +} diff --git a/src/Privacy/ProviderErasureResult.php b/src/Privacy/ProviderErasureResult.php new file mode 100644 index 0000000..56051dd --- /dev/null +++ b/src/Privacy/ProviderErasureResult.php @@ -0,0 +1,18 @@ + $data + */ + public function __construct( + public readonly string $provider, + public readonly array $data, + ) {} +} diff --git a/src/Privacy/Providers/AddressDataProvider.php b/src/Privacy/Providers/AddressDataProvider.php new file mode 100644 index 0000000..9dd4138 --- /dev/null +++ b/src/Privacy/Providers/AddressDataProvider.php @@ -0,0 +1,63 @@ +customerId)->get(); + + return new ProviderExportResult('addresses', $addresses->map(fn (Address $address) => [ + 'id' => $address->id, + 'first_name' => $address->first_name, + 'last_name' => $address->last_name, + 'company_name' => $address->company_name, + 'line_one' => $address->line_one, + 'line_two' => $address->line_two, + 'line_three' => $address->line_three, + 'city' => $address->city, + 'state' => $address->state, + 'postcode' => $address->postcode, + 'contact_email' => $address->contact_email, + 'contact_phone' => $address->contact_phone, + ])->all()); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + return new ProviderExportResult('addresses', []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + Address::where('customer_id', $subject->customerId)->delete(); + + return new ProviderErasureResult('addresses', ErasureOutcome::Erased); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('addresses', ErasureOutcome::Skipped, 'Addresses belong to Customer accounts, not individual users.'); + } +} diff --git a/src/Privacy/Providers/CartDataProvider.php b/src/Privacy/Providers/CartDataProvider.php new file mode 100644 index 0000000..8ec2835 --- /dev/null +++ b/src/Privacy/Providers/CartDataProvider.php @@ -0,0 +1,62 @@ +customerId)->pluck('id'))->get(); + + return new ProviderExportResult('carts', $addresses->map(fn (CartAddress $address) => [ + 'type' => $address->type, + 'first_name' => $address->first_name, + 'last_name' => $address->last_name, + 'line_one' => $address->line_one, + 'city' => $address->city, + 'postcode' => $address->postcode, + 'contact_email' => $address->contact_email, + 'contact_phone' => $address->contact_phone, + ])->all()); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + return new ProviderExportResult('carts', []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete(); + + return new ProviderErasureResult('carts', ErasureOutcome::Erased); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.'); + } +} diff --git a/src/Privacy/Providers/CustomerDataProvider.php b/src/Privacy/Providers/CustomerDataProvider.php new file mode 100644 index 0000000..876d956 --- /dev/null +++ b/src/Privacy/Providers/CustomerDataProvider.php @@ -0,0 +1,115 @@ +users exists. Erasing a business account must not destroy the + * login access of every person who works there. + * - eraseForUser() clears that one person's name/email only — it never touches + * the Customer record's own fields, and it also detaches the User from every + * Customer they're linked to (the customer_user pivot — see docs/modules.md + * "Customer/User Pairing"), since erasing a person's identity should end + * their membership everywhere, without erasing the business accounts + * themselves or any other User still linked to them. + * + * No legal retention requirement applies to this table on its own, so both + * directions are freely erased — Order/OrderAddress, which DO have a retention + * requirement, are handled separately by OrderDataProvider. + */ +class CustomerDataProvider implements PersonalDataProvider +{ + public function name(): string + { + return 'customer'; + } + + public function exportForCustomer(CustomerSubject $subject): ProviderExportResult + { + $customer = Customer::find($subject->customerId); + + return new ProviderExportResult('customer', $customer ? [ + 'id' => $customer->id, + 'title' => $customer->title, + 'first_name' => $customer->first_name, + 'last_name' => $customer->last_name, + 'company_name' => $customer->company_name, + 'tax_identifier' => $customer->tax_identifier, + 'meta' => $customer->meta, + 'users' => $customer->users->map(fn ($user) => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ])->all(), + ] : []); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + $model = config('auth.providers.users.model'); + $user = $model::find($subject->userId); + + return new ProviderExportResult('customer', $user ? [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'customers' => $user->customers->map(fn (Customer $customer) => [ + 'id' => $customer->id, + 'company_name' => $customer->company_name, + ])->all(), + ] : []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + $customer = Customer::find($subject->customerId); + + if (! $customer) { + return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'Customer record not found.'); + } + + $customer->update([ + 'title' => null, + 'first_name' => 'Erased', + 'last_name' => "Customer #{$customer->id}", + 'company_name' => null, + 'tax_identifier' => null, + 'account_ref' => null, + 'meta' => null, + ]); + + return new ProviderErasureResult('customer', ErasureOutcome::Erased); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + $model = config('auth.providers.users.model'); + $user = $model::find($subject->userId); + + if (! $user) { + return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'User record not found.'); + } + + $user->customers()->detach(); + + $user->update([ + 'name' => null, + 'email' => "erased-user-{$user->id}@example.invalid", + ]); + + return new ProviderErasureResult('customer', ErasureOutcome::Erased); + } +} diff --git a/src/Privacy/Providers/OrderDataProvider.php b/src/Privacy/Providers/OrderDataProvider.php new file mode 100644 index 0000000..f3d8f16 --- /dev/null +++ b/src/Privacy/Providers/OrderDataProvider.php @@ -0,0 +1,97 @@ +customerId)->with('addresses')->get(); + + return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [ + 'id' => $order->id, + 'reference' => $order->reference, + 'status' => $order->status, + 'total' => $order->total?->decimal(), + 'placed_at' => $order->placed_at?->toIso8601String(), + 'addresses' => $order->addresses->map(fn (OrderAddress $address) => [ + 'type' => $address->type, + 'first_name' => $address->first_name, + 'last_name' => $address->last_name, + 'line_one' => $address->line_one, + 'city' => $address->city, + 'postcode' => $address->postcode, + 'contact_email' => $address->contact_email, + 'contact_phone' => $address->contact_phone, + ])->all(), + ])->all()); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + return new ProviderExportResult('orders', []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + $orderIds = Order::where('customer_id', $subject->customerId)->pluck('id'); + + if ($orderIds->isEmpty()) { + return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.'); + } + + Order::whereIn('id', $orderIds)->update([ + 'customer_reference' => null, + 'notes' => null, + ]); + + OrderAddress::whereIn('order_id', $orderIds)->update([ + 'title' => null, + 'first_name' => 'Erased', + 'last_name' => 'Customer', + 'company_name' => null, + 'tax_identifier' => null, + 'line_one' => null, + 'line_two' => null, + 'line_three' => null, + 'delivery_instructions' => null, + 'contact_email' => null, + 'contact_phone' => null, + ]); + + return new ProviderErasureResult( + 'orders', + ErasureOutcome::Pseudonymized, + 'Order and address free-text fields cleared; order records, totals, and line items retained for legal/tax record-keeping.' + ); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.'); + } +} diff --git a/src/Privacy/Providers/ReviewDataProvider.php b/src/Privacy/Providers/ReviewDataProvider.php new file mode 100644 index 0000000..a3bebbe --- /dev/null +++ b/src/Privacy/Providers/ReviewDataProvider.php @@ -0,0 +1,99 @@ +email) { + return new ProviderExportResult('reviews', []); + } + + $reviews = $this->matchingReviews($subject->email)->get(); + + return new ProviderExportResult('reviews', $reviews->map(fn (ProductReview $review) => [ + 'id' => $review->id, + 'product_id' => $review->product_id, + 'title' => $review->title, + 'body' => $review->body, + 'rating' => $review->rating, + 'reviewer_name' => $review->reviewer_name, + 'reviewer_email' => $review->reviewer_email, + 'reviewed_at' => $review->reviewed_at?->toIso8601String(), + ])->all()); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'Reviews are authored by individuals, not Customer accounts.'); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + if (! $subject->email) { + return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No email on this subject to match reviews by.'); + } + + $matched = $this->matchingReviews($subject->email)->count(); + + if ($matched === 0) { + return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No reviews matched this email.'); + } + + // The review content itself (rating/title/body) is kept — it's the + // reviewer's own product feedback, not identity data on its own — only + // the identifying fields are cleared. + $this->matchingReviews($subject->email)->update([ + 'reviewer_name' => 'Anonymous', + 'reviewer_email' => null, + ]); + + return new ProviderErasureResult( + 'reviews', + ErasureOutcome::Pseudonymized, + 'Reviewer name/email cleared on reviews matched by email; rating/title/body text retained.' + ); + } + + private function matchingReviews(string $email): Builder + { + return ProductReview::where('reviewer_email', $email); + } +} diff --git a/src/Privacy/UserSubject.php b/src/Privacy/UserSubject.php new file mode 100644 index 0000000..f0962bc --- /dev/null +++ b/src/Privacy/UserSubject.php @@ -0,0 +1,30 @@ +id, email: $user->email); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 0574c40..598ee91 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; +use Modules\Core\Command\ProcessErasureRequestsCommand; class CoreServiceProvider extends ServiceProvider { @@ -35,10 +36,10 @@ class CoreServiceProvider extends ServiceProvider ], 'core-assets'); if ($this->app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, ProcessErasureRequestsCommand::class]); //Overriding lunar:install - $this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); + $this->app->booted(fn() => $this->commands([InstallLunarCommand::class])); } } } diff --git a/src/Providers/PrivacyServiceProvider.php b/src/Providers/PrivacyServiceProvider.php new file mode 100644 index 0000000..146ec51 --- /dev/null +++ b/src/Providers/PrivacyServiceProvider.php @@ -0,0 +1,19 @@ +