266 lines
8.6 KiB
PHP
266 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Photobooth;
|
|
|
|
use App\Jobs\ProcessPhotoSecurityScan;
|
|
use App\Models\Emotion;
|
|
use App\Models\Event;
|
|
use App\Models\EventPackage;
|
|
use App\Models\Photo;
|
|
use App\Services\Packages\PackageLimitEvaluator;
|
|
use App\Services\Packages\PackageUsageTracker;
|
|
use App\Services\Storage\EventStorageManager;
|
|
use App\Support\ImageHelper;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
class PhotoboothIngestService
|
|
{
|
|
private bool $hasFilenameColumn;
|
|
|
|
private bool $hasPathColumn;
|
|
|
|
public function __construct(
|
|
private readonly EventStorageManager $storageManager,
|
|
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
|
private readonly PackageUsageTracker $packageUsageTracker,
|
|
) {
|
|
$this->hasFilenameColumn = Photo::supportsFilenameColumn();
|
|
$this->hasPathColumn = Schema::hasColumn('photos', 'path');
|
|
}
|
|
|
|
public function ingest(Event $event, ?int $maxFiles = null): array
|
|
{
|
|
$tenant = $event->tenant;
|
|
|
|
if (! $tenant) {
|
|
return ['processed' => 0, 'skipped' => 0];
|
|
}
|
|
|
|
$importDisk = config('photobooth.import.disk', 'photobooth');
|
|
$basePath = ltrim((string) $event->photobooth_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);
|
|
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,
|
|
): 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;
|
|
|
|
$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,
|
|
$mimeType,
|
|
$size,
|
|
$filename,
|
|
$originalName,
|
|
) {
|
|
$payload = [
|
|
'event_id' => $event->id,
|
|
'emotion_id' => $this->resolveEmotionId($event),
|
|
'original_name' => $originalName,
|
|
'mime_type' => $mimeType,
|
|
'size' => $size,
|
|
'file_path' => $destinationPath,
|
|
'thumbnail_path' => $thumbnailToStore,
|
|
'status' => 'pending',
|
|
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
|
|
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
|
|
'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,
|
|
]);
|
|
|
|
if ($thumbnailRelative) {
|
|
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
|
|
'variant' => 'thumbnail',
|
|
'mime_type' => 'image/jpeg',
|
|
'status' => 'hot',
|
|
'processed_at' => now(),
|
|
'photo_id' => $photo->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');
|
|
}
|
|
}
|