From 75d862748b706e7e4255d23304a19b23e7812a35 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 2 Jan 2026 22:50:07 +0100 Subject: [PATCH] Add data export retry and cancel controls --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- .../Tables/DataExportTable.php | 43 ++++++ app/Jobs/GenerateDataExport.php | 4 + app/Models/DataExport.php | 34 +++++ resources/lang/de/admin.php | 3 + resources/lang/en/admin.php | 3 + tests/Unit/DataExportLifecycleTest.php | 133 ++++++++++++++++++ 8 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/DataExportLifecycleTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6fba66e..9ecf2c6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -111,7 +111,7 @@ {"id":"fotospiel-app-wkl","title":"Paddle catalog sync: paddle:sync-packages command (dry-run/pull)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:58.753792575+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:04.39629062+01:00","closed_at":"2026-01-01T16:01:04.39629062+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"} {"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"} -{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T17:39:14.808842908+01:00"} +{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"} {"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"} {"id":"fotospiel-app-z5g","title":"Tenant admin onboarding: PWA/Capacitor/TWA packaging prep","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:46.126417696+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:46.126417696+01:00"} {"id":"fotospiel-app-zli","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:55:03.625388684+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:09.286391766+01:00","closed_at":"2026-01-01T15:55:09.286391766+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index 66d4f15..4f12500 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-w7g +fotospiel-app-y1f diff --git a/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php b/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php index 4ac7381..84576c1 100644 --- a/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php +++ b/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php @@ -2,7 +2,9 @@ namespace App\Filament\Resources\DataExportResource\Tables; +use App\Jobs\GenerateDataExport; use App\Models\DataExport; +use App\Services\Audit\SuperAdminAuditLogger; use Filament\Actions\Action; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -38,6 +40,7 @@ class DataExportTable DataExport::STATUS_READY => 'success', DataExport::STATUS_FAILED => 'danger', DataExport::STATUS_PROCESSING => 'warning', + DataExport::STATUS_CANCELED => 'gray', default => 'gray', }), IconColumn::make('include_media') @@ -71,6 +74,7 @@ class DataExportTable DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'), DataExport::STATUS_READY => __('admin.data_exports.status.ready'), DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'), + DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'), ]), ]) ->actions([ @@ -80,6 +84,45 @@ class DataExportTable ->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record)) ->openUrlInNewTab() ->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()), + Action::make('retry') + ->label(__('admin.data_exports.actions.retry')) + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(fn (DataExport $record): bool => $record->canRetry()) + ->action(function (DataExport $record): void { + if (! $record->canRetry()) { + return; + } + + $record->resetForRetry(); + GenerateDataExport::dispatch($record->id); + + app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $record, + source: self::class + ); + }), + Action::make('cancel') + ->label(__('admin.data_exports.actions.cancel')) + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (DataExport $record): bool => $record->canCancel()) + ->action(function (DataExport $record): void { + if (! $record->canCancel()) { + return; + } + + $record->markCanceled(); + + app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $record, + source: self::class + ); + }), ]) ->bulkActions([]); } diff --git a/app/Jobs/GenerateDataExport.php b/app/Jobs/GenerateDataExport.php index dd7f95e..4a45e27 100644 --- a/app/Jobs/GenerateDataExport.php +++ b/app/Jobs/GenerateDataExport.php @@ -39,6 +39,10 @@ class GenerateDataExport implements ShouldQueue return; } + if ($export->status !== DataExport::STATUS_PENDING) { + return; + } + if (! $export->user) { $export->update([ 'status' => DataExport::STATUS_FAILED, diff --git a/app/Models/DataExport.php b/app/Models/DataExport.php index 26c0943..5771965 100644 --- a/app/Models/DataExport.php +++ b/app/Models/DataExport.php @@ -19,6 +19,8 @@ class DataExport extends Model public const STATUS_FAILED = 'failed'; + public const STATUS_CANCELED = 'canceled'; + protected $fillable = [ 'user_id', 'tenant_id', @@ -58,6 +60,38 @@ class DataExport extends Model return $this->status === self::STATUS_READY; } + public function canRetry(): bool + { + return in_array($this->status, [self::STATUS_FAILED, self::STATUS_CANCELED], true); + } + + public function canCancel(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true); + } + + public function resetForRetry(): void + { + $this->update([ + 'status' => self::STATUS_PENDING, + 'error_message' => null, + 'path' => null, + 'size_bytes' => null, + 'expires_at' => null, + ]); + } + + public function markCanceled(?string $reason = null): void + { + $this->update([ + 'status' => self::STATUS_CANCELED, + 'error_message' => $reason ?: 'Canceled by superadmin.', + 'path' => null, + 'size_bytes' => null, + 'expires_at' => null, + ]); + } + public function hasExpired(): bool { return $this->expires_at !== null && $this->expires_at->isPast(); diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 59912e8..8c60cc1 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -670,10 +670,13 @@ return [ 'processing' => 'In Arbeit', 'ready' => 'Bereit', 'failed' => 'Fehlgeschlagen', + 'canceled' => 'Abgebrochen', ], 'actions' => [ 'request' => 'Export anfordern', 'download' => 'Herunterladen', + 'retry' => 'Erneut versuchen', + 'cancel' => 'Abbrechen', ], ], 'retention_overrides' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 2eb0123..46434c1 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -656,10 +656,13 @@ return [ 'processing' => 'Processing', 'ready' => 'Ready', 'failed' => 'Failed', + 'canceled' => 'Canceled', ], 'actions' => [ 'request' => 'Request export', 'download' => 'Download', + 'retry' => 'Retry', + 'cancel' => 'Cancel', ], ], 'retention_overrides' => [ diff --git a/tests/Unit/DataExportLifecycleTest.php b/tests/Unit/DataExportLifecycleTest.php new file mode 100644 index 0000000..3e275a1 --- /dev/null +++ b/tests/Unit/DataExportLifecycleTest.php @@ -0,0 +1,133 @@ +create(); + + $failed = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_FAILED, + 'scope' => DataExportScope::TENANT->value, + ]); + + $canceled = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_CANCELED, + 'scope' => DataExportScope::TENANT->value, + ]); + + $pending = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_PENDING, + 'scope' => DataExportScope::TENANT->value, + ]); + + $this->assertTrue($failed->canRetry()); + $this->assertTrue($canceled->canRetry()); + $this->assertFalse($pending->canRetry()); + } + + public function test_can_cancel_only_for_pending_or_processing_exports(): void + { + $user = User::factory()->create(); + + $pending = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_PENDING, + 'scope' => DataExportScope::TENANT->value, + ]); + + $processing = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_PROCESSING, + 'scope' => DataExportScope::TENANT->value, + ]); + + $ready = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_READY, + 'scope' => DataExportScope::TENANT->value, + ]); + + $this->assertTrue($pending->canCancel()); + $this->assertTrue($processing->canCancel()); + $this->assertFalse($ready->canCancel()); + } + + public function test_reset_for_retry_clears_export_fields(): void + { + $user = User::factory()->create(); + + $export = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_FAILED, + 'scope' => DataExportScope::TENANT->value, + 'error_message' => 'Boom', + 'path' => 'exports/test.zip', + 'size_bytes' => 123, + 'expires_at' => now()->addDay(), + ]); + + $export->resetForRetry(); + $export->refresh(); + + $this->assertSame(DataExport::STATUS_PENDING, $export->status); + $this->assertNull($export->error_message); + $this->assertNull($export->path); + $this->assertNull($export->size_bytes); + $this->assertNull($export->expires_at); + } + + public function test_mark_canceled_sets_status_and_clears_export_fields(): void + { + $user = User::factory()->create(); + + $export = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_PENDING, + 'scope' => DataExportScope::TENANT->value, + 'path' => 'exports/test.zip', + 'size_bytes' => 321, + 'expires_at' => now()->addDay(), + ]); + + $export->markCanceled('Manual cancel.'); + $export->refresh(); + + $this->assertSame(DataExport::STATUS_CANCELED, $export->status); + $this->assertSame('Manual cancel.', $export->error_message); + $this->assertNull($export->path); + $this->assertNull($export->size_bytes); + $this->assertNull($export->expires_at); + } + + public function test_generate_data_export_ignores_non_pending_exports(): void + { + $user = User::factory()->create(); + + $export = DataExport::query()->create([ + 'user_id' => $user->id, + 'status' => DataExport::STATUS_CANCELED, + 'scope' => DataExportScope::TENANT->value, + ]); + + (new GenerateDataExport($export->id))->handle(); + + $export->refresh(); + + $this->assertSame(DataExport::STATUS_CANCELED, $export->status); + } +}