Files
fotospiel-app/app/Jobs/GenerateDataExport.php
Codex Agent eed7699549
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Implement compliance exports and retention overrides
2026-01-02 20:13:45 +01:00

470 lines
16 KiB
PHP

<?php
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 Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use ZipArchive;
class GenerateDataExport implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly int $exportId)
{
$this->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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<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();
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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<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;
}
}