Implement compliance exports and retention overrides
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user