Add data export retry and cancel controls
This commit is contained in:
@@ -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-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-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-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-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-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)"}
|
{"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)"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-w7g
|
fotospiel-app-y1f
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\DataExportResource\Tables;
|
namespace App\Filament\Resources\DataExportResource\Tables;
|
||||||
|
|
||||||
|
use App\Jobs\GenerateDataExport;
|
||||||
use App\Models\DataExport;
|
use App\Models\DataExport;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
@@ -38,6 +40,7 @@ class DataExportTable
|
|||||||
DataExport::STATUS_READY => 'success',
|
DataExport::STATUS_READY => 'success',
|
||||||
DataExport::STATUS_FAILED => 'danger',
|
DataExport::STATUS_FAILED => 'danger',
|
||||||
DataExport::STATUS_PROCESSING => 'warning',
|
DataExport::STATUS_PROCESSING => 'warning',
|
||||||
|
DataExport::STATUS_CANCELED => 'gray',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
IconColumn::make('include_media')
|
IconColumn::make('include_media')
|
||||||
@@ -71,6 +74,7 @@ class DataExportTable
|
|||||||
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
||||||
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
||||||
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
||||||
|
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@@ -80,6 +84,45 @@ class DataExportTable
|
|||||||
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
||||||
->openUrlInNewTab()
|
->openUrlInNewTab()
|
||||||
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
|
->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([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class GenerateDataExport implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($export->status !== DataExport::STATUS_PENDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $export->user) {
|
if (! $export->user) {
|
||||||
$export->update([
|
$export->update([
|
||||||
'status' => DataExport::STATUS_FAILED,
|
'status' => DataExport::STATUS_FAILED,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class DataExport extends Model
|
|||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_CANCELED = 'canceled';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
@@ -58,6 +60,38 @@ class DataExport extends Model
|
|||||||
return $this->status === self::STATUS_READY;
|
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
|
public function hasExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||||
|
|||||||
@@ -670,10 +670,13 @@ return [
|
|||||||
'processing' => 'In Arbeit',
|
'processing' => 'In Arbeit',
|
||||||
'ready' => 'Bereit',
|
'ready' => 'Bereit',
|
||||||
'failed' => 'Fehlgeschlagen',
|
'failed' => 'Fehlgeschlagen',
|
||||||
|
'canceled' => 'Abgebrochen',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
'request' => 'Export anfordern',
|
'request' => 'Export anfordern',
|
||||||
'download' => 'Herunterladen',
|
'download' => 'Herunterladen',
|
||||||
|
'retry' => 'Erneut versuchen',
|
||||||
|
'cancel' => 'Abbrechen',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'retention_overrides' => [
|
'retention_overrides' => [
|
||||||
|
|||||||
@@ -656,10 +656,13 @@ return [
|
|||||||
'processing' => 'Processing',
|
'processing' => 'Processing',
|
||||||
'ready' => 'Ready',
|
'ready' => 'Ready',
|
||||||
'failed' => 'Failed',
|
'failed' => 'Failed',
|
||||||
|
'canceled' => 'Canceled',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
'request' => 'Request export',
|
'request' => 'Request export',
|
||||||
'download' => 'Download',
|
'download' => 'Download',
|
||||||
|
'retry' => 'Retry',
|
||||||
|
'cancel' => 'Cancel',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'retention_overrides' => [
|
'retention_overrides' => [
|
||||||
|
|||||||
133
tests/Unit/DataExportLifecycleTest.php
Normal file
133
tests/Unit/DataExportLifecycleTest.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Enums\DataExportScope;
|
||||||
|
use App\Jobs\GenerateDataExport;
|
||||||
|
use App\Models\DataExport;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DataExportLifecycleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_can_retry_only_for_failed_or_canceled_exports(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user