hasFilenameColumn = Photo::supportsFilenameColumn(); $this->hasPathColumn = Schema::hasColumn('photos', 'path'); } public function ingest(Event $event, ?int $maxFiles = null, string $ingestSource = Photo::SOURCE_PHOTOBOOTH): array { $event->loadMissing('photoboothSetting'); $eventSetting = $event->photoboothSetting; $tenant = $event->tenant; if (! $tenant || ! $eventSetting) { return ['processed' => 0, 'skipped' => 0]; } $importDisk = config('photobooth.import.disk', 'photobooth'); $basePath = ltrim((string) $eventSetting->path, '/'); if (str_starts_with($basePath, 'photobooth/')) { $basePath = substr($basePath, strlen('photobooth/')); } $disk = Storage::disk($importDisk); if ($basePath === '' || ! $disk->directoryExists($basePath)) { return ['processed' => 0, 'skipped' => 0]; } $allowedExtensions = config('photobooth.import.allowed_extensions', ['jpg', 'jpeg', 'png', 'webp']); $limit = $maxFiles ?? (int) config('photobooth.import.max_files_per_run', 50); $files = collect($disk->files($basePath)) ->filter(fn ($file) => $this->isAllowedFile($file, $allowedExtensions)) ->sort() ->take($limit); if ($files->isEmpty()) { return ['processed' => 0, 'skipped' => 0]; } $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget']); $tenant->refresh(); $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); if ($violation !== null) { Log::warning('[Photobooth] Upload blocked due to package violation', [ 'event_id' => $event->id, 'code' => $violation['code'], ]); return ['processed' => 0, 'skipped' => $files->count()]; } $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event); $disk = $this->storageManager->getHotDiskForEvent($event); $processed = 0; $skipped = 0; foreach ($files as $file) { if ($this->reachedPhotoLimit($eventPackage)) { break; } try { $result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file, $ingestSource); if ($result) { $processed++; Storage::disk($importDisk)->delete($file); } else { $skipped++; } } catch (\Throwable $exception) { $skipped++; Log::error('[Photobooth] Failed to ingest file', [ 'event_id' => $event->id, 'file' => $file, 'message' => $exception->getMessage(), ]); } } return ['processed' => $processed, 'skipped' => $skipped]; } protected function importFile( Event $event, ?EventPackage $eventPackage, string $destinationDisk, string $importDisk, string $file, string $ingestSource, ): bool { $stream = Storage::disk($importDisk)->readStream($file); if (! $stream) { return false; } $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'jpg'); $filename = Str::uuid().'.'.$extension; $eventSlug = $event->slug ?? 'event-'.$event->id; $destinationPath = "events/{$eventSlug}/photos/{$filename}"; try { Storage::disk($destinationDisk)->put($destinationPath, $stream); } finally { if (is_resource($stream)) { fclose($stream); } } $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82); $thumbnailToStore = $thumbnailRelative ?? $destinationPath; // Create watermarked copies (non-destructive). $watermarkConfig = \App\Support\WatermarkConfigResolver::resolve($event); $watermarkedPath = $destinationPath; $watermarkedThumb = $thumbnailToStore; if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { $watermarkedPath = ImageHelper::copyWithWatermark( $destinationDisk, $destinationPath, "events/{$eventSlug}/watermarked/{$filename}", $watermarkConfig ) ?? $destinationPath; if ($thumbnailRelative) { $watermarkedThumb = ImageHelper::copyWithWatermark( $destinationDisk, $thumbnailToStore, "events/{$eventSlug}/watermarked/thumbnails/{$filename}", $watermarkConfig ) ?? $thumbnailToStore; } else { $watermarkedThumb = $watermarkedPath; } } $size = Storage::disk($destinationDisk)->size($destinationPath); $mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg'; $originalName = basename($file); $photo = null; DB::transaction(function () use ( &$photo, $event, $eventPackage, $destinationDisk, $destinationPath, $thumbnailRelative, $thumbnailToStore, $watermarkedPath, $watermarkedThumb, $ingestSource, $mimeType, $size, $filename, $originalName, ) { $payload = [ 'event_id' => $event->id, 'emotion_id' => $this->resolveEmotionId($event), 'original_name' => $originalName, 'mime_type' => $mimeType, 'size' => $size, 'file_path' => $watermarkedPath, 'thumbnail_path' => $watermarkedThumb, 'status' => 'pending', 'guest_name' => $ingestSource, 'ingest_source' => $ingestSource, 'ip_address' => null, ]; if ($this->hasFilenameColumn) { $payload['filename'] = $filename; } if ($this->hasPathColumn) { $payload['path'] = $destinationPath; } $photo = Photo::create($payload); $asset = $this->storageManager->recordAsset($event, $destinationDisk, $destinationPath, [ 'variant' => 'original', 'mime_type' => $mimeType, 'size_bytes' => $size, 'checksum' => null, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); $watermarkedAsset = null; if ($watermarkedPath !== $destinationPath) { $watermarkedAsset = $this->storageManager->recordAsset($event, $destinationDisk, $watermarkedPath, [ 'variant' => 'watermarked', 'mime_type' => $mimeType, 'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedPath) ? Storage::disk($destinationDisk)->size($watermarkedPath) : null, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } if ($thumbnailRelative) { $this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); } if ($watermarkedThumb !== $thumbnailToStore) { $this->storageManager->recordAsset($event, $destinationDisk, $watermarkedThumb, [ 'variant' => 'watermarked_thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedThumb) ? Storage::disk($destinationDisk)->size($watermarkedThumb) : null, 'meta' => [ 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, ], ]); } $photo->update(['media_asset_id' => $asset->id]); $dimensions = @getimagesize(Storage::disk($destinationDisk)->path($destinationPath)); if ($dimensions !== false) { $photo->update([ 'width' => Arr::get($dimensions, 0), 'height' => Arr::get($dimensions, 1), ]); } if ($eventPackage) { $previousUsed = $eventPackage->used_photos; $eventPackage->increment('used_photos'); $eventPackage->refresh(); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1); } }); ProcessPhotoSecurityScan::dispatch($photo->id); return true; } protected function isAllowedFile(string $file, array $extensions): bool { $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: ''); return $extension !== '' && in_array($extension, $extensions, true); } protected function reachedPhotoLimit(?EventPackage $eventPackage): bool { if (! $eventPackage || ! $eventPackage->package) { return false; } $limit = $eventPackage->effectivePhotoLimit(); if ($limit === null) { return false; } return $limit > 0 && $eventPackage->used_photos >= $limit; } protected function resolveEmotionId(Event $event): ?int { if (! Photo::hasColumn('emotion_id')) { return null; } $existing = $event->photos()->value('emotion_id'); if ($existing) { return $existing; } return Emotion::query()->value('id'); } }