Feat: Updating the Privacy Providers, moving them into the appropriate Modules, Updating Privacy views
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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('—'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user