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\Contracts\Plugin;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Facades\Mail;
|
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\ProductResource;
|
||||||
use Lunar\Admin\Filament\Resources\StaffResource;
|
use Lunar\Admin\Filament\Resources\StaffResource;
|
||||||
use Lunar\Admin\Models\Staff as LunarStaff;
|
use Lunar\Admin\Models\Staff as LunarStaff;
|
||||||
use Lunar\Admin\Support\Facades\LunarPanel;
|
use Lunar\Admin\Support\Facades\LunarPanel;
|
||||||
|
use Lunar\Models\Customer;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
use Lunar\Shipping\ShippingPlugin;
|
use Lunar\Shipping\ShippingPlugin;
|
||||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||||
use Modules\Core\Auth\Filament\Pages\Login;
|
use Modules\Core\Auth\Filament\Pages\Login;
|
||||||
use Modules\Core\Auth\Mail\InviteMail;
|
use Modules\Core\Auth\Mail\InviteMail;
|
||||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
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\Extensions\ProductResourceExtension;
|
||||||
use Modules\Core\Review\Models\ProductReview;
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
@@ -35,12 +46,29 @@ class CorePlugin implements Plugin
|
|||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->resources([
|
->resources([
|
||||||
LanguageLineResource::class,
|
LanguageLineResource::class,
|
||||||
|
DataErasureRequestResource::class,
|
||||||
|
DataExportRequestResource::class,
|
||||||
])
|
])
|
||||||
->plugin(ShippingPlugin::make());
|
->plugin(ShippingPlugin::make());
|
||||||
|
|
||||||
LunarPanel::extensions([
|
LunarPanel::extensions([
|
||||||
StaffResource::class => StaffResourceExtension::class,
|
StaffResource::class => StaffResourceExtension::class,
|
||||||
ProductResource::class => ProductResourceExtension::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 {
|
Product::macro('reviews', function (): HasMany {
|
||||||
@@ -48,6 +76,34 @@ class CorePlugin implements Plugin
|
|||||||
return $this->hasMany(ProductReview::class);
|
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([
|
LunarStaff::addActivitylogExcept([
|
||||||
'otp_code',
|
'otp_code',
|
||||||
'otp_expires_at',
|
'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();
|
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