From eed76995495f864ee0bc27d34e0977e8e7d9a44b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 2 Jan 2026 20:13:45 +0100 Subject: [PATCH] Implement compliance exports and retention overrides --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- .../DispatchStorageArchiveCommand.php | 8 +- .../Commands/ProcessTenantRetention.php | 7 + app/Enums/DataExportScope.php | 19 ++ app/Enums/RetentionOverrideScope.php | 17 + app/Filament/Resources/DataExportResource.php | 78 +++++ .../Pages/CreateDataExport.php | 34 ++ .../Pages/ListDataExports.php | 20 ++ .../Schemas/DataExportForm.php | 68 ++++ .../Tables/DataExportTable.php | 86 +++++ .../Resources/RetentionOverrideResource.php | 75 +++++ .../Pages/CreateRetentionOverride.php | 26 ++ .../Pages/EditRetentionOverride.php | 44 +++ .../Pages/ListRetentionOverrides.php | 20 ++ .../Schemas/RetentionOverrideForm.php | 98 ++++++ .../Tables/RetentionOverrideTable.php | 111 ++++++ .../Api/Tenant/DataExportController.php | 190 +++++++++++ app/Http/Controllers/ProfileController.php | 4 + .../ProfileDataExportController.php | 5 + .../SuperAdmin/DataExportController.php | 32 ++ .../Tenant/DataExportStoreRequest.php | 41 +++ app/Jobs/GenerateDataExport.php | 315 ++++++++++++++++-- app/Models/DataExport.php | 11 + app/Models/Event.php | 5 + app/Models/RetentionOverride.php | 55 +++ app/Models/Tenant.php | 5 + .../Compliance/RetentionOverrideService.php | 36 ++ .../factories/RetentionOverrideFactory.php | 33 ++ ...90343_create_retention_overrides_table.php | 39 +++ ...add_scope_fields_to_data_exports_table.php | 35 ++ resources/js/admin/api.ts | 95 ++++++ resources/js/admin/constants.ts | 1 + .../js/admin/i18n/locales/de/management.json | 47 +++ .../js/admin/i18n/locales/en/management.json | 47 +++ resources/js/admin/mobile/DataExportsPage.tsx | 277 +++++++++++++++ resources/js/admin/mobile/ProfilePage.tsx | 20 +- resources/js/admin/router.tsx | 2 + resources/lang/de/admin.php | 71 ++++ resources/lang/en/admin.php | 71 ++++ routes/api.php | 10 + routes/web.php | 5 + .../Feature/Api/Tenant/DataExportApiTest.php | 116 +++++++ .../DispatchStorageArchiveCommandTest.php | 53 +++ tests/Feature/TenantRetentionCommandTest.php | 23 ++ 45 files changed, 2319 insertions(+), 40 deletions(-) create mode 100644 app/Enums/DataExportScope.php create mode 100644 app/Enums/RetentionOverrideScope.php create mode 100644 app/Filament/Resources/DataExportResource.php create mode 100644 app/Filament/Resources/DataExportResource/Pages/CreateDataExport.php create mode 100644 app/Filament/Resources/DataExportResource/Pages/ListDataExports.php create mode 100644 app/Filament/Resources/DataExportResource/Schemas/DataExportForm.php create mode 100644 app/Filament/Resources/DataExportResource/Tables/DataExportTable.php create mode 100644 app/Filament/Resources/RetentionOverrideResource.php create mode 100644 app/Filament/Resources/RetentionOverrideResource/Pages/CreateRetentionOverride.php create mode 100644 app/Filament/Resources/RetentionOverrideResource/Pages/EditRetentionOverride.php create mode 100644 app/Filament/Resources/RetentionOverrideResource/Pages/ListRetentionOverrides.php create mode 100644 app/Filament/Resources/RetentionOverrideResource/Schemas/RetentionOverrideForm.php create mode 100644 app/Filament/Resources/RetentionOverrideResource/Tables/RetentionOverrideTable.php create mode 100644 app/Http/Controllers/Api/Tenant/DataExportController.php create mode 100644 app/Http/Controllers/SuperAdmin/DataExportController.php create mode 100644 app/Http/Requests/Tenant/DataExportStoreRequest.php create mode 100644 app/Models/RetentionOverride.php create mode 100644 app/Services/Compliance/RetentionOverrideService.php create mode 100644 database/factories/RetentionOverrideFactory.php create mode 100644 database/migrations/2026_01_02_190343_create_retention_overrides_table.php create mode 100644 database/migrations/2026_01_02_190408_add_scope_fields_to_data_exports_table.php create mode 100644 resources/js/admin/mobile/DataExportsPage.tsx create mode 100644 tests/Feature/Api/Tenant/DataExportApiTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eaca325..045f1cd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -92,7 +92,7 @@ {"id":"fotospiel-app-q2n","title":"Checkout refactor: wizard foundations + updated steps","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:58.701443698+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:04.313207281+01:00","closed_at":"2026-01-01T16:06:04.313207281+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-01T15:59:43.333792314+01:00"} {"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"} -{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-01T14:20:16.530289009+01:00"} +{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"} {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"} {"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/.beads/last-touched b/.beads/last-touched index c366912..a04685f 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-bit +fotospiel-app-sbs diff --git a/app/Console/Commands/DispatchStorageArchiveCommand.php b/app/Console/Commands/DispatchStorageArchiveCommand.php index c3d57c5..463db9d 100644 --- a/app/Console/Commands/DispatchStorageArchiveCommand.php +++ b/app/Console/Commands/DispatchStorageArchiveCommand.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use App\Console\Concerns\InteractsWithCacheLocks; use App\Jobs\ArchiveEventMediaAssets; use App\Models\Event; +use App\Services\Compliance\RetentionOverrideService; use Illuminate\Console\Command; use Illuminate\Contracts\Cache\Lock; use Illuminate\Support\Facades\Log; @@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command $maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100)); $eventId = $this->option('event'); $dispatched = 0; + $overrides = app(RetentionOverrideService::class); try { $query = Event::query() @@ -57,12 +59,16 @@ class DispatchStorageArchiveCommand extends Command }); } - $query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) { + $query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl, $overrides) { foreach ($events as $event) { if ($dispatched >= $maxDispatch) { return false; } + if ($overrides->eventOnHold($event)) { + continue; + } + $eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl); if ($eventLock === false) { Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [ diff --git a/app/Console/Commands/ProcessTenantRetention.php b/app/Console/Commands/ProcessTenantRetention.php index 66425de..7593f87 100644 --- a/app/Console/Commands/ProcessTenantRetention.php +++ b/app/Console/Commands/ProcessTenantRetention.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use App\Jobs\AnonymizeAccount; use App\Models\Tenant; use App\Notifications\InactiveTenantDeletionWarning; +use App\Services\Compliance\RetentionOverrideService; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Notification; @@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command ->withMax('purchases as last_purchase_activity', 'purchased_at') ->withMax('photos as last_photo_activity', 'created_at') ->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) { + $overrides = app(RetentionOverrideService::class); + foreach ($tenants as $tenant) { + if ($overrides->tenantOnHold($tenant)) { + continue; + } + $lastActivity = $this->determineLastActivity($tenant); if (! $lastActivity) { diff --git a/app/Enums/DataExportScope.php b/app/Enums/DataExportScope.php new file mode 100644 index 0000000..5323fe8 --- /dev/null +++ b/app/Enums/DataExportScope.php @@ -0,0 +1,19 @@ + __('User'), + self::TENANT => __('Tenant'), + self::EVENT => __('Event'), + }; + } +} diff --git a/app/Enums/RetentionOverrideScope.php b/app/Enums/RetentionOverrideScope.php new file mode 100644 index 0000000..cd6d695 --- /dev/null +++ b/app/Enums/RetentionOverrideScope.php @@ -0,0 +1,17 @@ + __('Tenant'), + self::EVENT => __('Event'), + }; + } +} diff --git a/app/Filament/Resources/DataExportResource.php b/app/Filament/Resources/DataExportResource.php new file mode 100644 index 0000000..12511b4 --- /dev/null +++ b/app/Filament/Resources/DataExportResource.php @@ -0,0 +1,78 @@ +with(['tenant', 'event', 'user']); + } + + public static function getPages(): array + { + return [ + 'index' => ListDataExports::route('/'), + 'create' => CreateDataExport::route('/create'), + ]; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } +} diff --git a/app/Filament/Resources/DataExportResource/Pages/CreateDataExport.php b/app/Filament/Resources/DataExportResource/Pages/CreateDataExport.php new file mode 100644 index 0000000..fbdf61a --- /dev/null +++ b/app/Filament/Resources/DataExportResource/Pages/CreateDataExport.php @@ -0,0 +1,34 @@ +id(); + $data['status'] = DataExport::STATUS_PENDING; + + if (($data['scope'] ?? null) !== DataExportScope::EVENT->value) { + $data['event_id'] = null; + } + + return $data; + } + + protected function afterCreate(): void + { + parent::afterCreate(); + + GenerateDataExport::dispatch($this->record->id); + } +} diff --git a/app/Filament/Resources/DataExportResource/Pages/ListDataExports.php b/app/Filament/Resources/DataExportResource/Pages/ListDataExports.php new file mode 100644 index 0000000..28f2d82 --- /dev/null +++ b/app/Filament/Resources/DataExportResource/Pages/ListDataExports.php @@ -0,0 +1,20 @@ +label(__('admin.data_exports.actions.request')), + ]; + } +} diff --git a/app/Filament/Resources/DataExportResource/Schemas/DataExportForm.php b/app/Filament/Resources/DataExportResource/Schemas/DataExportForm.php new file mode 100644 index 0000000..649b7ab --- /dev/null +++ b/app/Filament/Resources/DataExportResource/Schemas/DataExportForm.php @@ -0,0 +1,68 @@ +schema([ + Section::make(__('admin.data_exports.sections.request')) + ->schema([ + Select::make('scope') + ->label(__('admin.data_exports.fields.scope')) + ->options([ + DataExportScope::TENANT->value => __('admin.data_exports.scope.tenant'), + DataExportScope::EVENT->value => __('admin.data_exports.scope.event'), + ]) + ->default(DataExportScope::TENANT->value) + ->live() + ->required(), + Select::make('tenant_id') + ->label(__('admin.data_exports.fields.tenant')) + ->options(Tenant::query()->orderBy('name')->pluck('name', 'id')) + ->searchable() + ->preload() + ->required() + ->live(), + Select::make('event_id') + ->label(__('admin.data_exports.fields.event')) + ->options(function (Get $get): array { + $tenantId = $get('tenant_id'); + if (! $tenantId) { + return []; + } + + return Event::query() + ->where('tenant_id', $tenantId) + ->orderByDesc('date') + ->get() + ->mapWithKeys(function (Event $event): array { + $name = $event->name['de'] ?? $event->name['en'] ?? $event->slug; + + return [$event->id => $name]; + }) + ->all(); + }) + ->searchable() + ->preload() + ->visible(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value) + ->required(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value) + ->dehydrated(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value), + Toggle::make('include_media') + ->label(__('admin.data_exports.fields.include_media')) + ->helperText(__('admin.data_exports.help.include_media')), + ]) + ->columns(2), + ]); + } +} diff --git a/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php b/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php new file mode 100644 index 0000000..4ac7381 --- /dev/null +++ b/app/Filament/Resources/DataExportResource/Tables/DataExportTable.php @@ -0,0 +1,86 @@ +columns([ + TextColumn::make('id') + ->label(__('admin.data_exports.fields.id')) + ->sortable(), + TextColumn::make('tenant.name') + ->label(__('admin.data_exports.fields.tenant')) + ->searchable(), + TextColumn::make('event.slug') + ->label(__('admin.data_exports.fields.event')) + ->toggleable() + ->placeholder('—'), + TextColumn::make('scope') + ->label(__('admin.data_exports.fields.scope')) + ->badge() + ->formatStateUsing(fn (?string $state) => $state ? __('admin.data_exports.scope.'.$state) : '—'), + TextColumn::make('status') + ->label(__('admin.data_exports.fields.status')) + ->badge() + ->formatStateUsing(fn (string $state) => __('admin.data_exports.status.'.$state)) + ->color(fn (string $state) => match ($state) { + DataExport::STATUS_READY => 'success', + DataExport::STATUS_FAILED => 'danger', + DataExport::STATUS_PROCESSING => 'warning', + default => 'gray', + }), + IconColumn::make('include_media') + ->label(__('admin.data_exports.fields.include_media')) + ->boolean(), + TextColumn::make('size_bytes') + ->label(__('admin.data_exports.fields.size')) + ->formatStateUsing(fn (?int $state) => $state ? Number::fileSize($state) : '—') + ->toggleable(), + TextColumn::make('created_at') + ->label(__('admin.data_exports.fields.created_at')) + ->since() + ->sortable(), + TextColumn::make('expires_at') + ->label(__('admin.data_exports.fields.expires_at')) + ->since() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('scope') + ->label(__('admin.data_exports.fields.scope')) + ->options([ + 'tenant' => __('admin.data_exports.scope.tenant'), + 'event' => __('admin.data_exports.scope.event'), + 'user' => __('admin.data_exports.scope.user'), + ]), + SelectFilter::make('status') + ->label(__('admin.data_exports.fields.status')) + ->options([ + DataExport::STATUS_PENDING => __('admin.data_exports.status.pending'), + DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'), + DataExport::STATUS_READY => __('admin.data_exports.status.ready'), + DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'), + ]), + ]) + ->actions([ + Action::make('download') + ->label(__('admin.data_exports.actions.download')) + ->icon('heroicon-o-arrow-down-tray') + ->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record)) + ->openUrlInNewTab() + ->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource.php b/app/Filament/Resources/RetentionOverrideResource.php new file mode 100644 index 0000000..6a41b80 --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource.php @@ -0,0 +1,75 @@ +with(['tenant', 'event', 'createdBy', 'releasedBy']); + } + + public static function getPages(): array + { + return [ + 'index' => ListRetentionOverrides::route('/'), + 'create' => CreateRetentionOverride::route('/create'), + 'edit' => EditRetentionOverride::route('/{record}/edit'), + ]; + } + + public static function canDelete($record): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource/Pages/CreateRetentionOverride.php b/app/Filament/Resources/RetentionOverrideResource/Pages/CreateRetentionOverride.php new file mode 100644 index 0000000..8bb121b --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource/Pages/CreateRetentionOverride.php @@ -0,0 +1,26 @@ +id(); + $data['released_at'] = null; + $data['released_by_id'] = null; + + if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) { + $data['event_id'] = null; + } + + return $data; + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource/Pages/EditRetentionOverride.php b/app/Filament/Resources/RetentionOverrideResource/Pages/EditRetentionOverride.php new file mode 100644 index 0000000..2ad24b6 --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource/Pages/EditRetentionOverride.php @@ -0,0 +1,44 @@ +label(__('admin.retention_overrides.actions.release')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn () => $this->record instanceof RetentionOverride && $this->record->released_at === null) + ->action(function (): void { + if (! ($this->record instanceof RetentionOverride) || $this->record->released_at !== null) { + return; + } + + $this->record->forceFill([ + 'released_at' => now(), + 'released_by_id' => Filament::auth()->id(), + ])->save(); + + app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $this->record, + SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']), + static::class + ); + }), + ]; + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource/Pages/ListRetentionOverrides.php b/app/Filament/Resources/RetentionOverrideResource/Pages/ListRetentionOverrides.php new file mode 100644 index 0000000..9ee076a --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource/Pages/ListRetentionOverrides.php @@ -0,0 +1,20 @@ +label(__('admin.retention_overrides.actions.request')), + ]; + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource/Schemas/RetentionOverrideForm.php b/app/Filament/Resources/RetentionOverrideResource/Schemas/RetentionOverrideForm.php new file mode 100644 index 0000000..db130ed --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource/Schemas/RetentionOverrideForm.php @@ -0,0 +1,98 @@ +components([ + Section::make(__('admin.retention_overrides.sections.override')) + ->schema([ + Select::make('scope') + ->label(__('admin.retention_overrides.fields.scope')) + ->options([ + RetentionOverrideScope::TENANT->value => __('admin.retention_overrides.scope.tenant'), + RetentionOverrideScope::EVENT->value => __('admin.retention_overrides.scope.event'), + ]) + ->default(RetentionOverrideScope::TENANT->value) + ->required() + ->live() + ->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null), + Select::make('tenant_id') + ->label(__('admin.retention_overrides.fields.tenant')) + ->options(Tenant::query()->orderBy('name')->pluck('name', 'id')) + ->searchable() + ->preload() + ->required() + ->live() + ->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null), + Select::make('event_id') + ->label(__('admin.retention_overrides.fields.event')) + ->options(function (Get $get): array { + $tenantId = $get('tenant_id'); + if (! $tenantId) { + return []; + } + + return Event::query() + ->where('tenant_id', $tenantId) + ->orderByDesc('date') + ->get() + ->mapWithKeys(function (Event $event): array { + $name = $event->name['de'] ?? $event->name['en'] ?? $event->slug; + + return [$event->id => $name]; + }) + ->all(); + }) + ->searchable() + ->preload() + ->visible(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value) + ->required(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value) + ->dehydrated(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value) + ->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null), + TextInput::make('reason') + ->label(__('admin.retention_overrides.fields.reason')) + ->maxLength(200) + ->required() + ->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null), + Textarea::make('note') + ->label(__('admin.retention_overrides.fields.note')) + ->rows(3) + ->maxLength(2000) + ->columnSpanFull() + ->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null), + ]) + ->columns(2), + Section::make(__('admin.retention_overrides.sections.status')) + ->schema([ + Placeholder::make('created_by_id') + ->label(__('admin.retention_overrides.fields.created_by')) + ->content(fn (?RetentionOverride $record) => $record?->createdBy?->name ?? '—'), + Placeholder::make('created_at') + ->label(__('admin.retention_overrides.fields.created_at')) + ->content(fn (?RetentionOverride $record) => $record?->created_at?->diffForHumans() ?? '—'), + Placeholder::make('released_by_id') + ->label(__('admin.retention_overrides.fields.released_by')) + ->content(fn (?RetentionOverride $record) => $record?->releasedBy?->name ?? '—'), + Placeholder::make('released_at') + ->label(__('admin.retention_overrides.fields.released_at')) + ->content(fn (?RetentionOverride $record) => $record?->released_at?->diffForHumans() ?? '—'), + ]) + ->columns(2), + ]); + } +} diff --git a/app/Filament/Resources/RetentionOverrideResource/Tables/RetentionOverrideTable.php b/app/Filament/Resources/RetentionOverrideResource/Tables/RetentionOverrideTable.php new file mode 100644 index 0000000..f371436 --- /dev/null +++ b/app/Filament/Resources/RetentionOverrideResource/Tables/RetentionOverrideTable.php @@ -0,0 +1,111 @@ +defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('id') + ->label(__('admin.retention_overrides.fields.id')) + ->sortable(), + TextColumn::make('scope') + ->label(__('admin.retention_overrides.fields.scope')) + ->badge() + ->formatStateUsing(fn (?string $state) => $state ? __('admin.retention_overrides.scope.'.$state) : '—'), + TextColumn::make('tenant.name') + ->label(__('admin.retention_overrides.fields.tenant')) + ->searchable(), + TextColumn::make('event.slug') + ->label(__('admin.retention_overrides.fields.event')) + ->toggleable() + ->placeholder('—'), + TextColumn::make('reason') + ->label(__('admin.retention_overrides.fields.reason')) + ->limit(40) + ->searchable(), + TextColumn::make('status') + ->label(__('admin.retention_overrides.fields.status')) + ->state(fn (RetentionOverride $record) => $record->released_at ? 'released' : 'active') + ->badge() + ->formatStateUsing(fn (string $state) => __('admin.retention_overrides.status.'.$state)) + ->color(fn (string $state) => $state === 'released' ? 'gray' : 'success'), + TextColumn::make('createdBy.name') + ->label(__('admin.retention_overrides.fields.created_by')) + ->toggleable() + ->placeholder('—'), + TextColumn::make('created_at') + ->label(__('admin.retention_overrides.fields.created_at')) + ->since() + ->sortable(), + TextColumn::make('releasedBy.name') + ->label(__('admin.retention_overrides.fields.released_by')) + ->toggleable(isToggledHiddenByDefault: true) + ->placeholder('—'), + TextColumn::make('released_at') + ->label(__('admin.retention_overrides.fields.released_at')) + ->since() + ->toggleable(isToggledHiddenByDefault: true) + ->placeholder('—'), + ]) + ->filters([ + SelectFilter::make('scope') + ->label(__('admin.retention_overrides.fields.scope')) + ->options([ + 'tenant' => __('admin.retention_overrides.scope.tenant'), + 'event' => __('admin.retention_overrides.scope.event'), + ]), + SelectFilter::make('status') + ->label(__('admin.retention_overrides.fields.status')) + ->options([ + 'active' => __('admin.retention_overrides.status.active'), + 'released' => __('admin.retention_overrides.status.released'), + ]) + ->query(function (Builder $query, array $data): Builder { + return match ($data['value'] ?? null) { + 'active' => $query->whereNull('released_at'), + 'released' => $query->whereNotNull('released_at'), + default => $query, + }; + }), + ]) + ->actions([ + Action::make('release') + ->label(__('admin.retention_overrides.actions.release')) + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn (RetentionOverride $record): bool => $record->released_at === null) + ->action(function (RetentionOverride $record): void { + if ($record->released_at !== null) { + return; + } + + $record->forceFill([ + 'released_at' => now(), + 'released_by_id' => Filament::auth()->id(), + ])->save(); + + app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $record, + SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']), + static::class + ); + }), + ]) + ->bulkActions([]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/DataExportController.php b/app/Http/Controllers/Api/Tenant/DataExportController.php new file mode 100644 index 0000000..889f6f4 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/DataExportController.php @@ -0,0 +1,190 @@ +resolveTenant($request); + + $exports = DataExport::query() + ->with('event') + ->where('tenant_id', $tenant->id) + ->whereIn('scope', ['tenant', 'event']) + ->latest() + ->limit(10) + ->get() + ->map(fn (DataExport $export) => [ + 'id' => $export->id, + 'scope' => $export->scope?->value ?? $export->scope, + 'status' => $export->status, + 'include_media' => (bool) $export->include_media, + 'size_bytes' => $export->size_bytes, + 'created_at' => optional($export->created_at)->toIso8601String(), + 'expires_at' => optional($export->expires_at)->toIso8601String(), + 'download_url' => $export->isReady() && ! $export->hasExpired() + ? route('api.v1.tenant.exports.download', $export) + : null, + 'error_message' => $export->error_message, + 'event' => $export->event ? [ + 'id' => $export->event->id, + 'slug' => $export->event->slug, + 'name' => $export->event->name, + ] : null, + ]); + + return response()->json([ + 'data' => $exports, + ]); + } + + public function store(DataExportStoreRequest $request): JsonResponse + { + $tenant = $this->resolveTenant($request); + $user = $request->user(); + + if (! $user) { + return ApiError::response( + 'export_user_missing', + 'Export user missing', + 'Unable to determine the requesting user.', + Response::HTTP_UNAUTHORIZED + ); + } + + $payload = $request->validated(); + $scope = $payload['scope']; + $event = null; + + if ($scope === 'event') { + $event = Event::query() + ->where('tenant_id', $tenant->id) + ->find($payload['event_id']); + + if (! $event) { + return ApiError::response( + 'export_event_missing', + 'Event not found', + 'The selected event does not exist for this tenant.', + Response::HTTP_NOT_FOUND + ); + } + } + + $hasInProgress = DataExport::query() + ->where('tenant_id', $tenant->id) + ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) + ->exists(); + + if ($hasInProgress) { + return ApiError::response( + 'export_in_progress', + 'Export already in progress', + 'Please wait for the current export to finish before requesting another.', + Response::HTTP_CONFLICT + ); + } + + $export = DataExport::query()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'event_id' => $event?->id, + 'scope' => $scope, + 'include_media' => (bool) ($payload['include_media'] ?? false), + 'status' => DataExport::STATUS_PENDING, + ]); + + GenerateDataExport::dispatch($export->id); + + return response()->json([ + 'message' => 'Export started.', + 'data' => [ + 'id' => $export->id, + 'scope' => $export->scope?->value ?? $export->scope, + 'status' => $export->status, + 'include_media' => (bool) $export->include_media, + 'created_at' => optional($export->created_at)->toIso8601String(), + ], + ], Response::HTTP_ACCEPTED); + } + + public function download(Request $request, DataExport $export): StreamedResponse|JsonResponse + { + $tenant = $this->resolveTenant($request); + + if ((int) $export->tenant_id !== (int) $tenant->id) { + return ApiError::response( + 'export_not_found', + 'Export not found', + 'The requested export is not available for this tenant.', + Response::HTTP_NOT_FOUND + ); + } + + if (! $export->isReady() || $export->hasExpired() || ! $export->path) { + return ApiError::response( + 'export_not_ready', + 'Export not ready', + 'The export is not ready or has expired.', + Response::HTTP_BAD_REQUEST + ); + } + + $disk = 'local'; + + if (! Storage::disk($disk)->exists($export->path)) { + return ApiError::response( + 'export_missing', + 'Export not found', + 'The export archive could not be located.', + Response::HTTP_NOT_FOUND + ); + } + + return Storage::disk($disk)->download( + $export->path, + sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')), + [ + 'Cache-Control' => 'private, no-store', + ] + ); + } + + private function resolveTenant(Request $request): Tenant + { + $tenant = $request->attributes->get('tenant'); + + if ($tenant instanceof Tenant) { + return $tenant; + } + + $tenantId = $request->attributes->get('tenant_id') + ?? $request->attributes->get('current_tenant_id') + ?? $request->user()?->tenant_id; + + if ($tenantId) { + $tenant = Tenant::query()->find($tenantId); + if ($tenant) { + $request->attributes->set('tenant', $tenant); + + return $tenant; + } + } + + abort(401, 'Tenant context missing.'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index a510b87..f523f61 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\DataExportScope; use App\Models\DataExport; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\Request; @@ -45,6 +46,7 @@ class ProfileController extends Controller ->all(); $recentExports = $user->dataExports() + ->where('scope', DataExportScope::USER->value) ->latest() ->limit(5) ->get() @@ -61,6 +63,7 @@ class ProfileController extends Controller ]); $pendingExport = $user->dataExports() + ->where('scope', DataExportScope::USER->value) ->whereIn('status', [ DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING, @@ -68,6 +71,7 @@ class ProfileController extends Controller ->exists(); $lastReadyExport = $user->dataExports() + ->where('scope', DataExportScope::USER->value) ->where('status', DataExport::STATUS_READY) ->latest('created_at') ->first(); diff --git a/app/Http/Controllers/ProfileDataExportController.php b/app/Http/Controllers/ProfileDataExportController.php index 8cff323..38e3d67 100644 --- a/app/Http/Controllers/ProfileDataExportController.php +++ b/app/Http/Controllers/ProfileDataExportController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\DataExportScope; use App\Jobs\GenerateDataExport; use App\Models\DataExport; use Illuminate\Http\RedirectResponse; @@ -17,6 +18,7 @@ class ProfileDataExportController extends Controller abort_unless($user, 403); $hasRecentExport = $user->dataExports() + ->where('scope', DataExportScope::USER->value) ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) ->exists(); @@ -25,6 +27,7 @@ class ProfileDataExportController extends Controller } $recentReadyExport = $user->dataExports() + ->where('scope', DataExportScope::USER->value) ->where('status', DataExport::STATUS_READY) ->where('created_at', '>=', now()->subDay()) ->exists(); @@ -36,6 +39,8 @@ class ProfileDataExportController extends Controller $export = $user->dataExports()->create([ 'tenant_id' => $user->tenant_id, 'status' => DataExport::STATUS_PENDING, + 'scope' => DataExportScope::USER->value, + 'include_media' => false, ]); GenerateDataExport::dispatch($export->id); diff --git a/app/Http/Controllers/SuperAdmin/DataExportController.php b/app/Http/Controllers/SuperAdmin/DataExportController.php new file mode 100644 index 0000000..2697247 --- /dev/null +++ b/app/Http/Controllers/SuperAdmin/DataExportController.php @@ -0,0 +1,32 @@ +isReady() || $export->hasExpired() || ! $export->path) { + abort(404); + } + + $disk = 'local'; + + if (! Storage::disk($disk)->exists($export->path)) { + abort(404); + } + + return Storage::disk($disk)->download( + $export->path, + sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')), + [ + 'Cache-Control' => 'private, no-store', + ] + ); + } +} diff --git a/app/Http/Requests/Tenant/DataExportStoreRequest.php b/app/Http/Requests/Tenant/DataExportStoreRequest.php new file mode 100644 index 0000000..312d86e --- /dev/null +++ b/app/Http/Requests/Tenant/DataExportStoreRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'scope' => ['required', Rule::in(['tenant', 'event'])], + 'event_id' => ['required_if:scope,event', 'integer', 'exists:events,id'], + 'include_media' => ['nullable', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'scope.required' => 'Export scope is required.', + 'scope.in' => 'Export scope must be tenant or event.', + 'event_id.required_if' => 'Event export requires an event.', + 'event_id.exists' => 'Selected event could not be found.', + ]; + } +} diff --git a/app/Jobs/GenerateDataExport.php b/app/Jobs/GenerateDataExport.php index 46d02e6..dd7f95e 100644 --- a/app/Jobs/GenerateDataExport.php +++ b/app/Jobs/GenerateDataExport.php @@ -2,11 +2,13 @@ namespace App\Jobs; +use App\Enums\DataExportScope; use App\Models\DataExport; use App\Models\Event; +use App\Models\EventMediaAsset; use App\Models\PackagePurchase; +use App\Models\Photo; use App\Models\Tenant; -use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -31,7 +33,7 @@ class GenerateDataExport implements ShouldQueue public function handle(): void { - $export = DataExport::with(['user', 'tenant'])->find($this->exportId); + $export = DataExport::with(['user', 'tenant', 'event'])->find($this->exportId); if (! $export) { return; @@ -46,10 +48,41 @@ class GenerateDataExport implements ShouldQueue return; } + if ($export->scope === DataExportScope::TENANT && ! $export->tenant) { + $export->update([ + 'status' => DataExport::STATUS_FAILED, + 'error_message' => 'Tenant no longer exists.', + ]); + + return; + } + + if ($export->scope === DataExportScope::EVENT && ! $export->event) { + $export->update([ + 'status' => DataExport::STATUS_FAILED, + 'error_message' => 'Event no longer exists.', + ]); + + return; + } + + if ( + $export->event + && $export->tenant + && (int) $export->event->tenant_id !== (int) $export->tenant->id + ) { + $export->update([ + 'status' => DataExport::STATUS_FAILED, + 'error_message' => 'Event does not belong to tenant.', + ]); + + return; + } + $export->update(['status' => DataExport::STATUS_PROCESSING, 'error_message' => null]); try { - $payload = $this->buildPayload($export->user, $export->tenant); + $payload = $this->buildPayload($export); $zipPath = $this->writeArchive($export, $payload); $export->update([ 'status' => DataExport::STATUS_READY, @@ -70,10 +103,16 @@ class GenerateDataExport implements ShouldQueue /** * @return array */ - protected function buildPayload(User $user, ?Tenant $tenant): array + protected function buildPayload(DataExport $export): array { + $user = $export->user; + $tenant = $export->tenant; + $event = $export->event; + $profile = [ 'generated_at' => now()->toIso8601String(), + 'scope' => $export->scope?->value ?? DataExportScope::USER->value, + 'include_media' => (bool) $export->include_media, 'user' => [ 'id' => $user->id, 'name' => $user->name, @@ -98,18 +137,34 @@ class GenerateDataExport implements ShouldQueue ]; } - $events = $tenant - ? $this->collectEvents($tenant) - : []; - $invoices = $tenant + $payload = [ + 'profile' => $profile, + ]; + + if ($export->scope === DataExportScope::EVENT && $event) { + $event->loadCount([ + 'photos as photos_total', + 'photos as featured_photos_total' => fn ($query) => $query->where('is_featured', true), + 'joinTokens', + 'members', + ]); + + $payload['event'] = $this->buildEventSummary( + $event, + $this->countEventLikes($event) + ); + $payload['photos'] = $this->collectEventPhotos($event); + } else { + $payload['events'] = $tenant + ? $this->collectEvents($tenant) + : []; + } + + $payload['invoices'] = $tenant ? $this->collectInvoices($tenant) : []; - return [ - 'profile' => $profile, - 'events' => $events, - 'invoices' => $invoices, - ]; + return $payload; } /** @@ -135,25 +190,66 @@ class GenerateDataExport implements ShouldQueue ->pluck(DB::raw('COUNT(*)'), 'photos.event_id'); return $events - ->map(function (Event $event) use ($likeCounts): array { - $likes = (int) ($likeCounts[$event->id] ?? 0); + ->map(fn (Event $event): array => $this->buildEventSummary( + $event, + (int) ($likeCounts[$event->id] ?? 0) + )) + ->all(); + } - return [ - 'id' => $event->id, - 'slug' => $event->slug, - 'status' => $event->status, - 'name' => $event->name, - 'location' => $event->location, - 'date' => optional($event->date)->toIso8601String(), - 'photos_total' => (int) ($event->photos_total ?? 0), - 'featured_photos_total' => (int) ($event->featured_photos_total ?? 0), - 'join_tokens_total' => (int) ($event->join_tokens_count ?? 0), - 'members_total' => (int) ($event->members_count ?? 0), - 'likes_total' => $likes, - 'created_at' => optional($event->created_at)->toIso8601String(), - 'updated_at' => optional($event->updated_at)->toIso8601String(), - ]; - }) + /** + * @return array + */ + protected function buildEventSummary(Event $event, ?int $likes = null): array + { + $likes = $likes ?? $this->countEventLikes($event); + + return [ + 'id' => $event->id, + 'slug' => $event->slug, + 'status' => $event->status, + 'name' => $event->name, + 'location' => $event->location, + 'date' => optional($event->date)->toIso8601String(), + 'photos_total' => (int) ($event->photos_total ?? $event->photos()->count()), + 'featured_photos_total' => (int) ($event->featured_photos_total ?? $event->photos()->where('is_featured', true)->count()), + 'join_tokens_total' => (int) ($event->join_tokens_count ?? $event->joinTokens()->count()), + 'members_total' => (int) ($event->members_count ?? $event->members()->count()), + 'likes_total' => $likes, + 'created_at' => optional($event->created_at)->toIso8601String(), + 'updated_at' => optional($event->updated_at)->toIso8601String(), + ]; + } + + protected function countEventLikes(Event $event): int + { + return (int) DB::table('photo_likes') + ->join('photos', 'photo_likes.photo_id', '=', 'photos.id') + ->where('photos.event_id', $event->id) + ->count(); + } + + /** + * @return array> + */ + protected function collectEventPhotos(Event $event): array + { + return Photo::query() + ->withCount('likes') + ->where('event_id', $event->id) + ->orderBy('created_at') + ->get() + ->map(fn (Photo $photo): array => [ + 'id' => $photo->id, + 'status' => $photo->status ?? null, + 'ingest_source' => $photo->ingest_source, + 'is_featured' => (bool) $photo->is_featured, + 'emotion_id' => $photo->emotion_id, + 'task_id' => $photo->task_id, + 'likes_total' => (int) ($photo->likes_count ?? 0), + 'created_at' => optional($photo->created_at)->toIso8601String(), + 'moderated_at' => optional($photo->moderated_at)->toIso8601String(), + ]) ->all(); } @@ -189,7 +285,11 @@ class GenerateDataExport implements ShouldQueue */ protected function writeArchive(DataExport $export, array $payload): string { - $directory = 'exports/user-'.$export->user_id; + $directory = match ($export->scope) { + DataExportScope::TENANT => 'exports/tenant-'.$export->tenant_id, + DataExportScope::EVENT => 'exports/event-'.$export->event_id, + default => 'exports/user-'.$export->user_id, + }; Storage::disk('local')->makeDirectory($directory); $filename = sprintf('data-export-%s.zip', Str::uuid()); $path = $directory.'/'.$filename; @@ -201,9 +301,39 @@ class GenerateDataExport implements ShouldQueue throw new \RuntimeException('Unable to create export archive.'); } - $zip->addFromString('profile.json', json_encode($payload['profile'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); - $zip->addFromString('events.json', json_encode($payload['events'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); - $zip->addFromString('invoices.json', json_encode($payload['invoices'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $zip->addFromString('export.json', json_encode([ + 'id' => $export->id, + 'scope' => $export->scope?->value ?? DataExportScope::USER->value, + 'include_media' => (bool) $export->include_media, + 'tenant_id' => $export->tenant_id, + 'event_id' => $export->event_id, + 'requested_by' => $export->user?->id, + 'generated_at' => now()->toIso8601String(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + if (array_key_exists('profile', $payload)) { + $zip->addFromString('profile.json', json_encode($payload['profile'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + if (array_key_exists('events', $payload)) { + $zip->addFromString('events.json', json_encode($payload['events'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + if (array_key_exists('event', $payload)) { + $zip->addFromString('event.json', json_encode($payload['event'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + if (array_key_exists('photos', $payload)) { + $zip->addFromString('photos.json', json_encode($payload['photos'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + if (array_key_exists('invoices', $payload)) { + $zip->addFromString('invoices.json', json_encode($payload['invoices'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + + if ($export->include_media) { + $this->appendMediaFiles($zip, $export); + } $locale = $export->user?->preferred_locale ?? app()->getLocale(); $readme = implode("\n", [ @@ -221,4 +351,119 @@ class GenerateDataExport implements ShouldQueue return $path; } + + /** + * @return array + */ + protected function resolveMediaEvents(DataExport $export): array + { + if ($export->scope === DataExportScope::EVENT && $export->event) { + return [$export->event]; + } + + if ($export->scope === DataExportScope::TENANT && $export->tenant) { + return Event::query() + ->where('tenant_id', $export->tenant->id) + ->orderBy('date') + ->get() + ->all(); + } + + return []; + } + + protected function appendMediaFiles(ZipArchive $zip, DataExport $export): void + { + $events = $this->resolveMediaEvents($export); + + foreach ($events as $event) { + $photos = Photo::query() + ->with('mediaAsset') + ->where('event_id', $event->id) + ->orderBy('created_at') + ->get(); + + foreach ($photos as $photo) { + $asset = $this->resolveOriginalAsset($photo); + $sourcePath = $asset?->path ?? $photo->file_path; + + if (! $sourcePath) { + continue; + } + + $filename = $this->buildMediaFilename($event, $photo, $sourcePath); + + if ($asset && $asset->path) { + $this->addDiskFileToArchive( + $zip, + $asset->disk ?? config('filesystems.default'), + $asset->path, + $filename + ); + } else { + $this->addDiskFileToArchive( + $zip, + config('filesystems.default'), + $photo->file_path, + $filename + ); + } + } + } + } + + protected function resolveOriginalAsset(Photo $photo): ?EventMediaAsset + { + $asset = $photo->mediaAsset; + + if ($asset && $asset->variant === 'original') { + return $asset; + } + + $original = EventMediaAsset::query() + ->where('photo_id', $photo->id) + ->where('variant', 'original') + ->first(); + + return $original ?? $asset; + } + + protected function buildMediaFilename(Event $event, Photo $photo, string $sourcePath): string + { + $eventSlug = $event->slug ?: 'event-'.$event->id; + $timestamp = $photo->created_at?->format('Ymd_His') ?? now()->format('Ymd_His'); + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION) ?: 'jpg'; + + return sprintf('media/%s/%s-photo-%d.%s', $eventSlug, $timestamp, $photo->id, $extension); + } + + protected function addDiskFileToArchive(ZipArchive $zip, ?string $diskName, string $path, string $filename): bool + { + $disk = $diskName ? Storage::disk($diskName) : Storage::disk(config('filesystems.default')); + + try { + if (method_exists($disk, 'path')) { + $absolute = $disk->path($path); + + if (is_file($absolute)) { + return $zip->addFile($absolute, $filename); + } + } + + $stream = $disk->readStream($path); + + if ($stream) { + $contents = stream_get_contents($stream); + fclose($stream); + + if ($contents !== false) { + return $zip->addFromString($filename, $contents); + } + } + } catch (\Throwable $exception) { + report($exception); + } + + return false; + } } diff --git a/app/Models/DataExport.php b/app/Models/DataExport.php index 304bbd0..26c0943 100644 --- a/app/Models/DataExport.php +++ b/app/Models/DataExport.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\DataExportScope; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,7 +22,10 @@ class DataExport extends Model protected $fillable = [ 'user_id', 'tenant_id', + 'event_id', 'status', + 'scope', + 'include_media', 'path', 'size_bytes', 'expires_at', @@ -30,6 +34,8 @@ class DataExport extends Model protected $casts = [ 'expires_at' => 'datetime', + 'include_media' => 'boolean', + 'scope' => DataExportScope::class, ]; public function user(): BelongsTo @@ -42,6 +48,11 @@ class DataExport extends Model return $this->belongsTo(Tenant::class); } + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + public function isReady(): bool { return $this->status === self::STATUS_READY; diff --git a/app/Models/Event.php b/app/Models/Event.php index 5fb6ff0..d018db1 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -104,6 +104,11 @@ class Event extends Model return $this->hasMany(EventPackage::class); } + public function retentionOverrides(): HasMany + { + return $this->hasMany(RetentionOverride::class); + } + public function joinTokens(): HasMany { return $this->hasMany(EventJoinToken::class); diff --git a/app/Models/RetentionOverride.php b/app/Models/RetentionOverride.php new file mode 100644 index 0000000..1fb51d1 --- /dev/null +++ b/app/Models/RetentionOverride.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + protected $fillable = [ + 'scope', + 'tenant_id', + 'event_id', + 'reason', + 'note', + 'created_by_id', + 'released_by_id', + 'released_at', + ]; + + protected $casts = [ + 'scope' => RetentionOverrideScope::class, + 'released_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function releasedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'released_by_id'); + } + + public function isActive(): bool + { + return $this->released_at === null; + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1858bc5..9cae9f2 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -86,6 +86,11 @@ class Tenant extends Model return $this->hasMany(TenantAnnouncementDelivery::class); } + public function retentionOverrides(): HasMany + { + return $this->hasMany(RetentionOverride::class); + } + public function packages(): BelongsToMany { return $this->belongsToMany(Package::class, 'tenant_packages') diff --git a/app/Services/Compliance/RetentionOverrideService.php b/app/Services/Compliance/RetentionOverrideService.php new file mode 100644 index 0000000..a533add --- /dev/null +++ b/app/Services/Compliance/RetentionOverrideService.php @@ -0,0 +1,36 @@ +where('scope', RetentionOverrideScope::TENANT->value) + ->where('tenant_id', $tenant->id) + ->whereNull('released_at') + ->exists(); + } + + public function eventOnHold(Event $event): bool + { + return RetentionOverride::query() + ->whereNull('released_at') + ->where(function ($query) use ($event) { + $query->where(function ($inner) use ($event) { + $inner->where('scope', RetentionOverrideScope::EVENT->value) + ->where('event_id', $event->id); + })->orWhere(function ($inner) use ($event) { + $inner->where('scope', RetentionOverrideScope::TENANT->value) + ->where('tenant_id', $event->tenant_id); + }); + }) + ->exists(); + } +} diff --git a/database/factories/RetentionOverrideFactory.php b/database/factories/RetentionOverrideFactory.php new file mode 100644 index 0000000..8cc4559 --- /dev/null +++ b/database/factories/RetentionOverrideFactory.php @@ -0,0 +1,33 @@ + + */ +class RetentionOverrideFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'scope' => RetentionOverrideScope::TENANT, + 'tenant_id' => Tenant::factory(), + 'event_id' => null, + 'reason' => $this->faker->sentence(3), + 'note' => $this->faker->optional()->sentence(), + 'created_by_id' => User::factory(), + 'released_by_id' => null, + 'released_at' => null, + ]; + } +} diff --git a/database/migrations/2026_01_02_190343_create_retention_overrides_table.php b/database/migrations/2026_01_02_190343_create_retention_overrides_table.php new file mode 100644 index 0000000..d4f6c2e --- /dev/null +++ b/database/migrations/2026_01_02_190343_create_retention_overrides_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('scope', 20); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('event_id')->nullable()->constrained()->nullOnDelete(); + $table->string('reason', 200); + $table->text('note')->nullable(); + $table->foreignId('created_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('released_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('released_at')->nullable(); + $table->timestamps(); + + $table->index(['scope', 'released_at']); + $table->index(['tenant_id', 'released_at']); + $table->index(['event_id', 'released_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('retention_overrides'); + } +}; diff --git a/database/migrations/2026_01_02_190408_add_scope_fields_to_data_exports_table.php b/database/migrations/2026_01_02_190408_add_scope_fields_to_data_exports_table.php new file mode 100644 index 0000000..d043e14 --- /dev/null +++ b/database/migrations/2026_01_02_190408_add_scope_fields_to_data_exports_table.php @@ -0,0 +1,35 @@ +string('scope', 20)->default('user')->after('tenant_id'); + $table->foreignId('event_id')->nullable()->after('scope')->constrained()->nullOnDelete(); + $table->boolean('include_media')->default(false)->after('event_id'); + + $table->index(['scope']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('data_exports', function (Blueprint $table) { + $table->dropIndex(['scope']); + $table->dropColumn('include_media'); + $table->dropConstrainedForeignId('event_id'); + $table->dropColumn('scope'); + }); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index b042fec..c323be0 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -430,6 +430,23 @@ export type NotificationLogEntry = { is_read?: boolean; }; +export type DataExportSummary = { + id: number; + scope: 'tenant' | 'event'; + status: 'pending' | 'processing' | 'ready' | 'failed'; + include_media: boolean; + size_bytes: number | null; + created_at: string | null; + expires_at: string | null; + download_url: string | null; + error_message?: string | null; + event?: { + id: number; + slug: string; + name: string | Record; + } | null; +}; + export type PaddleTransactionSummary = { id: string | null; status: string | null; @@ -2133,6 +2150,39 @@ function normalizeNotificationLog(entry: JsonValue): NotificationLogEntry | null }; } +function normalizeDataExport(entry: JsonValue): DataExportSummary | null { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return null; + } + + const row = entry as Record; + const event = row.event; + const eventRecord = event && typeof event === 'object' && !Array.isArray(event) + ? (event as Record) + : null; + + return { + id: Number(row.id ?? 0), + scope: row.scope === 'event' ? 'event' : 'tenant', + status: typeof row.status === 'string' ? (row.status as DataExportSummary['status']) : 'pending', + include_media: Boolean(row.include_media), + size_bytes: typeof row.size_bytes === 'number' ? row.size_bytes : null, + created_at: typeof row.created_at === 'string' ? row.created_at : null, + expires_at: typeof row.expires_at === 'string' ? row.expires_at : null, + download_url: typeof row.download_url === 'string' ? row.download_url : null, + error_message: typeof row.error_message === 'string' ? row.error_message : null, + event: eventRecord + ? { + id: Number(eventRecord.id ?? 0), + slug: typeof eventRecord.slug === 'string' ? eventRecord.slug : '', + name: typeof eventRecord.name === 'string' || typeof eventRecord.name === 'object' + ? (eventRecord.name as DataExportSummary['event']['name']) + : '', + } + : null, + }; +} + export async function listNotificationLogs(options?: { page?: number; perPage?: number; @@ -2178,6 +2228,51 @@ export async function markNotificationLogs(ids: number[], status: 'read' | 'dism }); } +export async function listTenantDataExports(): Promise { + const response = await authorizedFetch('/api/v1/tenant/exports'); + const payload = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load data exports'); + const rows = Array.isArray(payload.data) ? payload.data : []; + + return rows + .map((row) => normalizeDataExport(row)) + .filter((row): row is DataExportSummary => Boolean(row)); +} + +export async function requestTenantDataExport(payload: { + scope: 'tenant' | 'event'; + eventId?: number; + includeMedia?: boolean; +}): Promise { + const response = await authorizedFetch('/api/v1/tenant/exports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scope: payload.scope, + event_id: payload.eventId, + include_media: payload.includeMedia, + }), + }); + + const body = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to request export'); + const record = body.data ? normalizeDataExport(body.data) : null; + + return record ?? null; +} + +export async function downloadTenantDataExport(downloadUrl: string): Promise { + const response = await authorizedFetch(downloadUrl, { + headers: { 'Accept': 'application/octet-stream' }, + }); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to download data export', response.status, payload); + throw new Error('Failed to download data export'); + } + + return response.blob(); +} + export async function getTenantPaddleTransactions(cursor?: string): Promise<{ data: PaddleTransactionSummary[]; nextCursor: string | null; diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index ca57f34..086d8e2 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -13,6 +13,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); export const ADMIN_BILLING_PATH = adminPath('/mobile/billing'); +export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports'); export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads'); export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard'); export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome'); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 6e71c29..347934d 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2623,5 +2623,52 @@ "send": "Benachrichtigung senden", "validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu." } + }, + "dataExports": { + "title": "Datenexporte", + "request": { + "title": "Exportanfrage", + "hint": "Exportiere Mandantendaten oder ein einzelnes Event-Archiv." + }, + "fields": { + "scope": "Umfang", + "event": "Veranstaltung", + "eventPlaceholder": "Event auswählen", + "includeMedia": "Originaldateien einschließen", + "includeMediaHint": "Größeres ZIP; nur bei Bedarf." + }, + "scopes": { + "tenant": "Mandantenexport", + "event": "Event-Export" + }, + "history": { + "title": "Letzte Exporte", + "hint": "Die letzten 10 Exporte für Mandant und Events.", + "empty": "Noch keine Exporte." + }, + "status": { + "pending": "Ausstehend", + "processing": "In Arbeit", + "ready": "Bereit", + "failed": "Fehlgeschlagen" + }, + "badges": { + "includesMedia": "Originaldateien" + }, + "actions": { + "refresh": "Aktualisieren", + "request": "Export anfordern", + "requesting": "Wird angefordert...", + "requested": "Export wird vorbereitet.", + "download": "Herunterladen", + "downloaded": "Download gestartet." + }, + "errors": { + "load": "Exporte konnten nicht geladen werden.", + "request": "Export konnte nicht gestartet werden.", + "eventRequired": "Bitte zuerst ein Event auswählen.", + "failed": "Export fehlgeschlagen.", + "download": "Download fehlgeschlagen." + } } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 0b9d661..d13d4f9 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2627,5 +2627,52 @@ "send": "Send notification", "validation": "Add a title, message, and target guest when needed." } + }, + "dataExports": { + "title": "Data exports", + "request": { + "title": "Export request", + "hint": "Export tenant data or a specific event archive." + }, + "fields": { + "scope": "Scope", + "event": "Event", + "eventPlaceholder": "Choose event", + "includeMedia": "Include raw media", + "includeMediaHint": "Bigger ZIP; choose when needed." + }, + "scopes": { + "tenant": "Tenant export", + "event": "Event export" + }, + "history": { + "title": "Recent exports", + "hint": "Latest 10 exports for your tenant and events.", + "empty": "No exports yet." + }, + "status": { + "pending": "Pending", + "processing": "Processing", + "ready": "Ready", + "failed": "Failed" + }, + "badges": { + "includesMedia": "Raw media" + }, + "actions": { + "refresh": "Refresh", + "request": "Request export", + "requesting": "Requesting...", + "requested": "Export is being prepared.", + "download": "Download", + "downloaded": "Download started." + }, + "errors": { + "load": "Exports could not be loaded.", + "request": "Export could not be started.", + "eventRequired": "Select an event first.", + "failed": "Export failed.", + "download": "Download failed." + } } } diff --git a/resources/js/admin/mobile/DataExportsPage.tsx b/resources/js/admin/mobile/DataExportsPage.tsx new file mode 100644 index 0000000..1b23e4d --- /dev/null +++ b/resources/js/admin/mobile/DataExportsPage.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshCcw } from 'lucide-react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Switch } from '@tamagui/switch'; +import toast from 'react-hot-toast'; +import { MobileShell, HeaderActionButton } from './components/MobileShell'; +import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; +import { MobileSelect } from './components/FormControls'; +import { + DataExportSummary, + downloadTenantDataExport, + getEvents, + listTenantDataExports, + requestTenantDataExport, + TenantEvent, +} from '../api'; +import { getApiErrorMessage } from '../lib/apiError'; +import { adminPath } from '../constants'; +import { useBackNavigation } from './hooks/useBackNavigation'; +import { formatRelativeTime } from './lib/relativeTime'; +import { useAdminTheme } from './theme'; +import i18n from '../i18n'; +import { triggerDownloadFromBlob } from './invite-layout/export-utils'; + +const statusTone: Record = { + pending: 'warning', + processing: 'warning', + ready: 'success', + failed: 'muted', +}; + +function formatBytes(bytes: number | null): string { + if (!bytes || bytes <= 0) { + return '—'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / Math.pow(1024, index); + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatEventName(event: TenantEvent | DataExportSummary['event'] | null): string { + if (!event) { + return ''; + } + const name = event.name; + if (typeof name === 'string') { + return name; + } + if (name && typeof name === 'object') { + return (name as Record)[i18n.language] ?? name.de ?? name.en ?? event.slug ?? ''; + } + return event.slug ?? ''; +} + +export default function MobileDataExportsPage() { + const { t } = useTranslation('management'); + const { textStrong, text, muted, danger } = useAdminTheme(); + const [exports, setExports] = React.useState([]); + const [events, setEvents] = React.useState([]); + const [scope, setScope] = React.useState<'tenant' | 'event'>('tenant'); + const [eventId, setEventId] = React.useState(null); + const [includeMedia, setIncludeMedia] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [requesting, setRequesting] = React.useState(false); + const [error, setError] = React.useState(null); + const back = useBackNavigation(adminPath('/mobile/profile')); + + const load = React.useCallback(async () => { + setLoading(true); + try { + const [exportRows, eventRows] = await Promise.all([ + listTenantDataExports(), + getEvents({ force: true }), + ]); + setExports(exportRows); + setEvents(eventRows); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, t('dataExports.errors.load', 'Exports konnten nicht geladen werden.'))); + } finally { + setLoading(false); + } + }, [t]); + + React.useEffect(() => { + void load(); + }, [load]); + + const handleRequest = async () => { + if (requesting) { + return; + } + + if (scope === 'event' && !eventId) { + setError(t('dataExports.errors.eventRequired', 'Bitte wähle ein Event aus.')); + return; + } + + setRequesting(true); + try { + await requestTenantDataExport({ + scope, + eventId: scope === 'event' ? eventId ?? undefined : undefined, + includeMedia, + }); + toast.success(t('dataExports.actions.requested', 'Export wird vorbereitet.')); + await load(); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, t('dataExports.errors.request', 'Export konnte nicht gestartet werden.'))); + } finally { + setRequesting(false); + } + }; + + return ( + load()} ariaLabel={t('common.refresh', 'Refresh')}> + + + } + > + {error ? ( + + + {error} + + + + ) : null} + + + + {t('dataExports.request.title', 'Export request')} + + + {t('dataExports.request.hint', 'Export tenant data or a specific event archive.')} + + + + + {t('dataExports.fields.scope', 'Scope')} + + setScope(event.target.value as 'tenant' | 'event')} + compact + style={{ minWidth: 160 }} + > + + + + + {scope === 'event' ? ( + + + {t('dataExports.fields.event', 'Event')} + + setEventId(event.target.value ? Number(event.target.value) : null)} + compact + style={{ minWidth: 200 }} + > + + {events.map((event) => ( + + ))} + + + ) : null} + + + + {t('dataExports.fields.includeMedia', 'Include raw media')} + + + {t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')} + + + + + + + + + + + {t('dataExports.history.title', 'Recent exports')} + + + {t('dataExports.history.hint', 'Latest 10 exports for your tenant and events.')} + + {loading ? ( + + + + + ) : exports.length === 0 ? ( + + {t('dataExports.history.empty', 'No exports yet.')} + + ) : ( + + {exports.map((entry) => ( + + + + + {entry.scope === 'event' + ? t('dataExports.scopes.event', 'Event export') + : t('dataExports.scopes.tenant', 'Tenant export')} + + {entry.event ? ( + + {formatEventName(entry.event)} + + ) : null} + + + {t(`dataExports.status.${entry.status}`, entry.status)} + + + + + {formatRelativeTime(entry.created_at, { locale: i18n.language }) || '—'} + + + {formatBytes(entry.size_bytes)} + + + {entry.include_media ? ( + {t('dataExports.badges.includesMedia', 'Raw media')} + ) : null} + {entry.download_url ? ( + { + if (!entry.download_url) { + return; + } + try { + const blob = await downloadTenantDataExport(entry.download_url); + const filename = `fotospiel-data-export-${entry.id}.zip`; + triggerDownloadFromBlob(blob, filename); + toast.success(t('dataExports.actions.downloaded', 'Download started.')); + } catch (err) { + toast.error(getApiErrorMessage(err, t('dataExports.errors.download', 'Download failed.'))); + } + }} + /> + ) : entry.status === 'failed' ? ( + + {entry.error_message ?? t('dataExports.errors.failed', 'Export failed.')} + + ) : null} + + ))} + + )} + + + ); +} diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx index 224aa8d..5857bf9 100644 --- a/resources/js/admin/mobile/ProfilePage.tsx +++ b/resources/js/admin/mobile/ProfilePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react'; +import { LogOut, User, Settings, Shield, Globe, Moon, Download } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; @@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives'; import { MobileSelect } from './components/FormControls'; import { useAuth } from '../auth/context'; import { fetchTenantProfile } from '../api'; -import { adminPath } from '../constants'; +import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants'; import i18n from '../i18n'; import { useAppearance } from '@/hooks/use-appearance'; import { useBackNavigation } from './hooks/useBackNavigation'; @@ -131,6 +131,22 @@ export default function MobileProfilePage() { /> + + navigate(ADMIN_DATA_EXPORTS_PATH)}> + + {t('dataExports.title', 'Data exports')} + + } + iconAfter={} + /> + + import('./mobile/NotificationsP const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage')); +const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage')); const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); @@ -203,6 +204,7 @@ export const router = createBrowserRouter([ { path: 'mobile/profile', element: }, { path: 'mobile/billing', element: }, { path: 'mobile/settings', element: }, + { path: 'mobile/exports', element: }, { path: 'mobile/dashboard', element: }, { path: 'mobile/tasks', element: }, { path: 'mobile/uploads', element: }, diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 6ad4530..59912e8 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -639,6 +639,77 @@ return [ ], 'export_success' => 'Export abgeschlossen. :count Einträge exportiert.', ], + 'data_exports' => [ + 'navigation' => [ + 'label' => 'Datenexporte', + ], + 'sections' => [ + 'request' => 'Exportanfrage', + ], + 'fields' => [ + 'id' => '#', + 'scope' => 'Umfang', + 'tenant' => 'Mandant', + 'event' => 'Veranstaltung', + 'include_media' => 'Originaldateien einschließen', + 'status' => 'Status', + 'size' => 'Größe', + 'created_at' => 'Angefordert', + 'expires_at' => 'Läuft ab', + ], + 'help' => [ + 'include_media' => 'Originaldateien in das Export-Archiv aufnehmen.', + ], + 'scope' => [ + 'user' => 'Benutzer', + 'tenant' => 'Mandant', + 'event' => 'Veranstaltung', + ], + 'status' => [ + 'pending' => 'Ausstehend', + 'processing' => 'In Arbeit', + 'ready' => 'Bereit', + 'failed' => 'Fehlgeschlagen', + ], + 'actions' => [ + 'request' => 'Export anfordern', + 'download' => 'Herunterladen', + ], + ], + 'retention_overrides' => [ + 'navigation' => [ + 'label' => 'Retention-Overrides', + ], + 'sections' => [ + 'override' => 'Retention-Stopp', + 'status' => 'Status', + ], + 'fields' => [ + 'id' => '#', + 'scope' => 'Umfang', + 'tenant' => 'Mandant', + 'event' => 'Veranstaltung', + 'reason' => 'Grund', + 'note' => 'Notiz', + 'created_by' => 'Erstellt von', + 'created_at' => 'Erstellt', + 'released_by' => 'Freigegeben von', + 'released_at' => 'Freigegeben am', + 'status' => 'Status', + ], + 'scope' => [ + 'tenant' => 'Mandant', + 'event' => 'Veranstaltung', + ], + 'status' => [ + 'active' => 'Aktiv', + 'released' => 'Freigegeben', + ], + 'actions' => [ + 'request' => 'Stopp hinzufügen', + 'release' => 'Stopp aufheben', + ], + ], 'shell' => [ 'tenant_admin_title' => 'Tenant‑Admin', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 5158f53..2eb0123 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -625,6 +625,77 @@ return [ ], 'export_success' => 'Export ready. :count rows exported.', ], + 'data_exports' => [ + 'navigation' => [ + 'label' => 'Data exports', + ], + 'sections' => [ + 'request' => 'Export request', + ], + 'fields' => [ + 'id' => '#', + 'scope' => 'Scope', + 'tenant' => 'Tenant', + 'event' => 'Event', + 'include_media' => 'Include raw media', + 'status' => 'Status', + 'size' => 'Size', + 'created_at' => 'Requested', + 'expires_at' => 'Expires', + ], + 'help' => [ + 'include_media' => 'Include original media files in the export archive.', + ], + 'scope' => [ + 'user' => 'User', + 'tenant' => 'Tenant', + 'event' => 'Event', + ], + 'status' => [ + 'pending' => 'Pending', + 'processing' => 'Processing', + 'ready' => 'Ready', + 'failed' => 'Failed', + ], + 'actions' => [ + 'request' => 'Request export', + 'download' => 'Download', + ], + ], + 'retention_overrides' => [ + 'navigation' => [ + 'label' => 'Retention overrides', + ], + 'sections' => [ + 'override' => 'Retention hold', + 'status' => 'Status', + ], + 'fields' => [ + 'id' => '#', + 'scope' => 'Scope', + 'tenant' => 'Tenant', + 'event' => 'Event', + 'reason' => 'Reason', + 'note' => 'Note', + 'created_by' => 'Created by', + 'created_at' => 'Created', + 'released_by' => 'Released by', + 'released_at' => 'Released at', + 'status' => 'Status', + ], + 'scope' => [ + 'tenant' => 'Tenant', + 'event' => 'Event', + ], + 'status' => [ + 'active' => 'Active', + 'released' => 'Released', + ], + 'actions' => [ + 'request' => 'Add hold', + 'release' => 'Release hold', + ], + ], 'shell' => [ 'tenant_admin_title' => 'Tenant Admin', diff --git a/routes/api.php b/routes/api.php index cec40f9..8612574 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\SparkboothUploadController; use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController; use App\Http\Controllers\Api\Tenant\DashboardController; +use App\Http\Controllers\Api\Tenant\DataExportController; use App\Http\Controllers\Api\Tenant\EmotionController; use App\Http\Controllers\Api\Tenant\EventAddonCatalogController; use App\Http\Controllers\Api\Tenant\EventAddonController; @@ -293,6 +294,15 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('tenant.settings.notifications.update'); }); + Route::middleware('tenant.admin')->group(function () { + Route::get('exports', [DataExportController::class, 'index']) + ->name('tenant.exports.index'); + Route::post('exports', [DataExportController::class, 'store']) + ->name('tenant.exports.store'); + Route::get('exports/{export}/download', [DataExportController::class, 'download']) + ->name('tenant.exports.download'); + }); + Route::get('notifications/logs', [NotificationLogController::class, 'index']) ->middleware('tenant.admin') ->name('tenant.notifications.logs.index'); diff --git a/routes/web.php b/routes/web.php index a0a8d10..a8f5e80 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Http\Controllers\PaddleWebhookController; use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileDataExportController; +use App\Http\Controllers\SuperAdmin\DataExportController as SuperAdminDataExportController; use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminGoogleController; @@ -314,6 +315,10 @@ Route::middleware('auth')->group(function () { Route::delete('/profile/account', [ProfileAccountController::class, 'destroy']) ->name('profile.account.destroy'); }); +Route::middleware('auth:super_admin')->group(function () { + Route::get('/super-admin/data-exports/{export}/download', [SuperAdminDataExportController::class, 'download']) + ->name('superadmin.data-exports.download'); +}); Route::prefix('event-admin')->group(function () { $renderAdmin = fn () => view('admin'); $authAdmin = TenantAdminAuthController::class; diff --git a/tests/Feature/Api/Tenant/DataExportApiTest.php b/tests/Feature/Api/Tenant/DataExportApiTest.php new file mode 100644 index 0000000..961e570 --- /dev/null +++ b/tests/Feature/Api/Tenant/DataExportApiTest.php @@ -0,0 +1,116 @@ +create(['tenant_id' => $this->tenant->id]); + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/exports', [ + 'scope' => 'event', + 'event_id' => $event->id, + 'include_media' => true, + ]); + + $response->assertStatus(202) + ->assertJsonPath('data.scope', 'event') + ->assertJsonPath('data.status', DataExport::STATUS_PENDING); + + $this->assertDatabaseHas('data_exports', [ + 'tenant_id' => $this->tenant->id, + 'event_id' => $event->id, + 'scope' => DataExportScope::EVENT->value, + 'include_media' => true, + ]); + + Queue::assertPushed(GenerateDataExport::class); + } + + public function test_exports_index_filters_to_tenant_scopes(): void + { + $event = Event::factory()->create(['tenant_id' => $this->tenant->id]); + $otherTenant = Tenant::factory()->create(); + $otherEvent = Event::factory()->create(['tenant_id' => $otherTenant->id]); + + DataExport::query()->create([ + 'user_id' => $this->tenantUser->id, + 'tenant_id' => $this->tenant->id, + 'event_id' => $event->id, + 'scope' => DataExportScope::EVENT->value, + 'status' => DataExport::STATUS_READY, + 'include_media' => false, + ]); + + DataExport::query()->create([ + 'user_id' => $this->tenantUser->id, + 'tenant_id' => $this->tenant->id, + 'scope' => DataExportScope::USER->value, + 'status' => DataExport::STATUS_READY, + 'include_media' => false, + ]); + + DataExport::query()->create([ + 'user_id' => $this->tenantUser->id, + 'tenant_id' => $otherTenant->id, + 'event_id' => $otherEvent->id, + 'scope' => DataExportScope::EVENT->value, + 'status' => DataExport::STATUS_READY, + 'include_media' => true, + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/exports'); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertSame($event->id, $response->json('data.0.event.id')); + } + + public function test_event_export_rejects_foreign_event(): void + { + $otherTenant = Tenant::factory()->create(); + $otherEvent = Event::factory()->create(['tenant_id' => $otherTenant->id]); + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/exports', [ + 'scope' => 'event', + 'event_id' => $otherEvent->id, + ]); + + $response->assertStatus(404); + } + + public function test_ready_export_can_be_downloaded(): void + { + Storage::fake('local'); + + Storage::disk('local')->put('exports/tenant-export.zip', 'demo-content'); + + $export = DataExport::query()->create([ + 'user_id' => $this->tenantUser->id, + 'tenant_id' => $this->tenant->id, + 'scope' => DataExportScope::TENANT->value, + 'status' => DataExport::STATUS_READY, + 'include_media' => false, + 'path' => 'exports/tenant-export.zip', + 'size_bytes' => 123, + 'expires_at' => now()->addDay(), + ]); + + $response = $this->authenticatedRequest('GET', route('api.v1.tenant.exports.download', $export)); + + $response->assertOk(); + $response->assertHeader('content-disposition'); + } +} diff --git a/tests/Feature/Console/DispatchStorageArchiveCommandTest.php b/tests/Feature/Console/DispatchStorageArchiveCommandTest.php index b264ef6..0bc05ac 100644 --- a/tests/Feature/Console/DispatchStorageArchiveCommandTest.php +++ b/tests/Feature/Console/DispatchStorageArchiveCommandTest.php @@ -2,12 +2,14 @@ namespace Tests\Feature\Console; +use App\Enums\RetentionOverrideScope; use App\Jobs\ArchiveEventMediaAssets; use App\Models\Event; use App\Models\EventMediaAsset; use App\Models\EventPackage; use App\Models\MediaStorageTarget; use App\Models\Package; +use App\Models\RetentionOverride; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -94,4 +96,55 @@ class DispatchStorageArchiveCommandTest extends TestCase Queue::assertNothingPushed(); } + + public function test_skips_events_with_retention_override(): void + { + $target = MediaStorageTarget::create([ + 'key' => 'local-hot', + 'name' => 'Local Hot', + 'driver' => 'local', + 'config' => ['monitor_path' => storage_path('app')], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 100, + ]); + + $event = Event::factory()->create(['status' => 'published']); + $package = Package::factory()->create(['gallery_days' => 1]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 0, + 'purchased_at' => now()->subDays(10), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->subDays(5), + ]); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'variant' => 'original', + 'disk' => 'local-hot', + 'path' => 'events/'.$event->id.'/photo.jpg', + 'size_bytes' => 1024, + 'status' => 'hot', + ]); + + RetentionOverride::factory()->create([ + 'scope' => RetentionOverrideScope::EVENT, + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + ]); + + Queue::fake(); + + $this->artisan('storage:archive-pending') + ->expectsOutput('Dispatched 0 archive job(s).') + ->assertExitCode(0); + + Queue::assertNothingPushed(); + } } diff --git a/tests/Feature/TenantRetentionCommandTest.php b/tests/Feature/TenantRetentionCommandTest.php index 17105fe..1989af0 100644 --- a/tests/Feature/TenantRetentionCommandTest.php +++ b/tests/Feature/TenantRetentionCommandTest.php @@ -2,8 +2,10 @@ namespace Tests\Feature; +use App\Enums\RetentionOverrideScope; use App\Jobs\AnonymizeAccount; use App\Models\Package; +use App\Models\RetentionOverride; use App\Models\Tenant; use App\Models\TenantPackage; use App\Models\User; @@ -33,6 +35,27 @@ class TenantRetentionCommandTest extends TestCase }); } + public function test_retention_override_skips_tenant_deletion(): void + { + Queue::fake(); + + $tenant = Tenant::factory()->create([ + 'last_activity_at' => now()->subMonths(25), + ]); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $tenant->user()->associate($user)->save(); + + RetentionOverride::factory()->create([ + 'scope' => RetentionOverrideScope::TENANT, + 'tenant_id' => $tenant->id, + 'event_id' => null, + ]); + + $this->artisan('tenants:retention-scan')->assertExitCode(0); + + Queue::assertNothingPushed(); + } + public function test_warning_is_sent_one_month_before(): void { Queue::fake();