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:
2026-08-25 09:33:03 +03:00
parent 59303cf25f
commit e7784364fd
13 changed files with 800 additions and 0 deletions
+56
View File
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
+22
View File
@@ -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),
];
}
}