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
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Extensions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Notifications\Notification;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
use Lunar\Models\Customer;
|
||||
use Modules\Core\Auth\Models\Staff;
|
||||
use Modules\Core\Privacy\PrivacyService;
|
||||
|
||||
/**
|
||||
* Adds "Request erasure" / "Request export" header actions to the Customer
|
||||
* resource's edit/view page — the panel entry point for a staff member handling
|
||||
* "a customer emailed asking to be forgotten/for their data" without needing
|
||||
* tinker/code access. Registered centrally in CorePlugin, layered alongside
|
||||
* whatever CustomerResourceExtension a consuming app registers for its own
|
||||
* form/table/relations — LunarPanel::extensions() merges per resource (see
|
||||
* docs/modules.md "Layering Module and App Configuration"), and this extension
|
||||
* deliberately only implements headerActions(), so it never conflicts with an
|
||||
* app's own extension for the same resource.
|
||||
*/
|
||||
class CustomerErasureActionsExtension extends BaseExtension
|
||||
{
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
return [
|
||||
...$actions,
|
||||
Action::make('requestErasure')
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Extensions;
|
||||
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource\RelationManagers\UserRelationManager as BaseUserRelationManager;
|
||||
use Lunar\Admin\Support\Extending\BaseExtension;
|
||||
use Modules\Core\Privacy\RelationManagers\ErasureRequestsRelationManager;
|
||||
use Modules\Core\Privacy\RelationManagers\ExportRequestsRelationManager;
|
||||
use Modules\Core\Privacy\RelationManagers\UserRelationManager;
|
||||
|
||||
/**
|
||||
* Adds the erasureRequests/exportRequests relation managers (see CorePlugin's
|
||||
* Customer::erasureRequests()/exportRequests() macros) to the Customer resource's
|
||||
* relation tabs — separate from CustomerErasureActionsExtension (headerActions)
|
||||
* so each extension stays single-purpose. Unlike headerActions(), getRelations()
|
||||
* genuinely is resource-keyed (called statically from the Resource class, not a
|
||||
* page instance), so this is registered under CustomerResource::class itself in
|
||||
* CorePlugin, not a page class.
|
||||
*
|
||||
* Also swaps Lunar's base UserRelationManager for Modules\Core\Privacy\
|
||||
* RelationManagers\UserRelationManager, which adds a "Privacy Requests" row
|
||||
* action per user — see that class's docblock for why this can't be a nested
|
||||
* relation manager instead. Compares against Lunar's own base class, not any
|
||||
* intermediate override, per docs/lunar.md "Overriding Lunar Relation Managers".
|
||||
*/
|
||||
class CustomerErasureRelationsExtension extends BaseExtension
|
||||
{
|
||||
public function getRelations(array $relations): array
|
||||
{
|
||||
$relations = array_map(
|
||||
fn ($relation) => $relation === BaseUserRelationManager::class ? UserRelationManager::class : $relation,
|
||||
$relations
|
||||
);
|
||||
|
||||
return [
|
||||
...$relations,
|
||||
ErasureRequestsRelationManager::class,
|
||||
ExportRequestsRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources;
|
||||
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Lunar\Models\Customer;
|
||||
use Modules\Core\Privacy\ErasureRequestStatus;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages;
|
||||
use Modules\Core\Privacy\Models\DataErasureRequest;
|
||||
use Modules\Core\Privacy\PrivacyService;
|
||||
|
||||
/**
|
||||
* Read-mostly audit view over data_erasure_requests — staff can see every request
|
||||
* (who/what/when/status), inspect the per-provider outcome report once completed,
|
||||
* and cancel a pending one. Requests themselves are created via PrivacyService
|
||||
* (see Modules\Core\Privacy\Filament\Extensions\CustomerErasureActionsExtension
|
||||
* for the Customer-resource entry point) — this resource has no create/edit page,
|
||||
* since a request's lifecycle is owned by PrivacyService, not free-form editing.
|
||||
*/
|
||||
class DataErasureRequestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DataErasureRequest::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
protected static ?string $navigationGroup = 'Privacy';
|
||||
|
||||
protected static ?string $modelLabel = 'Erasure Request';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Erasure Requests';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->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;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
|
||||
|
||||
class ListDataErasureRequests extends ListRecords
|
||||
{
|
||||
protected static string $resource = DataErasureRequestResource::class;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
|
||||
|
||||
class ViewDataErasureRequest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = DataErasureRequestResource::class;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources;
|
||||
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Privacy\ExportRequestStatus;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages;
|
||||
use Modules\Core\Privacy\Models\DataErasureRequest;
|
||||
use Modules\Core\Privacy\Models\DataExportRequest;
|
||||
|
||||
/**
|
||||
* Read-only audit view over data_export_requests — see DataErasureRequestResource
|
||||
* for the erasure-side equivalent and the shared reasoning (no create/edit page,
|
||||
* requests are created via PrivacyService::requestExport*()).
|
||||
*/
|
||||
class DataExportRequestResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DataExportRequest::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-arrow-down-tray';
|
||||
|
||||
protected static ?string $navigationGroup = 'Privacy';
|
||||
|
||||
protected static ?string $modelLabel = 'Export Request';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Export Requests';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->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;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
|
||||
|
||||
class ListDataExportRequests extends ListRecords
|
||||
{
|
||||
protected static string $resource = DataExportRequestResource::class;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\Filament\Resources\DataExportRequestResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
|
||||
|
||||
class ViewDataExportRequest extends ViewRecord
|
||||
{
|
||||
protected static string $resource = DataExportRequestResource::class;
|
||||
}
|
||||
@@ -79,4 +79,26 @@ class DataErasureRequest extends Model
|
||||
{
|
||||
return $this->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()}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Privacy\ErasureRequestStatus;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource;
|
||||
use Modules\Core\Privacy\Models\DataErasureRequest;
|
||||
use Modules\Core\Privacy\PrivacyService;
|
||||
|
||||
/**
|
||||
* Lists erasure requests where the record being viewed (Customer or User) is the
|
||||
* subject — scoped via the Customer::erasureRequests()/{User}::erasureRequests()
|
||||
* morphMany macros registered in CorePlugin. Read-mostly, same as
|
||||
* DataErasureRequestResource itself — no create here either, requests are opened
|
||||
* via PrivacyService (see CustomerErasureActionsExtension for the Customer-page
|
||||
* entry point).
|
||||
*/
|
||||
class ErasureRequestsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'erasureRequests';
|
||||
|
||||
protected static ?string $title = 'Erasure Requests';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Core\Privacy\ExportRequestStatus;
|
||||
use Modules\Core\Privacy\Filament\Resources\DataExportRequestResource;
|
||||
use Modules\Core\Privacy\Models\DataExportRequest;
|
||||
|
||||
/**
|
||||
* Lists export requests where the record being viewed (Customer or User) is the
|
||||
* subject — scoped via the Customer::exportRequests()/{User}::exportRequests()
|
||||
* morphMany macros registered in CorePlugin. See
|
||||
* ErasureRequestsRelationManager for the erasure-side equivalent.
|
||||
*/
|
||||
class ExportRequestsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'exportRequests';
|
||||
|
||||
protected static ?string $title = 'Export Requests';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Privacy\RelationManagers;
|
||||
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Core\Customer\RelationManagers\UserRelationManager as CoreUserRelationManager;
|
||||
use Modules\Core\Privacy\Models\DataErasureRequest;
|
||||
use Modules\Core\Privacy\PrivacyService;
|
||||
|
||||
/**
|
||||
* Extends core's own Customer -> 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user