Implement compliance exports and retention overrides
This commit is contained in:
@@ -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)"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-bit
|
||||
fotospiel-app-sbs
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
19
app/Enums/DataExportScope.php
Normal file
19
app/Enums/DataExportScope.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
app/Enums/RetentionOverrideScope.php
Normal file
17
app/Enums/RetentionOverrideScope.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/Filament/Resources/DataExportResource.php
Normal file
78
app/Filament/Resources/DataExportResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
75
app/Filament/Resources/RetentionOverrideResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Api/Tenant/DataExportController.php
Normal file
190
app/Http/Controllers/Api/Tenant/DataExportController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
app/Http/Controllers/SuperAdmin/DataExportController.php
Normal file
32
app/Http/Controllers/SuperAdmin/DataExportController.php
Normal 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',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/Tenant/DataExportStoreRequest.php
Normal file
41
app/Http/Requests/Tenant/DataExportStoreRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<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 = [
|
||||
'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<string, mixed>
|
||||
*/
|
||||
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<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();
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
55
app/Models/RetentionOverride.php
Normal file
55
app/Models/RetentionOverride.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
36
app/Services/Compliance/RetentionOverrideService.php
Normal file
36
app/Services/Compliance/RetentionOverrideService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
33
database/factories/RetentionOverrideFactory.php
Normal file
33
database/factories/RetentionOverrideFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
} | 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<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?: {
|
||||
page?: 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<{
|
||||
data: PaddleTransactionSummary[];
|
||||
nextCursor: string | null;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
277
resources/js/admin/mobile/DataExportsPage.tsx
Normal file
277
resources/js/admin/mobile/DataExportsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
/>
|
||||
</Pressable>
|
||||
</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>
|
||||
<ListItem
|
||||
paddingVertical="$2"
|
||||
|
||||
@@ -36,6 +36,7 @@ const MobileNotificationsPage = React.lazy(() => 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: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/exports', element: <RequireAdminAccess><MobileDataExportsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
||||
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> },
|
||||
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
116
tests/Feature/Api/Tenant/DataExportApiTest.php
Normal file
116
tests/Feature/Api/Tenant/DataExportApiTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user