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