Feat: Updating the Privacy Providers, moving them into the appropriate Modules, Updating Privacy views

This commit is contained in:
2026-09-16 18:51:20 +03:00
parent fdd1899c34
commit 910fa94395
14 changed files with 621 additions and 51 deletions
+7 -1
View File
@@ -25,7 +25,13 @@ class ExportReport
$data = [];
foreach ($this->results as $result) {
$data[$result->provider] = $result->data;
// A provider that threw (ProviderExportResult::$error set — see
// Modules\Core\Privacy\Jobs\ExportDataSubjectJob::safeExport())
// surfaces as an explicit error marker rather than an empty
// array indistinguishable from "genuinely nothing to export."
$data[$result->provider] = $result->error !== null
? ['error' => $result->error]
: $result->data;
}
return $data;
@@ -6,6 +6,12 @@ namespace Modules\Core\Privacy\DTOs;
* One provider's contribution to a right-of-access export. `provider` is a short,
* stable machine name (e.g. 'customer', 'orders', 'reviews') used as the top-level
* key when PrivacyService assembles every provider's data into one export payload.
*
* `error` is set only when the provider threw an exception instead of returning
* normally — see Modules\Core\Privacy\Jobs\ExportDataSubjectJob, which catches
* per-provider so one provider throwing doesn't discard every other provider's
* already-gathered data for the same request. `data` is empty whenever `error` is
* set, never a partial/best-effort payload.
*/
class ProviderExportResult
{
@@ -15,5 +21,6 @@ class ProviderExportResult
public function __construct(
public readonly string $provider,
public readonly array $data,
public readonly ?string $error = null,
) {}
}
+9 -3
View File
@@ -3,9 +3,14 @@
namespace Modules\Core\Privacy\Enums;
/**
* What actually happened to a provider's data on an erasure request. None of these
* are failures — Retained is a valid, often legally-required outcome (e.g. an Order
* kept intact for tax retention), distinct from a provider erroring out.
* What actually happened to a provider's data on an erasure request. Erased/
* Pseudonymized/Retained/Skipped are never failures — Retained is a valid, often
* legally-required outcome (e.g. an Order kept intact for tax retention), distinct
* from a provider erroring out. Failed is the one genuine failure case: a provider
* threw an exception instead of returning normally — see Modules\Core\Privacy\
* Services\PrivacyService::completeErasure(), which catches per-provider so one
* provider throwing doesn't discard every other provider's already-computed
* result for the same request.
*/
enum ErasureOutcome: string
{
@@ -13,4 +18,5 @@ enum ErasureOutcome: string
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
case Failed = 'failed';
}
@@ -5,9 +5,11 @@ namespace Modules\Core\Privacy\Filament\Resources;
use Filament\Schemas\Schema;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\RepeatableEntry\TableColumn;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ListDataErasureRequests;
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ViewDataErasureRequest;
use Filament\Resources\Resource;
@@ -112,12 +114,32 @@ class DataErasureRequestResource extends Resource
]),
Section::make('Outcome')
->description('Each provider\'s outcome once the erasure completed — see docs/privacy.md.')
->description('What happened to each data category once the erasure ran. "Retained"/"Pseudonymized" usually means the data is kept in an anonymized form for legal or accounting reasons.')
->icon('heroicon-o-document-check')
->visible(fn (DataErasureRequest $record) => $record->report !== null)
->components([
KeyValueEntry::make('report')
->label(''),
RepeatableEntry::make('report')
->hiddenLabel()
->table([
TableColumn::make('Data category'),
TableColumn::make('Outcome'),
TableColumn::make('Reason'),
])
->components([
TextEntry::make('provider'),
TextEntry::make('outcome')
->badge()
->formatStateUsing(fn (string $state) => ucfirst($state))
->color(fn (string $state) => match ($state) {
ErasureOutcome::Erased->value => 'success',
ErasureOutcome::Pseudonymized->value, ErasureOutcome::Retained->value => 'info',
ErasureOutcome::Skipped->value => 'gray',
ErasureOutcome::Failed->value => 'danger',
default => 'gray',
}),
TextEntry::make('reason')
->placeholder('—'),
]),
]),
]);
}
+36 -2
View File
@@ -9,9 +9,12 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\DTOs\ExportReport;
use Modules\Core\Privacy\DTOs\ProviderExportResult;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\Services\PrivacyManager;
@@ -49,10 +52,16 @@ class ExportDataSubjectJob implements ShouldQueue
{
if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id);
$results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($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());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForUser', $subject),
$manager->providers()
);
}
Event::dispatch(new PersonalDataGathered(
@@ -65,4 +74,29 @@ class ExportDataSubjectJob implements ShouldQueue
{
$this->request->update(['status' => ExportRequestStatus::Failed]);
}
/**
* Catches per-provider so one provider throwing doesn't discard every
* other provider's already-gathered export data for this same request —
* without this, the whole array_map aborts, handle() never reaches
* Event::dispatch(), and failed() marks the ENTIRE request Failed even
* though most providers may have already gathered their data
* successfully. Logged via Log::error() so a thrown provider is still
* visible to staff, not just an empty/missing section in the export.
*
* @param 'exportForCustomer'|'exportForUser' $method
*/
private function safeExport(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderExportResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during export", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderExportResult($provider->name(), [], $e->getMessage());
}
}
}
+38 -2
View File
@@ -5,19 +5,23 @@ namespace Modules\Core\Privacy\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\DTOs\CustomerSubject;
use Modules\Core\Privacy\DTOs\ErasureReport;
use Modules\Core\Privacy\DTOs\ProviderErasureResult;
use Modules\Core\Privacy\DTOs\UserSubject;
use Modules\Core\Privacy\Enums\ErasureOutcome;
use Modules\Core\Privacy\Enums\ErasureRequestStatus;
use Modules\Core\Privacy\Enums\ExportRequestStatus;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
use Throwable;
/**
* Entry point for right-of-access and right-of-erasure requests, split into two
@@ -244,15 +248,30 @@ class PrivacyService
* called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this.
*
* Each provider call is caught individually — a provider throwing (a bug,
* an unexpected DB state) converts to ErasureOutcome::Failed rather than
* aborting the whole array_map, so one broken provider never discards
* every OTHER provider's already-completed erasure for this same request.
* Without this, the $request->update() below would never run at all on a
* throw, silently leaving providers that already succeeded unrecorded and
* the request stuck Pending forever. Logged via Log::error() so a thrown
* provider is still visible to staff, not just swallowed into "Failed."
*/
public function completeErasure(DataErasureRequest $request): ErasureReport
{
if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id);
$results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForCustomer', $subject),
$this->manager->providers()
);
} else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
$results = array_map(fn ($provider) => $provider->eraseForUser($subject), $this->manager->providers());
$results = array_map(
fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject),
$this->manager->providers()
);
}
$report = new ErasureReport($subject, $results);
@@ -282,4 +301,21 @@ class PrivacyService
'deactivated_at' => $deactivated ? now() : null,
]);
}
/**
* @param 'eraseForCustomer'|'eraseForUser' $method
*/
private function safeErase(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderErasureResult
{
try {
return $provider->{$method}($subject);
} catch (Throwable $e) {
Log::error("Privacy provider {$provider->name()}::{$method}() threw during erasure", [
'provider' => $provider->name(),
'exception' => $e,
]);
return new ProviderErasureResult($provider->name(), ErasureOutcome::Failed, $e->getMessage());
}
}
}