Implement compliance exports and retention overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 20:13:45 +01:00
parent 5fd546c428
commit eed7699549
45 changed files with 2319 additions and 40 deletions

View File

@@ -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-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-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-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-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-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)"} {"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)"}

View File

@@ -1 +1 @@
fotospiel-app-bit fotospiel-app-sbs

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks; use App\Console\Concerns\InteractsWithCacheLocks;
use App\Jobs\ArchiveEventMediaAssets; use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event; use App\Models\Event;
use App\Services\Compliance\RetentionOverrideService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock; use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100)); $maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
$eventId = $this->option('event'); $eventId = $this->option('event');
$dispatched = 0; $dispatched = 0;
$overrides = app(RetentionOverrideService::class);
try { try {
$query = Event::query() $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) { foreach ($events as $event) {
if ($dispatched >= $maxDispatch) { if ($dispatched >= $maxDispatch) {
return false; return false;
} }
if ($overrides->eventOnHold($event)) {
continue;
}
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl); $eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
if ($eventLock === false) { if ($eventLock === false) {
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [ Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Jobs\AnonymizeAccount; use App\Jobs\AnonymizeAccount;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning; use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Compliance\RetentionOverrideService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command
->withMax('purchases as last_purchase_activity', 'purchased_at') ->withMax('purchases as last_purchase_activity', 'purchased_at')
->withMax('photos as last_photo_activity', 'created_at') ->withMax('photos as last_photo_activity', 'created_at')
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) { ->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
$overrides = app(RetentionOverrideService::class);
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
if ($overrides->tenantOnHold($tenant)) {
continue;
}
$lastActivity = $this->determineLastActivity($tenant); $lastActivity = $this->determineLastActivity($tenant);
if (! $lastActivity) { if (! $lastActivity) {

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum DataExportScope: string
{
case USER = 'user';
case TENANT = 'tenant';
case EVENT = 'event';
public function label(): string
{
return match ($this) {
self::USER => __('User'),
self::TENANT => __('Tenant'),
self::EVENT => __('Event'),
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum RetentionOverrideScope: string
{
case TENANT = 'tenant';
case EVENT = 'event';
public function label(): string
{
return match ($this) {
self::TENANT => __('Tenant'),
self::EVENT => __('Event'),
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\DataExportResource\Pages\CreateDataExport;
use App\Filament\Resources\DataExportResource\Pages\ListDataExports;
use App\Filament\Resources\DataExportResource\Schemas\DataExportForm;
use App\Filament\Resources\DataExportResource\Tables\DataExportTable;
use App\Models\DataExport;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class DataExportResource extends Resource
{
protected static ?string $model = DataExport::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowDownTray;
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 50;
public static function form(Schema $schema): Schema
{
return DataExportForm::configure($schema);
}
public static function table(Table $table): Table
{
return DataExportTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.data_exports.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\DataExportResource\Pages;
use App\Enums\DataExportScope;
use App\Filament\Resources\DataExportResource;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use Filament\Facades\Filament;
class CreateDataExport extends AuditedCreateRecord
{
protected static string $resource = DataExportResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = Filament::auth()->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);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\DataExportResource\Pages;
use App\Filament\Resources\DataExportResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListDataExports extends ListRecords
{
protected static string $resource = DataExportResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('admin.data_exports.actions.request')),
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\DataExportResource\Schemas;
use App\Enums\DataExportScope;
use App\Models\Event;
use App\Models\Tenant;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Get;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class DataExportForm
{
public static function configure(Schema $schema): Schema
{
return $schema->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),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Resources\DataExportResource\Tables;
use App\Models\DataExport;
use Filament\Actions\Action;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Number;
class DataExportTable
{
public static function configure(Table $table): Table
{
return $table
->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([]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\RetentionOverrideResource\Pages\CreateRetentionOverride;
use App\Filament\Resources\RetentionOverrideResource\Pages\EditRetentionOverride;
use App\Filament\Resources\RetentionOverrideResource\Pages\ListRetentionOverrides;
use App\Filament\Resources\RetentionOverrideResource\Schemas\RetentionOverrideForm;
use App\Filament\Resources\RetentionOverrideResource\Tables\RetentionOverrideTable;
use App\Models\RetentionOverride;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class RetentionOverrideResource extends Resource
{
protected static ?string $model = RetentionOverride::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldExclamation;
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 55;
public static function form(Schema $schema): Schema
{
return RetentionOverrideForm::configure($schema);
}
public static function table(Table $table): Table
{
return RetentionOverrideTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.retention_overrides.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->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;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Enums\RetentionOverrideScope;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Filament\Resources\RetentionOverrideResource;
use Filament\Facades\Filament;
class CreateRetentionOverride extends AuditedCreateRecord
{
protected static string $resource = RetentionOverrideResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by_id'] = Filament::auth()->id();
$data['released_at'] = null;
$data['released_by_id'] = null;
if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) {
$data['event_id'] = null;
}
return $data;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\RetentionOverrideResource;
use App\Models\RetentionOverride;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Facades\Filament;
class EditRetentionOverride extends AuditedEditRecord
{
protected static string $resource = RetentionOverrideResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('release')
->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
);
}),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Filament\Resources\RetentionOverrideResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListRetentionOverrides extends ListRecords
{
protected static string $resource = RetentionOverrideResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('admin.retention_overrides.actions.request')),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Schemas;
use App\Enums\RetentionOverrideScope;
use App\Models\Event;
use App\Models\RetentionOverride;
use App\Models\Tenant;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Schemas\Schema;
class RetentionOverrideForm
{
public static function configure(Schema $schema): Schema
{
return $schema->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),
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Tables;
use App\Models\RetentionOverride;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class RetentionOverrideTable
{
public static function configure(Table $table): Table
{
return $table
->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([]);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\DataExportStoreRequest;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Models\Event;
use App\Models\Tenant;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DataExportController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenant = $this->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.');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\DataExportScope;
use App\Models\DataExport; use App\Models\DataExport;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -45,6 +46,7 @@ class ProfileController extends Controller
->all(); ->all();
$recentExports = $user->dataExports() $recentExports = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->latest() ->latest()
->limit(5) ->limit(5)
->get() ->get()
@@ -61,6 +63,7 @@ class ProfileController extends Controller
]); ]);
$pendingExport = $user->dataExports() $pendingExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->whereIn('status', [ ->whereIn('status', [
DataExport::STATUS_PENDING, DataExport::STATUS_PENDING,
DataExport::STATUS_PROCESSING, DataExport::STATUS_PROCESSING,
@@ -68,6 +71,7 @@ class ProfileController extends Controller
->exists(); ->exists();
$lastReadyExport = $user->dataExports() $lastReadyExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->where('status', DataExport::STATUS_READY) ->where('status', DataExport::STATUS_READY)
->latest('created_at') ->latest('created_at')
->first(); ->first();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\DataExportScope;
use App\Jobs\GenerateDataExport; use App\Jobs\GenerateDataExport;
use App\Models\DataExport; use App\Models\DataExport;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -17,6 +18,7 @@ class ProfileDataExportController extends Controller
abort_unless($user, 403); abort_unless($user, 403);
$hasRecentExport = $user->dataExports() $hasRecentExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
->exists(); ->exists();
@@ -25,6 +27,7 @@ class ProfileDataExportController extends Controller
} }
$recentReadyExport = $user->dataExports() $recentReadyExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->where('status', DataExport::STATUS_READY) ->where('status', DataExport::STATUS_READY)
->where('created_at', '>=', now()->subDay()) ->where('created_at', '>=', now()->subDay())
->exists(); ->exists();
@@ -36,6 +39,8 @@ class ProfileDataExportController extends Controller
$export = $user->dataExports()->create([ $export = $user->dataExports()->create([
'tenant_id' => $user->tenant_id, 'tenant_id' => $user->tenant_id,
'status' => DataExport::STATUS_PENDING, 'status' => DataExport::STATUS_PENDING,
'scope' => DataExportScope::USER->value,
'include_media' => false,
]); ]);
GenerateDataExport::dispatch($export->id); GenerateDataExport::dispatch($export->id);

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\DataExport;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DataExportController extends Controller
{
public function download(DataExport $export): StreamedResponse
{
if (! $export->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',
]
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DataExportStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
];
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\DataExportScope;
use App\Models\DataExport; use App\Models\DataExport;
use App\Models\Event; use App\Models\Event;
use App\Models\EventMediaAsset;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -31,7 +33,7 @@ class GenerateDataExport implements ShouldQueue
public function handle(): void public function handle(): void
{ {
$export = DataExport::with(['user', 'tenant'])->find($this->exportId); $export = DataExport::with(['user', 'tenant', 'event'])->find($this->exportId);
if (! $export) { if (! $export) {
return; return;
@@ -46,10 +48,41 @@ class GenerateDataExport implements ShouldQueue
return; 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]); $export->update(['status' => DataExport::STATUS_PROCESSING, 'error_message' => null]);
try { try {
$payload = $this->buildPayload($export->user, $export->tenant); $payload = $this->buildPayload($export);
$zipPath = $this->writeArchive($export, $payload); $zipPath = $this->writeArchive($export, $payload);
$export->update([ $export->update([
'status' => DataExport::STATUS_READY, 'status' => DataExport::STATUS_READY,
@@ -70,10 +103,16 @@ class GenerateDataExport implements ShouldQueue
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
protected function buildPayload(User $user, ?Tenant $tenant): array protected function buildPayload(DataExport $export): array
{ {
$user = $export->user;
$tenant = $export->tenant;
$event = $export->event;
$profile = [ $profile = [
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
'scope' => $export->scope?->value ?? DataExportScope::USER->value,
'include_media' => (bool) $export->include_media,
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
@@ -98,18 +137,34 @@ class GenerateDataExport implements ShouldQueue
]; ];
} }
$events = $tenant $payload = [
? $this->collectEvents($tenant) 'profile' => $profile,
: []; ];
$invoices = $tenant
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) ? $this->collectInvoices($tenant)
: []; : [];
return [ return $payload;
'profile' => $profile,
'events' => $events,
'invoices' => $invoices,
];
} }
/** /**
@@ -135,25 +190,66 @@ class GenerateDataExport implements ShouldQueue
->pluck(DB::raw('COUNT(*)'), 'photos.event_id'); ->pluck(DB::raw('COUNT(*)'), 'photos.event_id');
return $events return $events
->map(function (Event $event) use ($likeCounts): array { ->map(fn (Event $event): array => $this->buildEventSummary(
$likes = (int) ($likeCounts[$event->id] ?? 0); $event,
(int) ($likeCounts[$event->id] ?? 0)
))
->all();
}
return [ /**
'id' => $event->id, * @return array<string, mixed>
'slug' => $event->slug, */
'status' => $event->status, protected function buildEventSummary(Event $event, ?int $likes = null): array
'name' => $event->name, {
'location' => $event->location, $likes = $likes ?? $this->countEventLikes($event);
'date' => optional($event->date)->toIso8601String(),
'photos_total' => (int) ($event->photos_total ?? 0), return [
'featured_photos_total' => (int) ($event->featured_photos_total ?? 0), 'id' => $event->id,
'join_tokens_total' => (int) ($event->join_tokens_count ?? 0), 'slug' => $event->slug,
'members_total' => (int) ($event->members_count ?? 0), 'status' => $event->status,
'likes_total' => $likes, 'name' => $event->name,
'created_at' => optional($event->created_at)->toIso8601String(), 'location' => $event->location,
'updated_at' => optional($event->updated_at)->toIso8601String(), '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<int, array<string, mixed>>
*/
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(); ->all();
} }
@@ -189,7 +285,11 @@ class GenerateDataExport implements ShouldQueue
*/ */
protected function writeArchive(DataExport $export, array $payload): string 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); Storage::disk('local')->makeDirectory($directory);
$filename = sprintf('data-export-%s.zip', Str::uuid()); $filename = sprintf('data-export-%s.zip', Str::uuid());
$path = $directory.'/'.$filename; $path = $directory.'/'.$filename;
@@ -201,9 +301,39 @@ class GenerateDataExport implements ShouldQueue
throw new \RuntimeException('Unable to create export archive.'); throw new \RuntimeException('Unable to create export archive.');
} }
$zip->addFromString('profile.json', json_encode($payload['profile'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $zip->addFromString('export.json', json_encode([
$zip->addFromString('events.json', json_encode($payload['events'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); 'id' => $export->id,
$zip->addFromString('invoices.json', json_encode($payload['invoices'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); '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(); $locale = $export->user?->preferred_locale ?? app()->getLocale();
$readme = implode("\n", [ $readme = implode("\n", [
@@ -221,4 +351,119 @@ class GenerateDataExport implements ShouldQueue
return $path; return $path;
} }
/**
* @return array<int, Event>
*/
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;
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\DataExportScope;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -21,7 +22,10 @@ class DataExport extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'tenant_id', 'tenant_id',
'event_id',
'status', 'status',
'scope',
'include_media',
'path', 'path',
'size_bytes', 'size_bytes',
'expires_at', 'expires_at',
@@ -30,6 +34,8 @@ class DataExport extends Model
protected $casts = [ protected $casts = [
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'include_media' => 'boolean',
'scope' => DataExportScope::class,
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@@ -42,6 +48,11 @@ class DataExport extends Model
return $this->belongsTo(Tenant::class); return $this->belongsTo(Tenant::class);
} }
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function isReady(): bool public function isReady(): bool
{ {
return $this->status === self::STATUS_READY; return $this->status === self::STATUS_READY;

View File

@@ -104,6 +104,11 @@ class Event extends Model
return $this->hasMany(EventPackage::class); return $this->hasMany(EventPackage::class);
} }
public function retentionOverrides(): HasMany
{
return $this->hasMany(RetentionOverride::class);
}
public function joinTokens(): HasMany public function joinTokens(): HasMany
{ {
return $this->hasMany(EventJoinToken::class); return $this->hasMany(EventJoinToken::class);

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use App\Enums\RetentionOverrideScope;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RetentionOverride extends Model
{
/** @use HasFactory<\Database\Factories\RetentionOverrideFactory> */
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;
}
}

View File

@@ -86,6 +86,11 @@ class Tenant extends Model
return $this->hasMany(TenantAnnouncementDelivery::class); return $this->hasMany(TenantAnnouncementDelivery::class);
} }
public function retentionOverrides(): HasMany
{
return $this->hasMany(RetentionOverride::class);
}
public function packages(): BelongsToMany public function packages(): BelongsToMany
{ {
return $this->belongsToMany(Package::class, 'tenant_packages') return $this->belongsToMany(Package::class, 'tenant_packages')

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\Compliance;
use App\Enums\RetentionOverrideScope;
use App\Models\Event;
use App\Models\RetentionOverride;
use App\Models\Tenant;
class RetentionOverrideService
{
public function tenantOnHold(Tenant $tenant): bool
{
return RetentionOverride::query()
->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();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Enums\RetentionOverrideScope;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\RetentionOverride>
*/
class RetentionOverrideFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('retention_overrides', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('data_exports', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -430,6 +430,23 @@ export type NotificationLogEntry = {
is_read?: boolean; 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<string, string>;
} | null;
};
export type PaddleTransactionSummary = { export type PaddleTransactionSummary = {
id: string | null; id: string | null;
status: 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<string, JsonValue>;
const event = row.event;
const eventRecord = event && typeof event === 'object' && !Array.isArray(event)
? (event as Record<string, JsonValue>)
: 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?: { export async function listNotificationLogs(options?: {
page?: number; page?: number;
perPage?: number; perPage?: number;
@@ -2178,6 +2228,51 @@ export async function markNotificationLogs(ids: number[], status: 'read' | 'dism
}); });
} }
export async function listTenantDataExports(): Promise<DataExportSummary[]> {
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<DataExportSummary | null> {
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<Blob> {
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<{ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
data: PaddleTransactionSummary[]; data: PaddleTransactionSummary[];
nextCursor: string | null; nextCursor: string | null;

View File

@@ -13,6 +13,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing'); 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_PHOTOS_PATH = adminPath('/mobile/uploads');
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard'); export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome'); export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome');

View File

@@ -2623,5 +2623,52 @@
"send": "Benachrichtigung senden", "send": "Benachrichtigung senden",
"validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu." "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."
}
} }
} }

View File

@@ -2627,5 +2627,52 @@
"send": "Send notification", "send": "Send notification",
"validation": "Add a title, message, and target guest when needed." "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."
}
} }
} }

View File

@@ -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<DataExportSummary['status'], 'success' | 'warning' | 'muted'> = {
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<string, string>)[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<DataExportSummary[]>([]);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [scope, setScope] = React.useState<'tenant' | 'event'>('tenant');
const [eventId, setEventId] = React.useState<number | null>(null);
const [includeMedia, setIncludeMedia] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [requesting, setRequesting] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<MobileShell
activeTab="profile"
title={t('dataExports.title', 'Data exports')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
<CTAButton label={t('dataExports.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
<MobileCard space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.request.title', 'Export request')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.request.hint', 'Export tenant data or a specific event archive.')}
</Text>
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.scope', 'Scope')}
</Text>
<MobileSelect
value={scope}
onChange={(event) => setScope(event.target.value as 'tenant' | 'event')}
compact
style={{ minWidth: 160 }}
>
<option value="tenant">{t('dataExports.scopes.tenant', 'Tenant')}</option>
<option value="event">{t('dataExports.scopes.event', 'Event')}</option>
</MobileSelect>
</XStack>
{scope === 'event' ? (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.event', 'Event')}
</Text>
<MobileSelect
value={eventId ? String(eventId) : ''}
onChange={(event) => setEventId(event.target.value ? Number(event.target.value) : null)}
compact
style={{ minWidth: 200 }}
>
<option value="">{t('dataExports.fields.eventPlaceholder', 'Choose event')}</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{formatEventName(event) || event.slug}
</option>
))}
</MobileSelect>
</XStack>
) : null}
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" color={text}>
{t('dataExports.fields.includeMedia', 'Include raw media')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.fields.includeMediaHint', 'Bigger ZIP; choose when needed.')}
</Text>
</YStack>
<Switch checked={includeMedia} onCheckedChange={setIncludeMedia} />
</XStack>
</YStack>
<CTAButton
label={requesting ? t('dataExports.actions.requesting', 'Requesting...') : t('dataExports.actions.request', 'Request export')}
onPress={handleRequest}
disabled={requesting}
/>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('dataExports.history.title', 'Recent exports')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('dataExports.history.hint', 'Latest 10 exports for your tenant and events.')}
</Text>
{loading ? (
<YStack space="$2">
<SkeletonCard height={72} />
<SkeletonCard height={72} />
</YStack>
) : exports.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('dataExports.history.empty', 'No exports yet.')}
</Text>
) : (
<YStack space="$2">
{exports.map((entry) => (
<MobileCard key={entry.id} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{entry.scope === 'event'
? t('dataExports.scopes.event', 'Event export')
: t('dataExports.scopes.tenant', 'Tenant export')}
</Text>
{entry.event ? (
<Text fontSize="$xs" color={muted}>
{formatEventName(entry.event)}
</Text>
) : null}
</YStack>
<PillBadge tone={statusTone[entry.status]}>
{t(`dataExports.status.${entry.status}`, entry.status)}
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{formatRelativeTime(entry.created_at, { locale: i18n.language }) || '—'}
</Text>
<Text fontSize="$xs" color={muted}>
{formatBytes(entry.size_bytes)}
</Text>
</XStack>
{entry.include_media ? (
<PillBadge tone="muted">{t('dataExports.badges.includesMedia', 'Raw media')}</PillBadge>
) : null}
{entry.download_url ? (
<CTAButton
label={t('dataExports.actions.download', 'Download')}
onPress={async () => {
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' ? (
<Text fontSize="$xs" color={danger}>
{entry.error_message ?? t('dataExports.errors.failed', 'Export failed.')}
</Text>
) : null}
</MobileCard>
))}
</YStack>
)}
</MobileCard>
</MobileShell>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
@@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls'; import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api'; import { fetchTenantProfile } from '../api';
import { adminPath } from '../constants'; import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants';
import i18n from '../i18n'; import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
@@ -131,6 +131,22 @@ export default function MobileProfilePage() {
/> />
</Pressable> </Pressable>
</YGroup.Item> </YGroup.Item>
<YGroup.Item bordered>
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('dataExports.title', 'Data exports')}
</Text>
}
iconAfter={<Download size={18} color={subtle} />}
/>
</Pressable>
</YGroup.Item>
<YGroup.Item bordered> <YGroup.Item bordered>
<ListItem <ListItem
paddingVertical="$2" paddingVertical="$2"

View File

@@ -36,6 +36,7 @@ const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsP
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage')); const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage')); const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
@@ -203,6 +204,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> }, { path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> }, { path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> }, { path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
{ path: 'mobile/exports', element: <RequireAdminAccess><MobileDataExportsPage /></RequireAdminAccess> },
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> }, { path: 'mobile/dashboard', element: <MobileDashboardPage /> },
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> }, { path: 'mobile/tasks', element: <MobileTasksTabPage /> },
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> }, { path: 'mobile/uploads', element: <MobileUploadsTabPage /> },

View File

@@ -639,6 +639,77 @@ return [
], ],
'export_success' => 'Export abgeschlossen. :count Einträge exportiert.', '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' => [ 'shell' => [
'tenant_admin_title' => 'TenantAdmin', 'tenant_admin_title' => 'TenantAdmin',

View File

@@ -625,6 +625,77 @@ return [
], ],
'export_success' => 'Export ready. :count rows exported.', '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' => [ 'shell' => [
'tenant_admin_title' => 'Tenant Admin', 'tenant_admin_title' => 'Tenant Admin',

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\SparkboothUploadController; use App\Http\Controllers\Api\SparkboothUploadController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController; use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\DashboardController; 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\EmotionController;
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController; use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
use App\Http\Controllers\Api\Tenant\EventAddonController; use App\Http\Controllers\Api\Tenant\EventAddonController;
@@ -293,6 +294,15 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->name('tenant.settings.notifications.update'); ->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']) Route::get('notifications/logs', [NotificationLogController::class, 'index'])
->middleware('tenant.admin') ->middleware('tenant.admin')
->name('tenant.notifications.logs.index'); ->name('tenant.notifications.logs.index');

View File

@@ -14,6 +14,7 @@ use App\Http\Controllers\PaddleWebhookController;
use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileAccountController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProfileDataExportController; use App\Http\Controllers\ProfileDataExportController;
use App\Http\Controllers\SuperAdmin\DataExportController as SuperAdminDataExportController;
use App\Http\Controllers\Tenant\EventPhotoArchiveController; use App\Http\Controllers\Tenant\EventPhotoArchiveController;
use App\Http\Controllers\TenantAdminAuthController; use App\Http\Controllers\TenantAdminAuthController;
use App\Http\Controllers\TenantAdminGoogleController; use App\Http\Controllers\TenantAdminGoogleController;
@@ -314,6 +315,10 @@ Route::middleware('auth')->group(function () {
Route::delete('/profile/account', [ProfileAccountController::class, 'destroy']) Route::delete('/profile/account', [ProfileAccountController::class, 'destroy'])
->name('profile.account.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 () { Route::prefix('event-admin')->group(function () {
$renderAdmin = fn () => view('admin'); $renderAdmin = fn () => view('admin');
$authAdmin = TenantAdminAuthController::class; $authAdmin = TenantAdminAuthController::class;

View File

@@ -0,0 +1,116 @@
<?php
namespace Tests\Feature\Api\Tenant;
use App\Enums\DataExportScope;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Models\Event;
use App\Models\Tenant;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Feature\Tenant\TenantTestCase;
class DataExportApiTest extends TenantTestCase
{
public function test_tenant_can_request_event_export(): void
{
Queue::fake();
$event = Event::factory()->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');
}
}

View File

@@ -2,12 +2,14 @@
namespace Tests\Feature\Console; namespace Tests\Feature\Console;
use App\Enums\RetentionOverrideScope;
use App\Jobs\ArchiveEventMediaAssets; use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event; use App\Models\Event;
use App\Models\EventMediaAsset; use App\Models\EventMediaAsset;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\MediaStorageTarget; use App\Models\MediaStorageTarget;
use App\Models\Package; use App\Models\Package;
use App\Models\RetentionOverride;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Tests\TestCase; use Tests\TestCase;
@@ -94,4 +96,55 @@ class DispatchStorageArchiveCommandTest extends TestCase
Queue::assertNothingPushed(); 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();
}
} }

View File

@@ -2,8 +2,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\RetentionOverrideScope;
use App\Jobs\AnonymizeAccount; use App\Jobs\AnonymizeAccount;
use App\Models\Package; use App\Models\Package;
use App\Models\RetentionOverride;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Models\User; 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 public function test_warning_is_sent_one_month_before(): void
{ {
Queue::fake(); Queue::fake();