418 lines
25 KiB
Markdown
418 lines
25 KiB
Markdown
# 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 provider implementation lives inside the module that owns the data it erases/exports, under
|
|
that module's own `Privacy/` subdirectory (e.g. `Modules\Core\Order\Privacy\OrderDataProvider`,
|
|
`Modules\Core\Customer\Privacy\CustomerDataProvider`) — never inside `Modules\Core\Privacy`
|
|
itself, which only owns the shared contract (`Contracts\PersonalDataProvider`), the request
|
|
lifecycle (`Services\PrivacyManager`/`PrivacyService`), and the DTOs/enums every provider
|
|
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
|
|
code (e.g. a resource's own `Filament/Extensions/` subdirectory) — and matters concretely if a
|
|
module is ever extracted into its own composer package (see `docs/modules.md`): the provider
|
|
that knows how to erase that module's data must travel with it, not get stranded in `Privacy`
|
|
depending on a package that no longer ships in this repo.
|
|
|
|
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\Customer\Privacy\CustomerDataProvider::class,
|
|
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
|
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
|
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
|
\Modules\Core\Review\Privacy\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()` | Lives in | Covers | Customer-scope | User-scope |
|
|
|---|---|---|---|---|---|
|
|
| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below |
|
|
| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer |
|
|
| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual |
|
|
| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual |
|
|
| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual |
|
|
| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept |
|
|
| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users |
|
|
| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) |
|
|
|
|
`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.
|
|
|
|
### `activity_log` is redacted by subject, never by causer
|
|
|
|
`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use
|
|
LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full
|
|
snapshot of whatever it logged in `properties`, completely independent of the real row it
|
|
describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to
|
|
this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on
|
|
every row whose **subject** (not causer) resolves back to that customer, across all five
|
|
PII-bearing subject types.
|
|
|
|
It deliberately never touches `causer_id` — the causer is "who performed this action," not PII
|
|
content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is
|
|
therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so
|
|
there's nothing to redact from the User side alone.
|
|
|
|
**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in
|
|
`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an
|
|
`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them.
|
|
Reversing the order would make matching those rows impossible once the addresses are gone.
|
|
|
|
**`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\Services\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`, **queued** — see below) 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, then reverts every Customer erasure request
|
|
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
|
|
action — no separate UI/flow needed for reactivation.
|
|
|
|
This listener is queued rather than synchronous, so login returns to the browser without waiting
|
|
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
|
|
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
|
|
so the brief window between the login response and the job actually running has no other consumer
|
|
to observe it as stale.
|
|
|
|
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
|
|
|
|
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
|
|
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
|
|
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
|
|
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
|
|
there indefinitely with no legitimate purpose.
|
|
|
|
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
|
|
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
|
|
the immediate path, before `completeErasure()` runs — see below).
|
|
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
|
|
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
|
|
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
|
|
1, to be explicit rather than relying on an assumption), it opens a second, independent
|
|
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
|
|
Both requests then run through their own separate 30-day windows.
|
|
|
|
```
|
|
User erasure requested
|
|
│
|
|
▼
|
|
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
|
|
│
|
|
▼
|
|
for each linked Customer: sole owner?
|
|
│ yes
|
|
▼
|
|
requestErasureForCustomer(..., causedByRequestId: <user request id>)
|
|
```
|
|
|
|
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
|
|
`caused_by_request_id` points back at the User request that triggered it. This is what lets
|
|
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
|
|
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
|
|
staff-requested Customer erasure the User happens to still be linked to.
|
|
|
|
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
|
|
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
|
|
cascade check never rolls back or blocks the User's own request, and there's no
|
|
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
|
|
Customer request) aren't required to be atomic with each other.
|
|
|
|
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
|
|
Eloquent re-fetches its models fresh when the job actually runs (see
|
|
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
|
|
*real* state at execution time, not a stale snapshot from dispatch time. For
|
|
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
|
|
the User's memberships in the same call. If the detach happens first, the User is simply no
|
|
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
|
|
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
|
|
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
|
|
separate `completeErasure()` run — well after the cascade job has had time to fire.
|
|
|
|
### 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; `Staff` acting on their behalf; or, for a cascade-created Customer
|
|
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
|
|
`DataErasureRequest.caused_by_request_id` is set only on a cascade-created Customer request,
|
|
pointing back at the User request that triggered it; null on every normal, directly-requested
|
|
erasure — see `DataErasureRequest::causedBy()`/`::caused()`.
|
|
`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.
|