onQueue('default'); } public function handle(): void { $export = DataExport::with(['user', 'tenant', 'event'])->find($this->exportId); if (! $export) { return; } if (! $export->user) { $export->update([ 'status' => DataExport::STATUS_FAILED, 'error_message' => 'User no longer exists.', ]); 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); $zipPath = $this->writeArchive($export, $payload); $export->update([ 'status' => DataExport::STATUS_READY, 'path' => $zipPath, 'size_bytes' => Storage::disk('local')->size($zipPath), 'expires_at' => now()->addDays(14), ]); } catch (\Throwable $exception) { $export->update([ 'status' => DataExport::STATUS_FAILED, 'error_message' => $exception->getMessage(), ]); report($exception); } } /** * @return 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, 'email' => $user->email, 'username' => $user->username, 'preferred_locale' => $user->preferred_locale, 'created_at' => optional($user->created_at)->toIso8601String(), 'email_verified_at' => optional($user->email_verified_at)->toIso8601String(), ], ]; if ($tenant) { $profile['tenant'] = [ 'id' => $tenant->id, 'name' => $tenant->name, 'slug' => $tenant->slug, 'contact_email' => $tenant->contact_email, 'contact_phone' => $tenant->contact_phone, 'subscription_status' => $tenant->subscription_status, 'subscription_expires_at' => optional($tenant->subscription_expires_at)->toIso8601String(), 'created_at' => optional($tenant->created_at)->toIso8601String(), ]; } $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 $payload; } /** * @return array> */ protected function collectEvents(Tenant $tenant): array { $events = Event::query() ->withCount([ 'photos as photos_total', 'photos as featured_photos_total' => fn ($query) => $query->where('is_featured', true), 'joinTokens', 'members', ]) ->where('tenant_id', $tenant->id) ->orderBy('date') ->get(); $likeCounts = DB::table('photo_likes') ->join('photos', 'photo_likes.photo_id', '=', 'photos.id') ->where('photos.tenant_id', $tenant->id) ->groupBy('photos.event_id') ->pluck(DB::raw('COUNT(*)'), 'photos.event_id'); return $events ->map(fn (Event $event): array => $this->buildEventSummary( $event, (int) ($likeCounts[$event->id] ?? 0) )) ->all(); } /** * @return array */ protected function buildEventSummary(Event $event, ?int $likes = null): array { $likes = $likes ?? $this->countEventLikes($event); return [ 'id' => $event->id, 'slug' => $event->slug, 'status' => $event->status, 'name' => $event->name, 'location' => $event->location, 'date' => optional($event->date)->toIso8601String(), 'photos_total' => (int) ($event->photos_total ?? $event->photos()->count()), 'featured_photos_total' => (int) ($event->featured_photos_total ?? $event->photos()->where('is_featured', true)->count()), 'join_tokens_total' => (int) ($event->join_tokens_count ?? $event->joinTokens()->count()), 'members_total' => (int) ($event->members_count ?? $event->members()->count()), 'likes_total' => $likes, 'created_at' => optional($event->created_at)->toIso8601String(), 'updated_at' => optional($event->updated_at)->toIso8601String(), ]; } protected function countEventLikes(Event $event): int { return (int) DB::table('photo_likes') ->join('photos', 'photo_likes.photo_id', '=', 'photos.id') ->where('photos.event_id', $event->id) ->count(); } /** * @return array> */ protected function collectEventPhotos(Event $event): array { return Photo::query() ->withCount('likes') ->where('event_id', $event->id) ->orderBy('created_at') ->get() ->map(fn (Photo $photo): array => [ 'id' => $photo->id, 'status' => $photo->status ?? null, 'ingest_source' => $photo->ingest_source, 'is_featured' => (bool) $photo->is_featured, 'emotion_id' => $photo->emotion_id, 'task_id' => $photo->task_id, 'likes_total' => (int) ($photo->likes_count ?? 0), 'created_at' => optional($photo->created_at)->toIso8601String(), 'moderated_at' => optional($photo->moderated_at)->toIso8601String(), ]) ->all(); } /** * @return array> */ protected function collectInvoices(Tenant $tenant): array { return PackagePurchase::query() ->where('tenant_id', $tenant->id) ->with('package') ->latest('purchased_at') ->get() ->map(function (PackagePurchase $purchase): array { return [ 'id' => $purchase->id, 'package' => $purchase->package?->getNameForLocale(app()->getLocale()) ?? $purchase->package?->name, 'price' => $purchase->price !== null ? (float) $purchase->price : null, 'currency' => 'EUR', 'type' => $purchase->type, 'provider' => $purchase->provider, 'provider_id' => $purchase->provider_id, 'purchased_at' => optional($purchase->purchased_at)->toIso8601String(), 'refunded' => (bool) $purchase->refunded, ]; }) ->all(); } /** * @param array $payload */ protected function writeArchive(DataExport $export, array $payload): string { $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; $zip = new ZipArchive; $fullPath = Storage::disk('local')->path($path); if ($zip->open($fullPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('Unable to create export archive.'); } $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", [ __('profile.export.readme.title', [], $locale), '---------------------', __('profile.export.readme.description', [], $locale), __('profile.export.readme.generated', [ 'date' => now()->copy()->locale($locale)->translatedFormat('d. F Y H:i'), ], $locale), __('profile.export.readme.expiry', [], $locale), ]); $zip->addFromString('README.txt', $readme); $zip->close(); return $path; } /** * @return array */ protected function resolveMediaEvents(DataExport $export): array { if ($export->scope === DataExportScope::EVENT && $export->event) { return [$export->event]; } if ($export->scope === DataExportScope::TENANT && $export->tenant) { return Event::query() ->where('tenant_id', $export->tenant->id) ->orderBy('date') ->get() ->all(); } return []; } protected function appendMediaFiles(ZipArchive $zip, DataExport $export): void { $events = $this->resolveMediaEvents($export); foreach ($events as $event) { $photos = Photo::query() ->with('mediaAsset') ->where('event_id', $event->id) ->orderBy('created_at') ->get(); foreach ($photos as $photo) { $asset = $this->resolveOriginalAsset($photo); $sourcePath = $asset?->path ?? $photo->file_path; if (! $sourcePath) { continue; } $filename = $this->buildMediaFilename($event, $photo, $sourcePath); if ($asset && $asset->path) { $this->addDiskFileToArchive( $zip, $asset->disk ?? config('filesystems.default'), $asset->path, $filename ); } else { $this->addDiskFileToArchive( $zip, config('filesystems.default'), $photo->file_path, $filename ); } } } } protected function resolveOriginalAsset(Photo $photo): ?EventMediaAsset { $asset = $photo->mediaAsset; if ($asset && $asset->variant === 'original') { return $asset; } $original = EventMediaAsset::query() ->where('photo_id', $photo->id) ->where('variant', 'original') ->first(); return $original ?? $asset; } protected function buildMediaFilename(Event $event, Photo $photo, string $sourcePath): string { $eventSlug = $event->slug ?: 'event-'.$event->id; $timestamp = $photo->created_at?->format('Ymd_His') ?? now()->format('Ymd_His'); $extension = pathinfo($sourcePath, PATHINFO_EXTENSION) ?: 'jpg'; return sprintf('media/%s/%s-photo-%d.%s', $eventSlug, $timestamp, $photo->id, $extension); } protected function addDiskFileToArchive(ZipArchive $zip, ?string $diskName, string $path, string $filename): bool { $disk = $diskName ? Storage::disk($diskName) : Storage::disk(config('filesystems.default')); try { if (method_exists($disk, 'path')) { $absolute = $disk->path($path); if (is_file($absolute)) { return $zip->addFile($absolute, $filename); } } $stream = $disk->readStream($path); if ($stream) { $contents = stream_get_contents($stream); fclose($stream); if ($contents !== false) { return $zip->addFromString($filename, $contents); } } } catch (\Throwable $exception) { report($exception); } return false; } }