From e7784364fdf350c3c03a0e028b0ff2078054535f Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 25 Aug 2026 09:33:03 +0300 Subject: [PATCH] Feature: Data Access and Data Export Admin Service This commit introduces the data retention and data export Admin Services, accessed by the Boboko admin UI --- src/CorePlugin.php | 56 ++++++ .../CustomerErasureActionsExtension.php | 85 +++++++++ .../CustomerErasureRelationsExtension.php | 41 +++++ .../Resources/DataErasureRequestResource.php | 166 ++++++++++++++++++ .../Pages/ListDataErasureRequests.php | 11 ++ .../Pages/ViewDataErasureRequest.php | 11 ++ .../Resources/DataExportRequestResource.php | 125 +++++++++++++ .../Pages/ListDataExportRequests.php | 11 ++ .../Pages/ViewDataExportRequest.php | 11 ++ src/Privacy/Models/DataErasureRequest.php | 22 +++ .../ErasureRequestsRelationManager.php | 80 +++++++++ .../ExportRequestsRelationManager.php | 69 ++++++++ .../RelationManagers/UserRelationManager.php | 112 ++++++++++++ 13 files changed, 800 insertions(+) create mode 100644 src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php create mode 100644 src/Privacy/Filament/Extensions/CustomerErasureRelationsExtension.php create mode 100644 src/Privacy/Filament/Resources/DataErasureRequestResource.php create mode 100644 src/Privacy/Filament/Resources/DataErasureRequestResource/Pages/ListDataErasureRequests.php create mode 100644 src/Privacy/Filament/Resources/DataErasureRequestResource/Pages/ViewDataErasureRequest.php create mode 100644 src/Privacy/Filament/Resources/DataExportRequestResource.php create mode 100644 src/Privacy/Filament/Resources/DataExportRequestResource/Pages/ListDataExportRequests.php create mode 100644 src/Privacy/Filament/Resources/DataExportRequestResource/Pages/ViewDataExportRequest.php create mode 100644 src/Privacy/RelationManagers/ErasureRequestsRelationManager.php create mode 100644 src/Privacy/RelationManagers/ExportRequestsRelationManager.php create mode 100644 src/Privacy/RelationManagers/UserRelationManager.php diff --git a/src/CorePlugin.php b/src/CorePlugin.php index ef0bd4a..348553d 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -5,17 +5,28 @@ namespace Modules\Core; use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\Mail; +use Lunar\Admin\Filament\Resources\CustomerResource; +use Lunar\Admin\Filament\Resources\CustomerResource\Pages\EditCustomer; +use Lunar\Admin\Filament\Resources\CustomerResource\Pages\ViewCustomer; use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Support\Facades\LunarPanel; +use Lunar\Models\Customer; use Lunar\Models\Product; use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; +use Modules\Core\Privacy\Filament\Extensions\CustomerErasureActionsExtension; +use Modules\Core\Privacy\Filament\Extensions\CustomerErasureRelationsExtension; +use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource; +use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource; +use Modules\Core\Privacy\Models\DataErasureRequest; +use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -35,12 +46,29 @@ class CorePlugin implements Plugin ->login(Login::class) ->resources([ LanguageLineResource::class, + DataErasureRequestResource::class, + DataExportRequestResource::class, ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, ProductResource::class => ProductResourceExtension::class, + // headerActions() is resolved per PAGE class, not per resource class — + // unlike extendForm()/extendTable(), which really are resource-keyed + // (called statically from the Resource class itself). Registering this + // under CustomerResource::class would silently never fire; it has to be + // keyed by each concrete page it should appear on. Layered with + // whatever extension the consuming app registers for the same page — + // LunarPanel::extensions() merges per key, and this one only touches + // headerActions(), so it never conflicts with an app's own extension + // (see docs/modules.md "Layering Module and App Configuration"). + EditCustomer::class => CustomerErasureActionsExtension::class, + ViewCustomer::class => CustomerErasureActionsExtension::class, + // getRelations(), unlike headerActions(), genuinely is resolved + // statically from the Resource class itself — CustomerResource::class + // is the correct key here. + CustomerResource::class => CustomerErasureRelationsExtension::class, ]); Product::macro('reviews', function (): HasMany { @@ -48,6 +76,34 @@ class CorePlugin implements Plugin return $this->hasMany(ProductReview::class); }); + // Customer::erasureRequests()/exportRequests() and the User-model + // equivalents below let a relation manager scope + // DataErasureRequest/DataExportRequest to one specific subject — both + // tables use a plain subject_type/subject_id pair rather than Laravel's + // usual morphs() convention, since one column pair identifies either a + // Customer or a User (see docs/privacy.md "User-scope vs Customer-scope"), + // so this is a MorphMany built by hand rather than a bare Eloquent + // convention lookup. + Customer::macro('erasureRequests', function (): MorphMany { + /** @var Customer $this */ + return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id'); + }); + + Customer::macro('exportRequests', function (): MorphMany { + /** @var Customer $this */ + return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id'); + }); + + $userModel = config('auth.providers.users.model'); + + $userModel::macro('erasureRequests', function (): MorphMany { + return $this->morphMany(DataErasureRequest::class, 'subject', 'subject_type', 'subject_id'); + }); + + $userModel::macro('exportRequests', function (): MorphMany { + return $this->morphMany(DataExportRequest::class, 'subject', 'subject_type', 'subject_id'); + }); + LunarStaff::addActivitylogExcept([ 'otp_code', 'otp_expires_at', diff --git a/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php b/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php new file mode 100644 index 0000000..a481ac4 --- /dev/null +++ b/src/Privacy/Filament/Extensions/CustomerErasureActionsExtension.php @@ -0,0 +1,85 @@ +label('Request Erasure') + ->icon('heroicon-o-shield-exclamation') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Opens a cancellable grace-period erasure request for this Customer account. No linked User\'s login is affected.') + ->form([ + Checkbox::make('immediate') + ->label('Erase immediately (skip the 30-day grace period)') + ->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.') + ->default(false), + ]) + ->action(function (Customer $record, array $data) { + $privacyService = app(PrivacyService::class); + + if ($data['immediate']) { + $privacyService->requestImmediateErasureForCustomer($record, $this->currentStaff()); + + Notification::make() + ->title('Customer erased') + ->body('Erasure ran immediately — see the Erasure Requests list for the outcome.') + ->success() + ->send(); + + return; + } + + $privacyService->requestErasureForCustomer($record, $this->currentStaff()); + + Notification::make() + ->title('Erasure requested') + ->body('The grace period starts now — see the Erasure Requests list.') + ->success() + ->send(); + }), + Action::make('requestExport') + ->label('Request Export') + ->icon('heroicon-o-arrow-down-tray') + ->requiresConfirmation() + ->action(function (Customer $record) { + app(PrivacyService::class)->requestExportForCustomer($record); + + Notification::make() + ->title('Export requested') + ->body('Generating in the background — see the Export Requests list once it completes.') + ->success() + ->send(); + }), + ]; + } + + private function currentStaff(): Staff + { + return auth('staff')->user(); + } +} diff --git a/src/Privacy/Filament/Extensions/CustomerErasureRelationsExtension.php b/src/Privacy/Filament/Extensions/CustomerErasureRelationsExtension.php new file mode 100644 index 0000000..ef38347 --- /dev/null +++ b/src/Privacy/Filament/Extensions/CustomerErasureRelationsExtension.php @@ -0,0 +1,41 @@ + $relation === BaseUserRelationManager::class ? UserRelationManager::class : $relation, + $relations + ); + + return [ + ...$relations, + ErasureRequestsRelationManager::class, + ExportRequestsRelationManager::class, + ]; + } +} diff --git a/src/Privacy/Filament/Resources/DataErasureRequestResource.php b/src/Privacy/Filament/Resources/DataErasureRequestResource.php new file mode 100644 index 0000000..837169f --- /dev/null +++ b/src/Privacy/Filament/Resources/DataErasureRequestResource.php @@ -0,0 +1,166 @@ +schema([ + Placeholder::make('subject') + ->label('Subject') + ->content(fn (DataErasureRequest $record) => sprintf( + '%s (%s)', + DataErasureRequest::displayNameFor($record->subject), + $record->isForCustomer() ? 'Customer account' : 'Individual user' + )), + Placeholder::make('requested_by') + ->label('Requested by') + ->content(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)), + TextInput::make('email') + ->label('Email (snapshot at request time)') + ->disabled(), + Placeholder::make('status') + ->content(fn (DataErasureRequest $record) => $record->status->value), + Placeholder::make('scheduled_for') + ->label('Scheduled for') + ->content(fn (DataErasureRequest $record) => $record->scheduled_for->toDayDateTimeString()), + Placeholder::make('cancelled_at') + ->label('Cancelled at') + ->content(fn (DataErasureRequest $record) => $record->cancelled_at?->toDayDateTimeString() ?? '—'), + Placeholder::make('completed_at') + ->label('Completed at') + ->content(fn (DataErasureRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'), + Placeholder::make('caused_by') + ->label('Caused by (cascade)') + ->content(fn (DataErasureRequest $record) => $record->causedBy + ? "Request #{$record->causedBy->id} (".DataErasureRequest::displayNameFor($record->causedBy->subject).')' + : 'Not a cascade — directly requested') + ->visible(fn (DataErasureRequest $record) => $record->caused_by_request_id !== null), + KeyValue::make('report') + ->label('Per-provider outcome') + ->disabled() + ->visible(fn (DataErasureRequest $record) => $record->report !== null) + ->helperText('Each provider\'s outcome once the erasure completed — see docs/privacy.md.'), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + TextColumn::make('subject_type') + ->label('Scope') + ->formatStateUsing(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'Customer' : 'User') + ->badge() + ->color(fn (DataErasureRequest $record) => $record->isForCustomer() ? 'info' : 'warning'), + TextColumn::make('subject') + ->label('Subject') + ->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->subject)) + ->searchable(query: fn ($query, string $search) => $query->where('email', 'like', "%{$search}%")), + TextColumn::make('requestedBy') + ->label('Requested by') + ->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)), + TextColumn::make('status') + ->badge() + ->formatStateUsing(fn (ErasureRequestStatus $state) => ucfirst($state->value)) + ->color(fn (ErasureRequestStatus $state) => match ($state) { + ErasureRequestStatus::Pending => 'warning', + ErasureRequestStatus::Cancelled => 'gray', + ErasureRequestStatus::Completed => 'success', + }), + TextColumn::make('scheduled_for') + ->label('Scheduled for') + ->dateTime() + ->sortable(), + TextColumn::make('caused_by_request_id') + ->label('Cascade') + ->formatStateUsing(fn (?int $state) => $state ? "from #{$state}" : '—') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('created_at') + ->label('Requested at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options([ + ErasureRequestStatus::Pending->value => 'Pending', + ErasureRequestStatus::Cancelled->value => 'Cancelled', + ErasureRequestStatus::Completed->value => 'Completed', + ]), + SelectFilter::make('subject_type') + ->label('Scope') + ->options(function () { + $userModel = config('auth.providers.users.model'); + + return [ + (new Customer)->getMorphClass() => 'Customer', + (new $userModel)->getMorphClass() => 'User', + ]; + }), + ]) + ->actions([ + ViewAction::make(), + Action::make('cancel') + ->label('Cancel') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (DataErasureRequest $record) => $record->isPending()) + ->action(fn (DataErasureRequest $record, PrivacyService $privacyService) => $privacyService->cancelErasure($record)), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDataErasureRequests::route('/'), + 'view' => Pages\ViewDataErasureRequest::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } +} diff --git a/src/Privacy/Filament/Resources/DataErasureRequestResource/Pages/ListDataErasureRequests.php b/src/Privacy/Filament/Resources/DataErasureRequestResource/Pages/ListDataErasureRequests.php new file mode 100644 index 0000000..ac86993 --- /dev/null +++ b/src/Privacy/Filament/Resources/DataErasureRequestResource/Pages/ListDataErasureRequests.php @@ -0,0 +1,11 @@ +schema([ + Placeholder::make('subject') + ->label('Subject') + ->content(fn (DataExportRequest $record) => sprintf( + '%s (%s)', + DataErasureRequest::displayNameFor($record->subject), + $record->isForCustomer() ? 'Customer account' : 'Individual user' + )), + TextInput::make('email') + ->label('Email (snapshot at request time)') + ->disabled(), + Placeholder::make('status') + ->content(fn (DataExportRequest $record) => $record->status->value), + Placeholder::make('completed_at') + ->label('Completed at') + ->content(fn (DataExportRequest $record) => $record->completed_at?->toDayDateTimeString() ?? '—'), + Placeholder::make('file_path') + ->label('Export file') + ->content(fn (DataExportRequest $record) => $record->file_path ?? 'Not generated yet'), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + TextColumn::make('subject_type') + ->label('Scope') + ->formatStateUsing(fn (DataExportRequest $record) => $record->isForCustomer() ? 'Customer' : 'User') + ->badge() + ->color(fn (DataExportRequest $record) => $record->isForCustomer() ? 'info' : 'warning'), + TextColumn::make('subject') + ->label('Subject') + ->state(fn (DataExportRequest $record) => DataErasureRequest::displayNameFor($record->subject)) + ->searchable(query: fn ($query, string $search) => $query->where('email', 'like', "%{$search}%")), + TextColumn::make('status') + ->badge() + ->formatStateUsing(fn (ExportRequestStatus $state) => ucfirst($state->value)) + ->color(fn (ExportRequestStatus $state) => match ($state) { + ExportRequestStatus::Pending => 'warning', + ExportRequestStatus::Failed => 'danger', + ExportRequestStatus::Completed => 'success', + }), + TextColumn::make('created_at') + ->label('Requested at') + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->label('Completed at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options([ + ExportRequestStatus::Pending->value => 'Pending', + ExportRequestStatus::Completed->value => 'Completed', + ExportRequestStatus::Failed->value => 'Failed', + ]), + ]) + ->actions([ + ViewAction::make(), + Action::make('download') + ->label('Download') + ->icon('heroicon-o-arrow-down-tray') + ->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path)) + ->action(fn (DataExportRequest $record) => response()->download($record->file_path)), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDataExportRequests::route('/'), + 'view' => Pages\ViewDataExportRequest::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } +} diff --git a/src/Privacy/Filament/Resources/DataExportRequestResource/Pages/ListDataExportRequests.php b/src/Privacy/Filament/Resources/DataExportRequestResource/Pages/ListDataExportRequests.php new file mode 100644 index 0000000..f6f1e79 --- /dev/null +++ b/src/Privacy/Filament/Resources/DataExportRequestResource/Pages/ListDataExportRequests.php @@ -0,0 +1,11 @@ +isPending() && $this->scheduled_for->isPast(); } + + /** + * A human-readable label for whichever record `$morphable` resolves to + * (Customer, User, or Staff — the three concrete types that appear across + * subject/requestedBy), since each uses a different name field and there's + * no shared interface for it. Falls back to "#id" if the record is gone + * (e.g. a Customer erased since this request completed) or the relation is + * simply empty. + */ + public static function displayNameFor(mixed $morphable): string + { + if (! $morphable) { + return '—'; + } + + return match (true) { + isset($morphable->full_name) => $morphable->full_name, + isset($morphable->name) => $morphable->name, + isset($morphable->first_name) => trim("{$morphable->first_name} {$morphable->last_name}"), + default => "#{$morphable->getKey()}", + }; + } } diff --git a/src/Privacy/RelationManagers/ErasureRequestsRelationManager.php b/src/Privacy/RelationManagers/ErasureRequestsRelationManager.php new file mode 100644 index 0000000..8d8fe0b --- /dev/null +++ b/src/Privacy/RelationManagers/ErasureRequestsRelationManager.php @@ -0,0 +1,80 @@ +recordTitleAttribute('id') + ->columns([ + TextColumn::make('id') + ->label('#'), + TextColumn::make('status') + ->badge() + ->formatStateUsing(fn (ErasureRequestStatus $state) => ucfirst($state->value)) + ->color(fn (ErasureRequestStatus $state) => match ($state) { + ErasureRequestStatus::Pending => 'warning', + ErasureRequestStatus::Cancelled => 'gray', + ErasureRequestStatus::Completed => 'success', + }), + TextColumn::make('requestedBy') + ->label('Requested by') + ->state(fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy)), + TextColumn::make('scheduled_for') + ->label('Scheduled for') + ->dateTime(), + TextColumn::make('caused_by_request_id') + ->label('Cascade') + ->formatStateUsing(fn (?int $state) => $state ? "from #{$state}" : '—'), + TextColumn::make('created_at') + ->label('Requested at') + ->dateTime(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options([ + ErasureRequestStatus::Pending->value => 'Pending', + ErasureRequestStatus::Cancelled->value => 'Cancelled', + ErasureRequestStatus::Completed->value => 'Completed', + ]), + ]) + ->headerActions([]) + ->actions([ + ViewAction::make() + ->url(fn (DataErasureRequest $record) => DataErasureRequestResource::getUrl('view', ['record' => $record])), + Action::make('cancel') + ->label('Cancel') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (DataErasureRequest $record) => $record->isPending()) + ->action(fn (DataErasureRequest $record, PrivacyService $privacyService) => $privacyService->cancelErasure($record)), + ]); + } +} diff --git a/src/Privacy/RelationManagers/ExportRequestsRelationManager.php b/src/Privacy/RelationManagers/ExportRequestsRelationManager.php new file mode 100644 index 0000000..2cec4c7 --- /dev/null +++ b/src/Privacy/RelationManagers/ExportRequestsRelationManager.php @@ -0,0 +1,69 @@ +recordTitleAttribute('id') + ->columns([ + TextColumn::make('id') + ->label('#'), + TextColumn::make('status') + ->badge() + ->formatStateUsing(fn (ExportRequestStatus $state) => ucfirst($state->value)) + ->color(fn (ExportRequestStatus $state) => match ($state) { + ExportRequestStatus::Pending => 'warning', + ExportRequestStatus::Failed => 'danger', + ExportRequestStatus::Completed => 'success', + }), + TextColumn::make('created_at') + ->label('Requested at') + ->dateTime(), + TextColumn::make('completed_at') + ->label('Completed at') + ->dateTime(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('status') + ->options([ + ExportRequestStatus::Pending->value => 'Pending', + ExportRequestStatus::Completed->value => 'Completed', + ExportRequestStatus::Failed->value => 'Failed', + ]), + ]) + ->headerActions([]) + ->actions([ + ViewAction::make() + ->url(fn (DataExportRequest $record) => DataExportRequestResource::getUrl('view', ['record' => $record])), + Action::make('download') + ->label('Download') + ->icon('heroicon-o-arrow-down-tray') + ->visible(fn (DataExportRequest $record) => $record->status === ExportRequestStatus::Completed && $record->file_path && file_exists($record->file_path)) + ->action(fn (DataExportRequest $record) => response()->download($record->file_path)), + ]); + } +} diff --git a/src/Privacy/RelationManagers/UserRelationManager.php b/src/Privacy/RelationManagers/UserRelationManager.php new file mode 100644 index 0000000..2d9b6d5 --- /dev/null +++ b/src/Privacy/RelationManagers/UserRelationManager.php @@ -0,0 +1,112 @@ + User relation manager to add: + * - a "Privacy Requests" row action, since a User's erasure/export requests + * can't be shown as a nested relation manager two levels deep (Customer -> + * User -> Requests isn't a shape Filament relation managers support) — a + * modal listing that specific User's requests is the practical alternative. + * - a "Request Erasure" row action — the User-scoped panel entry point, + * mirroring Modules\Core\Privacy\Filament\Extensions\ + * CustomerErasureActionsExtension on the Customer side, including the same + * "erase immediately" checkbox for a staff-triggered urgent request. + * See docs/privacy.md "User-scope vs Customer-scope". + */ +class UserRelationManager extends CoreUserRelationManager +{ + public function getDefaultTable(Table $table): Table + { + $table = parent::getDefaultTable($table); + + return $table->actions([ + ...$table->getActions(), + Action::make('privacyRequests') + ->label('Privacy Requests') + ->icon('heroicon-o-shield-exclamation') + ->modalHeading(fn (Model $record) => "Privacy requests for {$record->name}") + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->infolist(fn (Model $record) => $this->requestsInfolist($record)), + Action::make('requestErasure') + ->label('Request Erasure') + ->icon('heroicon-o-shield-exclamation') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Opens a cancellable grace-period erasure request for this individual — deactivates their login and detaches them from every linked Customer account once it completes. No Customer account\'s own data is affected.') + ->form([ + Checkbox::make('immediate') + ->label('Erase immediately (skip the 30-day grace period)') + ->helperText('Staff-only, for a formal legal request or regulator inquiry that genuinely requires urgency — not a routine deletion. Runs synchronously, cannot be cancelled once submitted.') + ->default(false), + ]) + ->action(function (Model $record, array $data) { + $privacyService = app(PrivacyService::class); + $staff = auth('staff')->user(); + + if ($data['immediate']) { + $privacyService->requestImmediateErasureForUser($record, $staff); + + Notification::make() + ->title('User erased') + ->body('Erasure ran immediately — see the Erasure Requests list for the outcome.') + ->success() + ->send(); + + return; + } + + $privacyService->requestErasureForUser($record, $staff); + + Notification::make() + ->title('Erasure requested') + ->body('The grace period starts now, and this user\'s login is deactivated immediately — see the Erasure Requests list.') + ->success() + ->send(); + }), + ]); + } + + private function requestsInfolist(Model $record): array + { + return [ + TextEntry::make('erasure_heading') + ->label('') + ->state('Erasure Requests'), + RepeatableEntry::make('erasureRequests') + ->label('') + ->state(fn () => $record->erasureRequests()->latest()->get()) + ->schema([ + TextEntry::make('status')->formatStateUsing(fn ($state) => ucfirst($state->value)), + TextEntry::make('scheduled_for')->dateTime(), + TextEntry::make('requestedBy')->label('Requested by')->state( + fn (DataErasureRequest $record) => DataErasureRequest::displayNameFor($record->requestedBy) + ), + ]) + ->columns(3), + TextEntry::make('export_heading') + ->label('') + ->state('Export Requests'), + RepeatableEntry::make('exportRequests') + ->label('') + ->state(fn () => $record->exportRequests()->latest()->get()) + ->schema([ + TextEntry::make('status')->formatStateUsing(fn ($state) => ucfirst($state->value)), + TextEntry::make('created_at')->label('Requested at')->dateTime(), + ]) + ->columns(2), + ]; + } +}