Feat: Rearranging Providers
This commit is contained in:
@@ -19,9 +19,11 @@ use ZipArchive;
|
||||
* listener) without touching how the data is gathered.
|
||||
*
|
||||
* Column schema: every provider's data is either a list of associative arrays
|
||||
* (rows directly) or a single associative array (one row) — see the providers in
|
||||
* Modules\Core\Privacy\Providers, all of which return exactly one of those two
|
||||
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is
|
||||
* (rows directly) or a single associative array (one row) — see the providers
|
||||
* registered in config('core.privacy.providers'), each living in its own owning
|
||||
* module's Privacy/ subdirectory (e.g. Modules\Core\Order\Privacy\
|
||||
* OrderDataProvider), all of which return exactly one of those two shapes. Any
|
||||
* nested array value within a row (e.g. an order's `addresses`) is
|
||||
* JSON-encoded into that one cell rather than exploded into further columns —
|
||||
* CsvWriter's generic stringify() behavior, not special-cased here.
|
||||
*/
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Providers;
|
||||
|
||||
use Lunar\Models\Address;
|
||||
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
||||
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
||||
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
||||
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
||||
use Modules\Core\Privacy\DTOs\ProviderExportResult;
|
||||
use Modules\Core\Privacy\DTOs\UserSubject;
|
||||
|
||||
/**
|
||||
* A customer's saved addresses (lunar_addresses) — belong to the Customer
|
||||
* (business account) via customer_id, not to an individual User, so this is
|
||||
* Customer-scope only. No legal retention requirement of their own (unlike
|
||||
* OrderAddress, handled by OrderDataProvider), so they're freely deleted outright
|
||||
* rather than pseudonymized in place.
|
||||
*/
|
||||
class
|
||||
AddressDataProvider implements PersonalDataProvider
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'addresses';
|
||||
}
|
||||
|
||||
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
|
||||
{
|
||||
$addresses = Address::where('customer_id', $subject->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.');
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Providers;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartAddress;
|
||||
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
||||
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
||||
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
||||
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
||||
use Modules\Core\Privacy\DTOs\ProviderExportResult;
|
||||
use Modules\Core\Privacy\DTOs\UserSubject;
|
||||
|
||||
/**
|
||||
* Carts and cart addresses (lunar_carts, lunar_cart_addresses) belong to the
|
||||
* Customer (business account) via customer_id, not to an individual User, so this
|
||||
* is Customer-scope only. Unlike Order/OrderAddress, an abandoned cart has no
|
||||
* legal retention requirement, so its addresses are freely deleted. The Cart row
|
||||
* itself is left alone (any completed order it produced is handled separately by
|
||||
* OrderDataProvider, which is what retention law actually cares about) — only its
|
||||
* address PII is removed.
|
||||
*/
|
||||
class CartDataProvider implements PersonalDataProvider
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'carts';
|
||||
}
|
||||
|
||||
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
|
||||
{
|
||||
$addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->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.');
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Providers;
|
||||
|
||||
use Lunar\Models\Customer;
|
||||
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
||||
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
||||
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
||||
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
||||
use Modules\Core\Privacy\DTOs\ProviderExportResult;
|
||||
use Modules\Core\Privacy\DTOs\UserSubject;
|
||||
|
||||
/**
|
||||
* The Customer record itself (lunar_customers) and, on the User side, the User's
|
||||
* own name/email. This is the one provider that implements both scopes
|
||||
* meaningfully, and they are deliberately kept from touching each other's data:
|
||||
*
|
||||
* - eraseForCustomer() clears the account's own fields (name, company, tax id)
|
||||
* only — it never touches any linked User's login or identity, even though
|
||||
* $customer->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);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Providers;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\OrderAddress;
|
||||
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
||||
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
||||
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
||||
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
||||
use Modules\Core\Privacy\DTOs\ProviderExportResult;
|
||||
use Modules\Core\Privacy\DTOs\UserSubject;
|
||||
|
||||
/**
|
||||
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
|
||||
* Customer (business account) via customer_id, not to an individual User, so this
|
||||
* is Customer-scope only. They're also subject to legal retention (tax/accounting
|
||||
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
|
||||
* explicitly allows this to override an erasure request). eraseForCustomer()
|
||||
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
|
||||
* deleting the order: totals, line items, tax data, and the order itself all
|
||||
* remain intact and auditable.
|
||||
*/
|
||||
class OrderDataProvider implements PersonalDataProvider
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'orders';
|
||||
}
|
||||
|
||||
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
|
||||
{
|
||||
$orders = Order::where('customer_id', $subject->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.');
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Providers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
|
||||
use Modules\Core\Privacy\DTOs\CustomerSubject;
|
||||
use Modules\Core\Privacy\Enums\ErasureOutcome;
|
||||
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
|
||||
use Modules\Core\Privacy\DTOs\ProviderExportResult;
|
||||
use Modules\Core\Privacy\DTOs\UserSubject;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
|
||||
/**
|
||||
* ProductReview (product_reviews) has no FK to Customer/User at all — it's
|
||||
* deliberately anonymous, just free-text reviewer_name/reviewer_email (see
|
||||
* docs/product-listing.md "Reviews"). A review is authored by an individual, not a
|
||||
* business account, so this is User-scope only — matched best-effort by email
|
||||
* against UserSubject::$email.
|
||||
*
|
||||
* NEEDS REVIEW: moved from Customer-scope to User-scope during the User/Customer
|
||||
* split (see docs/privacy.md "User-scope vs Customer-scope") on the reasoning that
|
||||
* authorship is a personal attribute — but this hasn't been fully validated against
|
||||
* how reviews are actually attributed in this codebase; revisit before relying on
|
||||
* it for a real erasure/export request.
|
||||
*
|
||||
* Matching by email is itself a real, documented limitation regardless of scope: a
|
||||
* review submitted under a different email than the one on file won't be found.
|
||||
* There's no stronger signal available without changing ProductReview's schema.
|
||||
*/
|
||||
class ReviewDataProvider implements PersonalDataProvider
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'reviews';
|
||||
}
|
||||
|
||||
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
|
||||
{
|
||||
return new ProviderExportResult('reviews', []);
|
||||
}
|
||||
|
||||
public function exportForUser(UserSubject $subject): ProviderExportResult
|
||||
{
|
||||
if (! $subject->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user