From 1c4acda3329078b9e21928ea3e598a0e9c95e981 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 18 Dec 2025 08:49:56 +0100 Subject: [PATCH] updated table structure for photobooth/sparkbooth settings. now there's a separate table for it. update all references and tests. also fixed the notification panel and the lightbox in the guest app. --- .../DeactivateExpiredPhotoboothAccounts.php | 22 +- .../Commands/PhotoboothIngestCommand.php | 21 +- .../Controllers/Api/EventPublicController.php | 12 +- .../Api/SparkboothUploadController.php | 5 +- .../Api/Tenant/PhotoboothController.php | 2 +- .../Tenant/PhotoboothStatusResource.php | 44 +-- app/Models/Event.php | 63 +---- app/Models/EventPhotoboothSetting.php | 64 +++++ app/Services/Compliance/AccountAnonymizer.php | 19 +- .../Photobooth/PhotoboothIngestService.php | 9 +- .../Photobooth/PhotoboothProvisioner.php | 189 ++++++++----- .../Photobooth/SparkboothUploadService.php | 55 ++-- .../EventPhotoboothSettingFactory.php | 47 ++++ ...add_photobooth_columns_to_events_table.php | 46 ---- ..._133343_add_watermark_fields_to_events.php | 4 +- ...add_sparkbooth_columns_to_events_table.php | 59 ---- ...create_event_photobooth_settings_table.php | 46 ++++ database/seeders/DatabaseSeeder.php | 2 +- .../seeders/EventPhotoboothSettingSeeder.php | 17 ++ .../js/guest/components/GalleryPreview.tsx | 4 +- resources/js/guest/components/Header.tsx | 127 ++++----- resources/js/guest/pages/GalleryPage.tsx | 9 +- resources/js/guest/pages/HomePage.tsx | 37 ++- resources/js/guest/pages/PhotoLightbox.tsx | 259 +++++++++++------- resources/js/guest/pages/UploadPage.tsx | 1 - .../js/guest/polling/usePollGalleryDelta.ts | 15 +- .../Console/PhotoboothCleanupCommandTest.php | 30 +- .../Photobooth/PhotoboothControllerTest.php | 37 ++- .../Photobooth/PhotoboothFilterTest.php | 10 +- .../PhotoboothIngestCommandTest.php | 17 +- 30 files changed, 734 insertions(+), 538 deletions(-) create mode 100644 app/Models/EventPhotoboothSetting.php create mode 100644 database/factories/EventPhotoboothSettingFactory.php delete mode 100644 database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php delete mode 100644 database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php create mode 100644 database/migrations/2025_12_18_081047_create_event_photobooth_settings_table.php create mode 100644 database/seeders/EventPhotoboothSettingSeeder.php diff --git a/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php b/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php index 188b225..660d480 100644 --- a/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php +++ b/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Services\Photobooth\PhotoboothProvisioner; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -17,12 +17,20 @@ class DeactivateExpiredPhotoboothAccounts extends Command { $total = 0; - Event::query() - ->where('photobooth_enabled', true) - ->whereNotNull('photobooth_expires_at') - ->where('photobooth_expires_at', '<=', now()) - ->chunkById(50, function ($events) use (&$total, $provisioner) { - foreach ($events as $event) { + EventPhotoboothSetting::query() + ->where('enabled', true) + ->where('mode', 'ftp') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->with('event') + ->chunkById(50, function ($settings) use (&$total, $provisioner) { + foreach ($settings as $setting) { + $event = $setting->event; + + if (! $event) { + continue; + } + try { $provisioner->disable($event); $total++; diff --git a/app/Console/Commands/PhotoboothIngestCommand.php b/app/Console/Commands/PhotoboothIngestCommand.php index 36dd9c6..a332bf3 100644 --- a/app/Console/Commands/PhotoboothIngestCommand.php +++ b/app/Console/Commands/PhotoboothIngestCommand.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Services\Photobooth\PhotoboothIngestService; use Illuminate\Console\Command; @@ -20,16 +20,23 @@ class PhotoboothIngestCommand extends Command $processedTotal = 0; $skippedTotal = 0; - $query = Event::query() - ->where('photobooth_enabled', true) - ->whereNotNull('photobooth_path'); + $query = EventPhotoboothSetting::query() + ->where('enabled', true) + ->whereNotNull('path') + ->with('event'); if ($eventId) { - $query->whereKey($eventId); + $query->where('event_id', $eventId); } - $query->chunkById(25, function ($events) use ($ingestService, $maxFiles, &$processedTotal, &$skippedTotal) { - foreach ($events as $event) { + $query->chunkById(25, function ($settings) use ($ingestService, $maxFiles, &$processedTotal, &$skippedTotal) { + foreach ($settings as $setting) { + $event = $setting->event; + + if (! $event) { + continue; + } + $summary = $ingestService->ingest($event, $maxFiles ? (int) $maxFiles : null); $processedTotal += $summary['processed'] ?? 0; $skippedTotal += $summary['skipped'] ?? 0; diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 356f82c..bfb1bc0 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -7,13 +7,13 @@ use App\Enums\GuestNotificationDeliveryStatus; use App\Enums\GuestNotificationState; use App\Enums\GuestNotificationType; use App\Events\GuestPhotoUploaded; +use App\Jobs\ProcessPhotoSecurityScan; use App\Models\Event; use App\Models\EventJoinToken; use App\Models\EventMediaAsset; use App\Models\GuestNotification; use App\Models\Photo; use App\Models\PhotoShareLink; -use App\Jobs\ProcessPhotoSecurityScan; use App\Services\Analytics\JoinTokenAnalyticsRecorder; use App\Services\EventJoinTokenService; use App\Services\EventTasksCacheService; @@ -44,6 +44,7 @@ use Symfony\Component\HttpFoundation\Response; class EventPublicController extends BaseController { private const SIGNED_URL_TTL_SECONDS = 1800; + private const BRANDING_SIGNED_TTL_SECONDS = 3600; public function __construct( @@ -952,7 +953,7 @@ class EventPublicController extends BaseController } /** - * @param array $sources + * @param array $sources */ private function firstStringFromSources(array $sources, array $keys): ?string { @@ -969,7 +970,7 @@ class EventPublicController extends BaseController } /** - * @param array $sources + * @param array $sources */ private function firstNumberFromSources(array $sources, array $keys): ?float { @@ -1778,6 +1779,7 @@ class EventPublicController extends BaseController $branding = $this->buildGalleryBranding($event); $settings = $this->normalizeSettings($event->settings ?? []); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; + $event->loadMissing('photoboothSetting'); if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); @@ -1792,7 +1794,7 @@ class EventPublicController extends BaseController 'updated_at' => $event->updated_at, 'type' => $eventTypeData, 'join_token' => $joinToken?->token, - 'photobooth_enabled' => (bool) $event->photobooth_enabled, + 'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled), 'branding' => $branding, 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'), 'engagement_mode' => $engagementMode, @@ -2558,7 +2560,7 @@ class EventPublicController extends BaseController $lastPage = (int) ceil($total / $perPage); $hasMore = $page < $lastPage; - $etag = sha1($baseHash . ':' . $page . ':' . $perPage . ':' . $seedValue); + $etag = sha1($baseHash.':'.$page.':'.$perPage.':'.$seedValue); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { diff --git a/app/Http/Controllers/Api/SparkboothUploadController.php b/app/Http/Controllers/Api/SparkboothUploadController.php index 279cdc3..7d5c0a2 100644 --- a/app/Http/Controllers/Api/SparkboothUploadController.php +++ b/app/Http/Controllers/Api/SparkboothUploadController.php @@ -7,8 +7,8 @@ use App\Models\Event; use App\Services\Photobooth\Exceptions\SparkboothUploadException; use App\Services\Photobooth\SparkboothUploadService; use Illuminate\Http\Request; -use Illuminate\Http\UploadedFile; use Illuminate\Http\Response; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Str; class SparkboothUploadController extends Controller @@ -68,7 +68,8 @@ class SparkboothUploadController extends Controller return $preferred; } - $configured = $event?->photobooth_metadata['sparkbooth_response_format'] ?? null; + $event?->loadMissing('photoboothSetting'); + $configured = ($event?->photoboothSetting?->metadata ?? [])['sparkbooth_response_format'] ?? null; if ($configured && in_array($configured, ['json', 'xml'], true)) { return $configured; diff --git a/app/Http/Controllers/Api/Tenant/PhotoboothController.php b/app/Http/Controllers/Api/Tenant/PhotoboothController.php index 9fcd91c..331f55f 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoboothController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoboothController.php @@ -72,7 +72,7 @@ class PhotoboothController extends Controller protected function resource(Event $event): PhotoboothStatusResource { return PhotoboothStatusResource::make([ - 'event' => $event->fresh(), + 'event' => $event->fresh('photoboothSetting'), 'settings' => PhotoboothSetting::current(), ]); } diff --git a/app/Http/Resources/Tenant/PhotoboothStatusResource.php b/app/Http/Resources/Tenant/PhotoboothStatusResource.php index 719d373..1623073 100644 --- a/app/Http/Resources/Tenant/PhotoboothStatusResource.php +++ b/app/Http/Resources/Tenant/PhotoboothStatusResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Tenant; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\PhotoboothSetting; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -20,31 +21,32 @@ class PhotoboothStatusResource extends JsonResource /** @var PhotoboothSetting $settings */ $settings = $payload['settings']; - $mode = $event->photobooth_mode ?? 'ftp'; + $event->loadMissing('photoboothSetting'); + $eventSetting = $event->photoboothSetting; + + $mode = $eventSetting?->mode ?? 'ftp'; $isSparkbooth = $mode === 'sparkbooth'; - $password = $isSparkbooth - ? $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password - : $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password; + $password = $eventSetting?->getAttribute('plain_password') ?? $eventSetting?->password; - $activeUsername = $isSparkbooth ? $event->sparkbooth_username : $event->photobooth_username; - $activeStatus = $isSparkbooth ? $event->sparkbooth_status : $event->photobooth_status; - $activeExpires = $isSparkbooth ? $event->sparkbooth_expires_at : $event->photobooth_expires_at; + $activeUsername = $eventSetting?->username; + $activeStatus = $eventSetting?->status ?? 'inactive'; + $activeExpires = $eventSetting?->expires_at; $sparkMetrics = [ - 'last_upload_at' => optional($event->sparkbooth_last_upload_at)->toIso8601String(), - 'uploads_24h' => (int) ($event->sparkbooth_uploads_last_24h ?? 0), - 'uploads_total' => (int) ($event->sparkbooth_uploads_total ?? 0), + 'last_upload_at' => optional($eventSetting?->last_upload_at)->toIso8601String(), + 'uploads_24h' => (int) ($eventSetting?->uploads_last_24h ?? 0), + 'uploads_total' => (int) ($eventSetting?->uploads_total ?? 0), ]; return [ 'mode' => $mode, - 'enabled' => (bool) $event->photobooth_enabled, + 'enabled' => (bool) ($eventSetting?->enabled), 'status' => $activeStatus, 'username' => $activeUsername, 'password' => $password, - 'path' => $event->photobooth_path, - 'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($event, $settings, $password), + 'path' => $eventSetting?->path, + 'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password), 'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null, 'expires_at' => optional($activeExpires)->toIso8601String(), 'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute, @@ -55,13 +57,13 @@ class PhotoboothStatusResource extends JsonResource ], 'metrics' => $isSparkbooth ? $sparkMetrics : null, 'sparkbooth' => [ - 'enabled' => $mode === 'sparkbooth' && $event->photobooth_enabled, - 'status' => $event->sparkbooth_status, - 'username' => $event->sparkbooth_username, - 'password' => $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password, - 'expires_at' => optional($event->sparkbooth_expires_at)->toIso8601String(), + 'enabled' => $mode === 'sparkbooth' && (bool) ($eventSetting?->enabled), + 'status' => $mode === 'sparkbooth' ? ($eventSetting?->status) : 'inactive', + 'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null, + 'password' => $mode === 'sparkbooth' ? $password : null, + 'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null, 'upload_url' => route('api.v1.photobooth.sparkbooth.upload'), - 'response_format' => $event->photobooth_metadata['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'), + 'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'), 'metrics' => $sparkMetrics, ], ]; @@ -87,10 +89,10 @@ class PhotoboothStatusResource extends JsonResource ]; } - protected function buildFtpUrl(Event $event, PhotoboothSetting $settings, ?string $password): ?string + protected function buildFtpUrl(?EventPhotoboothSetting $eventSetting, PhotoboothSetting $settings, ?string $password): ?string { $host = config('photobooth.ftp.host'); - $username = $event->photobooth_username; + $username = $eventSetting?->username; if (! $host || ! $username || ! $password) { return null; diff --git a/app/Models/Event.php b/app/Models/Event.php index 4f2b242..5fb6ff0 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -3,13 +3,12 @@ namespace App\Models; use App\Services\EventJoinTokenService; -use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Facades\Crypt; +use Illuminate\Database\Eloquent\Relations\HasOne; class Event extends Model { @@ -24,17 +23,6 @@ class Event extends Model 'is_active' => 'boolean', 'name' => 'array', 'description' => 'array', - 'photobooth_enabled' => 'boolean', - 'photobooth_mode' => 'string', - 'photobooth_expires_at' => 'datetime', - 'photobooth_metadata' => 'array', - 'sparkbooth_expires_at' => 'datetime', - 'sparkbooth_last_upload_at' => 'datetime', - ]; - - protected $hidden = [ - 'photobooth_password_encrypted', - 'sparkbooth_password_encrypted', ]; protected static function booted(): void @@ -126,6 +114,11 @@ class Event extends Model return $this->hasMany(EventMember::class); } + public function photoboothSetting(): HasOne + { + return $this->hasOne(EventPhotoboothSetting::class); + } + public function guestNotifications(): HasMany { return $this->hasMany(GuestNotification::class); @@ -178,48 +171,4 @@ class Event extends Model $this->attributes['settings'] = json_encode($value ?? []); } - - public function getPhotoboothPasswordAttribute(): ?string - { - $encrypted = $this->attributes['photobooth_password_encrypted'] ?? null; - - if (! $encrypted) { - return null; - } - - try { - return Crypt::decryptString($encrypted); - } catch (DecryptException) { - return null; - } - } - - public function setPhotoboothPasswordAttribute(?string $value): void - { - $this->attributes['photobooth_password_encrypted'] = $value - ? Crypt::encryptString($value) - : null; - } - - public function getSparkboothPasswordAttribute(): ?string - { - $encrypted = $this->attributes['sparkbooth_password_encrypted'] ?? null; - - if (! $encrypted) { - return null; - } - - try { - return Crypt::decryptString($encrypted); - } catch (DecryptException) { - return null; - } - } - - public function setSparkboothPasswordAttribute(?string $value): void - { - $this->attributes['sparkbooth_password_encrypted'] = $value - ? Crypt::encryptString($value) - : null; - } } diff --git a/app/Models/EventPhotoboothSetting.php b/app/Models/EventPhotoboothSetting.php new file mode 100644 index 0000000..c16273d --- /dev/null +++ b/app/Models/EventPhotoboothSetting.php @@ -0,0 +1,64 @@ + */ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'enabled' => 'boolean', + 'expires_at' => 'datetime', + 'last_provisioned_at' => 'datetime', + 'last_deprovisioned_at' => 'datetime', + 'last_upload_at' => 'datetime', + 'metadata' => 'array', + 'uploads_last_24h' => 'integer', + 'uploads_total' => 'integer', + ]; + + protected $hidden = [ + 'password_encrypted', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function getPasswordAttribute(): ?string + { + $encrypted = $this->attributes['password_encrypted'] ?? null; + + if (! $encrypted) { + return null; + } + + try { + return Crypt::decryptString($encrypted); + } catch (DecryptException) { + return null; + } + } + + public function setPasswordAttribute(?string $value): void + { + $this->attributes['password_encrypted'] = $value + ? Crypt::encryptString($value) + : null; + } + + public function setUsernameAttribute(?string $value): void + { + $this->attributes['username'] = $value ? strtolower($value) : null; + } +} diff --git a/app/Services/Compliance/AccountAnonymizer.php b/app/Services/Compliance/AccountAnonymizer.php index 93d1457..b02f922 100644 --- a/app/Services/Compliance/AccountAnonymizer.php +++ b/app/Services/Compliance/AccountAnonymizer.php @@ -85,9 +85,24 @@ class AccountAnonymizer 'location' => null, 'slug' => 'anonymized-event-'.$event->id, 'status' => 'archived', - 'photobooth_enabled' => false, - 'photobooth_path' => null, ])->save(); + + $event->loadMissing('photoboothSetting'); + + if ($event->photoboothSetting) { + $event->photoboothSetting->forceFill([ + 'enabled' => false, + 'status' => 'inactive', + 'username' => null, + 'password' => null, + 'path' => null, + 'expires_at' => null, + 'last_deprovisioned_at' => now(), + 'last_upload_at' => null, + 'uploads_last_24h' => 0, + 'uploads_total' => 0, + ])->save(); + } } }); diff --git a/app/Services/Photobooth/PhotoboothIngestService.php b/app/Services/Photobooth/PhotoboothIngestService.php index 0883a74..b3deddd 100644 --- a/app/Services/Photobooth/PhotoboothIngestService.php +++ b/app/Services/Photobooth/PhotoboothIngestService.php @@ -35,14 +35,16 @@ class PhotoboothIngestService 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) { + if (! $tenant || ! $eventSetting) { return ['processed' => 0, 'skipped' => 0]; } $importDisk = config('photobooth.import.disk', 'photobooth'); - $basePath = ltrim((string) $event->photobooth_path, '/'); + $basePath = ltrim((string) $eventSetting->path, '/'); if (str_starts_with($basePath, 'photobooth/')) { $basePath = substr($basePath, strlen('photobooth/')); } @@ -178,6 +180,9 @@ class PhotoboothIngestService $destinationPath, $thumbnailRelative, $thumbnailToStore, + $watermarkedPath, + $watermarkedThumb, + $ingestSource, $mimeType, $size, $filename, diff --git a/app/Services/Photobooth/PhotoboothProvisioner.php b/app/Services/Photobooth/PhotoboothProvisioner.php index f156f15..1872e8a 100644 --- a/app/Services/Photobooth/PhotoboothProvisioner.php +++ b/app/Services/Photobooth/PhotoboothProvisioner.php @@ -3,6 +3,7 @@ namespace App\Services\Photobooth; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\PhotoboothSetting; use Carbon\CarbonInterface; use Illuminate\Support\Carbon; @@ -20,9 +21,10 @@ class PhotoboothProvisioner public function enable(Event $event, ?PhotoboothSetting $settings = null): Event { $settings ??= PhotoboothSetting::current(); - $event->loadMissing('tenant'); + $event->loadMissing(['tenant', 'photoboothSetting']); return DB::transaction(function () use ($event, $settings) { + $eventSetting = $this->resolveEventSetting($event); $username = $this->generateUniqueUsername($event, $settings); $password = $this->credentialGenerator->generatePassword(); $path = $this->buildPath($event); @@ -39,21 +41,24 @@ class PhotoboothProvisioner $this->client->provisionUser($payload, $settings); - $event->forceFill([ - 'photobooth_enabled' => true, - 'photobooth_username' => $username, - 'photobooth_password' => $password, - 'photobooth_path' => $path, - 'photobooth_expires_at' => $expiresAt, - 'photobooth_status' => 'active', - 'photobooth_last_provisioned_at' => now(), - 'photobooth_metadata' => [ + $eventSetting->forceFill([ + 'enabled' => true, + 'mode' => 'ftp', + 'username' => $username, + 'password' => $password, + 'path' => $path, + 'expires_at' => $expiresAt, + 'status' => 'active', + 'last_provisioned_at' => now(), + 'metadata' => [ 'rate_limit_per_minute' => $settings->rate_limit_per_minute, ], ])->save(); - return tap($event->refresh(), function (Event $refreshed) use ($password) { - $refreshed->setAttribute('plain_photobooth_password', $password); + return tap($event->refresh()->load('photoboothSetting'), function (Event $refreshed) use ($password) { + if ($refreshed->photoboothSetting) { + $refreshed->photoboothSetting->setAttribute('plain_password', $password); + } }); }); } @@ -62,11 +67,14 @@ class PhotoboothProvisioner { $settings ??= PhotoboothSetting::current(); - if (! $event->photobooth_enabled || ! $event->photobooth_username) { + $event->loadMissing('photoboothSetting'); + $eventSetting = $event->photoboothSetting; + + if (! $eventSetting || ! $eventSetting->enabled || ! $eventSetting->username) { return $this->enable($event, $settings); } - return DB::transaction(function () use ($event, $settings) { + return DB::transaction(function () use ($event, $settings, $eventSetting) { $password = $this->credentialGenerator->generatePassword(); $expiresAt = $this->resolveExpiry($event, $settings); @@ -76,78 +84,90 @@ class PhotoboothProvisioner 'rate_limit_per_minute' => $settings->rate_limit_per_minute, ]; - $this->client->rotateUser($event->photobooth_username, $payload, $settings); + $this->client->rotateUser($eventSetting->username, $payload, $settings); - $event->forceFill([ - 'photobooth_password' => $password, - 'photobooth_expires_at' => $expiresAt, - 'photobooth_status' => 'active', - 'photobooth_last_provisioned_at' => now(), + $eventSetting->forceFill([ + 'enabled' => true, + 'mode' => 'ftp', + 'password' => $password, + 'expires_at' => $expiresAt, + 'status' => 'active', + 'path' => $eventSetting->path ?: $this->buildPath($event), + 'last_provisioned_at' => now(), ])->save(); - return tap($event->refresh(), function (Event $refreshed) use ($password) { - $refreshed->setAttribute('plain_photobooth_password', $password); + return tap($event->refresh()->load('photoboothSetting'), function (Event $refreshed) use ($password) { + if ($refreshed->photoboothSetting) { + $refreshed->photoboothSetting->setAttribute('plain_password', $password); + } }); }); } public function disable(Event $event, ?PhotoboothSetting $settings = null): Event { - if (! $event->photobooth_username) { + $event->loadMissing('photoboothSetting'); + $eventSetting = $event->photoboothSetting; + + if (! $eventSetting || ! $eventSetting->username) { return $event; } $settings ??= PhotoboothSetting::current(); - return DB::transaction(function () use ($event, $settings) { + return DB::transaction(function () use ($event, $settings, $eventSetting) { try { - $this->client->deleteUser($event->photobooth_username, $settings); + $this->client->deleteUser($eventSetting->username, $settings); } catch (\Throwable $exception) { Log::warning('Photobooth account deletion failed', [ 'event_id' => $event->id, - 'username' => $event->photobooth_username, + 'username' => $eventSetting->username, 'message' => $exception->getMessage(), ]); } - $event->forceFill([ - 'photobooth_enabled' => false, - 'photobooth_status' => 'inactive', - 'photobooth_username' => null, - 'photobooth_password' => null, - 'photobooth_path' => null, - 'photobooth_expires_at' => null, - 'photobooth_last_deprovisioned_at' => now(), + $eventSetting->forceFill([ + 'enabled' => false, + 'status' => 'inactive', + 'username' => null, + 'password' => null, + 'path' => null, + 'expires_at' => null, + 'last_deprovisioned_at' => now(), ])->save(); - return $event->refresh(); + return $event->refresh()->load('photoboothSetting'); }); } public function enableSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event { $settings ??= PhotoboothSetting::current(); - $event->loadMissing('tenant'); + $event->loadMissing(['tenant', 'photoboothSetting']); return DB::transaction(function () use ($event, $settings) { + $eventSetting = $this->resolveEventSetting($event); $username = $this->generateUniqueUsername($event, $settings); $password = $this->credentialGenerator->generatePassword(); $path = $this->buildPath($event); $expiresAt = $this->resolveExpiry($event, $settings); - $event->forceFill([ - 'photobooth_enabled' => true, - 'photobooth_mode' => 'sparkbooth', - 'sparkbooth_username' => $username, - 'sparkbooth_password' => $password, - 'sparkbooth_expires_at' => $expiresAt, - 'sparkbooth_status' => 'active', - 'photobooth_path' => $path, - 'sparkbooth_uploads_last_24h' => 0, + $eventSetting->forceFill([ + 'enabled' => true, + 'mode' => 'sparkbooth', + 'username' => $username, + 'password' => $password, + 'expires_at' => $expiresAt, + 'status' => 'active', + 'path' => $path, + 'uploads_last_24h' => 0, + 'last_provisioned_at' => now(), ])->save(); - return tap($event->refresh(), function (Event $refreshed) use ($password) { - $refreshed->setAttribute('plain_sparkbooth_password', $password); + return tap($event->refresh()->load('photoboothSetting'), function (Event $refreshed) use ($password) { + if ($refreshed->photoboothSetting) { + $refreshed->photoboothSetting->setAttribute('plain_password', $password); + } }); }); } @@ -155,48 +175,68 @@ class PhotoboothProvisioner public function rotateSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event { $settings ??= PhotoboothSetting::current(); + $event->loadMissing('photoboothSetting'); + $eventSetting = $event->photoboothSetting; - if ($event->photobooth_mode !== 'sparkbooth' || ! $event->sparkbooth_username) { + if (! $eventSetting || $eventSetting->mode !== 'sparkbooth' || ! $eventSetting->username) { return $this->enableSparkbooth($event, $settings); } - return DB::transaction(function () use ($event, $settings) { + return DB::transaction(function () use ($event, $settings, $eventSetting) { $password = $this->credentialGenerator->generatePassword(); $expiresAt = $this->resolveExpiry($event, $settings); - $event->forceFill([ - 'sparkbooth_password' => $password, - 'sparkbooth_expires_at' => $expiresAt, - 'sparkbooth_status' => 'active', - 'photobooth_enabled' => true, - 'photobooth_mode' => 'sparkbooth', + $eventSetting->forceFill([ + 'enabled' => true, + 'mode' => 'sparkbooth', + 'password' => $password, + 'expires_at' => $expiresAt, + 'status' => 'active', + 'path' => $eventSetting->path ?: $this->buildPath($event), + 'last_provisioned_at' => now(), ])->save(); - return tap($event->refresh(), function (Event $refreshed) use ($password) { - $refreshed->setAttribute('plain_sparkbooth_password', $password); + return tap($event->refresh()->load('photoboothSetting'), function (Event $refreshed) use ($password) { + if ($refreshed->photoboothSetting) { + $refreshed->photoboothSetting->setAttribute('plain_password', $password); + } }); }); } public function disableSparkbooth(Event $event): Event { - return DB::transaction(function () use ($event) { - $event->forceFill([ - 'photobooth_enabled' => false, - 'photobooth_mode' => 'ftp', - 'sparkbooth_username' => null, - 'sparkbooth_password' => null, - 'sparkbooth_expires_at' => null, - 'sparkbooth_status' => 'inactive', - 'sparkbooth_last_upload_at' => null, - 'sparkbooth_uploads_last_24h' => 0, - 'sparkbooth_uploads_total' => 0, + $event->loadMissing('photoboothSetting'); + $eventSetting = $event->photoboothSetting; + + if (! $eventSetting) { + return $event; + } + + return DB::transaction(function () use ($event, $eventSetting) { + $eventSetting->forceFill([ + 'enabled' => false, + 'mode' => 'ftp', + 'username' => null, + 'password' => null, + 'expires_at' => null, + 'status' => 'inactive', + 'last_upload_at' => null, + 'uploads_last_24h' => 0, + 'uploads_total' => 0, ])->save(); - return $event->refresh(); + return $event->refresh()->load('photoboothSetting'); }); } + protected function resolveEventSetting(Event $event): EventPhotoboothSetting + { + $event->loadMissing('photoboothSetting'); + + return $event->photoboothSetting ?? $event->photoboothSetting()->make(); + } + protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface { $eventEnd = $event->date ? Carbon::parse($event->date) : now(); @@ -214,14 +254,15 @@ class PhotoboothProvisioner for ($i = 0; $i < $maxAttempts; $i++) { $username = $this->credentialGenerator->generateUsername($event); - $exists = Event::query() - ->where('photobooth_username', $username) - ->orWhere('sparkbooth_username', $username) - ->whereKeyNot($event->getKey()) + $normalized = strtolower($username); + + $exists = EventPhotoboothSetting::query() + ->where('username', $normalized) + ->where('event_id', '!=', $event->getKey()) ->exists(); if (! $exists) { - return strtolower($username); + return $normalized; } } diff --git a/app/Services/Photobooth/SparkboothUploadService.php b/app/Services/Photobooth/SparkboothUploadService.php index 5327b30..db09664 100644 --- a/app/Services/Photobooth/SparkboothUploadService.php +++ b/app/Services/Photobooth/SparkboothUploadService.php @@ -3,6 +3,7 @@ namespace App\Services\Photobooth; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\Photo; use App\Services\Photobooth\Exceptions\SparkboothUploadException; use Illuminate\Http\UploadedFile; @@ -21,47 +22,52 @@ class SparkboothUploadService */ public function handleUpload(UploadedFile $media, ?string $username, ?string $password): array { - $event = $this->authenticate($username, $password); + $setting = $this->authenticate($username, $password); + $event = $setting->event; - $this->enforceExpiry($event); + if (! $event) { + throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); + } + + $this->enforceExpiry($setting); $this->enforceRateLimit($event); $this->assertValidFile($media); $importDisk = config('photobooth.import.disk', 'photobooth'); - $basePath = ltrim((string) ($event->photobooth_path ?: $this->buildPath($event)), '/'); + $basePath = ltrim((string) ($setting->path ?: $this->buildPath($event)), '/'); $extension = strtolower($media->getClientOriginalExtension() ?: $media->extension() ?: 'jpg'); $filename = Str::uuid().'.'.$extension; $relativePath = "{$basePath}/{$filename}"; - if (! $event->photobooth_path) { - $event->forceFill([ - 'photobooth_path' => $basePath, + if (! $setting->path) { + $setting->forceFill([ + 'path' => $basePath, ])->save(); } Storage::disk($importDisk)->makeDirectory($basePath); Storage::disk($importDisk)->putFileAs($basePath, $media, $filename); - $summary = $this->ingestService->ingest($event->fresh(), 1, Photo::SOURCE_SPARKBOOTH); + $summary = $this->ingestService->ingest($event->fresh('photoboothSetting'), 1, Photo::SOURCE_SPARKBOOTH); if (($summary['processed'] ?? 0) < 1) { throw new SparkboothUploadException('ingest_failed', 'Upload failed, please retry.', 500); } - $event->forceFill([ - 'sparkbooth_last_upload_at' => now(), - 'sparkbooth_uploads_last_24h' => ($event->sparkbooth_uploads_last_24h ?? 0) + 1, - 'sparkbooth_uploads_total' => ($event->sparkbooth_uploads_total ?? 0) + 1, + $setting->forceFill([ + 'last_upload_at' => now(), + 'uploads_last_24h' => ($setting->uploads_last_24h ?? 0) + 1, + 'uploads_total' => ($setting->uploads_total ?? 0) + 1, ])->save(); return [ - 'event' => $event->fresh(), + 'event' => $event->fresh('photoboothSetting'), 'processed' => $summary['processed'] ?? 0, 'skipped' => $summary['skipped'] ?? 0, ]; } - protected function authenticate(?string $username, ?string $password): Event + protected function authenticate(?string $username, ?string $password): EventPhotoboothSetting { if (! $username || ! $password) { throw new SparkboothUploadException('missing_credentials', 'Invalid credentials', 401); @@ -69,29 +75,34 @@ class SparkboothUploadService $normalizedUsername = strtolower(trim($username)); - /** @var Event|null $event */ - $event = Event::query() - ->whereRaw('LOWER(sparkbooth_username) = ?', [$normalizedUsername]) + /** @var EventPhotoboothSetting|null $setting */ + $setting = EventPhotoboothSetting::query() + ->where('username', $normalizedUsername) + ->with('event') ->first(); - if (! $event) { + if (! $setting) { throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); } - if ($event->photobooth_mode !== 'sparkbooth' || ! $event->photobooth_enabled) { + if (! $setting->event) { + throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); + } + + if ($setting->mode !== 'sparkbooth' || ! $setting->enabled) { throw new SparkboothUploadException('disabled', 'Upload not active for this event', 403); } - if (! hash_equals($event->sparkbooth_password ?? '', $password ?? '')) { + if (! hash_equals($setting->password ?? '', $password ?? '')) { throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401); } - return $event; + return $setting; } - protected function enforceExpiry(Event $event): void + protected function enforceExpiry(EventPhotoboothSetting $setting): void { - if ($event->sparkbooth_expires_at && $event->sparkbooth_expires_at->isPast()) { + if ($setting->expires_at && $setting->expires_at->isPast()) { throw new SparkboothUploadException('expired', 'Upload access has expired', 403); } } diff --git a/database/factories/EventPhotoboothSettingFactory.php b/database/factories/EventPhotoboothSettingFactory.php new file mode 100644 index 0000000..61355a5 --- /dev/null +++ b/database/factories/EventPhotoboothSettingFactory.php @@ -0,0 +1,47 @@ + + */ +class EventPhotoboothSettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'enabled' => false, + 'mode' => 'ftp', + 'status' => 'inactive', + 'uploads_last_24h' => 0, + 'uploads_total' => 0, + ]; + } + + public function activeFtp(): static + { + return $this->state(fn () => [ + 'enabled' => true, + 'mode' => 'ftp', + 'status' => 'active', + ]); + } + + public function activeSparkbooth(): static + { + return $this->state(fn () => [ + 'enabled' => true, + 'mode' => 'sparkbooth', + 'status' => 'active', + ]); + } +} diff --git a/database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php b/database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php deleted file mode 100644 index 7920614..0000000 --- a/database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php +++ /dev/null @@ -1,46 +0,0 @@ -boolean('photobooth_enabled')->default(false)->after('settings'); - $table->string('photobooth_username', 32)->nullable()->after('photobooth_enabled'); - $table->text('photobooth_password_encrypted')->nullable()->after('photobooth_username'); - $table->string('photobooth_path')->nullable()->after('photobooth_password_encrypted'); - $table->timestamp('photobooth_expires_at')->nullable()->after('photobooth_path'); - $table->string('photobooth_status', 32)->default('inactive')->after('photobooth_expires_at'); - $table->timestamp('photobooth_last_provisioned_at')->nullable()->after('photobooth_status'); - $table->timestamp('photobooth_last_deprovisioned_at')->nullable()->after('photobooth_last_provisioned_at'); - $table->json('photobooth_metadata')->nullable()->after('photobooth_last_deprovisioned_at'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('events', function (Blueprint $table) { - $table->dropColumn([ - 'photobooth_enabled', - 'photobooth_username', - 'photobooth_password_encrypted', - 'photobooth_path', - 'photobooth_expires_at', - 'photobooth_status', - 'photobooth_last_provisioned_at', - 'photobooth_last_deprovisioned_at', - 'photobooth_metadata', - ]); - }); - } -}; diff --git a/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php b/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php index f2a4504..b213ac1 100644 --- a/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php +++ b/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php @@ -16,8 +16,8 @@ return new class extends Migration $table->json('settings')->nullable()->after('max_participants'); } - $table->boolean('watermark_serve_originals')->default(false)->after('photobooth_metadata'); - $table->json('watermark_settings')->nullable()->after('photobooth_metadata'); + $table->boolean('watermark_serve_originals')->default(false)->after('settings'); + $table->json('watermark_settings')->nullable()->after('watermark_serve_originals'); }); } diff --git a/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php b/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php deleted file mode 100644 index b1715a0..0000000 --- a/database/migrations/2025_12_07_164119_add_sparkbooth_columns_to_events_table.php +++ /dev/null @@ -1,59 +0,0 @@ -string('photobooth_mode', 16) - ->default('ftp') - ->after('photobooth_enabled'); - - $table->string('sparkbooth_username', 32) - ->nullable() - ->after('photobooth_path'); - $table->text('sparkbooth_password_encrypted') - ->nullable() - ->after('sparkbooth_username'); - $table->timestamp('sparkbooth_expires_at') - ->nullable() - ->after('sparkbooth_password_encrypted'); - $table->string('sparkbooth_status', 32) - ->default('inactive') - ->after('sparkbooth_expires_at'); - $table->timestamp('sparkbooth_last_upload_at') - ->nullable() - ->after('sparkbooth_status'); - $table->unsignedInteger('sparkbooth_uploads_last_24h') - ->default(0) - ->after('sparkbooth_last_upload_at'); - $table->unsignedBigInteger('sparkbooth_uploads_total') - ->default(0) - ->after('sparkbooth_uploads_last_24h'); - - $table->unique('sparkbooth_username'); - }); - } - - public function down(): void - { - Schema::table('events', function (Blueprint $table) { - $table->dropUnique(['sparkbooth_username']); - - $table->dropColumn([ - 'photobooth_mode', - 'sparkbooth_username', - 'sparkbooth_password_encrypted', - 'sparkbooth_expires_at', - 'sparkbooth_status', - 'sparkbooth_last_upload_at', - 'sparkbooth_uploads_last_24h', - 'sparkbooth_uploads_total', - ]); - }); - } -}; diff --git a/database/migrations/2025_12_18_081047_create_event_photobooth_settings_table.php b/database/migrations/2025_12_18_081047_create_event_photobooth_settings_table.php new file mode 100644 index 0000000..a089eef --- /dev/null +++ b/database/migrations/2025_12_18_081047_create_event_photobooth_settings_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->boolean('enabled')->default(false); + $table->string('mode', 16)->default('ftp'); + $table->string('username', 32)->nullable(); + $table->text('password_encrypted')->nullable(); + $table->string('path')->nullable(); + $table->string('status', 32)->default('inactive'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_provisioned_at')->nullable(); + $table->timestamp('last_deprovisioned_at')->nullable(); + $table->timestamp('last_upload_at')->nullable(); + $table->unsignedInteger('uploads_last_24h')->default(0); + $table->unsignedBigInteger('uploads_total')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique('event_id'); + $table->unique('username'); + $table->index(['enabled', 'expires_at']); + $table->index(['enabled', 'path']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_photobooth_settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9a709e8..dc053d8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,7 +22,7 @@ class DatabaseSeeder extends Seeder EventTypesSeeder::class, EmotionsSeeder::class, TaskCollectionsSeeder::class, - InviteLayoutSeeder::class, + ]); $this->call([ diff --git a/database/seeders/EventPhotoboothSettingSeeder.php b/database/seeders/EventPhotoboothSettingSeeder.php new file mode 100644 index 0000000..70d428c --- /dev/null +++ b/database/seeders/EventPhotoboothSettingSeeder.php @@ -0,0 +1,17 @@ +create(); + } +} diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index ce1669a..1fa2a22 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -56,7 +56,7 @@ export default function GalleryPreview({ token }: Props) { arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } - return arr.slice(0, 4); // 2x2 = 4 items + return arr.slice(0, 9); // up to 3x3 preview }, [typedPhotos, mode]); React.useEffect(() => { @@ -134,7 +134,7 @@ export default function GalleryPreview({ token }: Props) { )} -
+
{items.map((p: PreviewPhoto) => ( ('new'); const taskProgress = useGuestTaskProgress(eventToken); const tasksEnabled = isTaskModeEnabled(event); const panelRef = React.useRef(null); @@ -275,8 +274,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) : 0; const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all'); - const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new'); - const [scopeFilter, setScopeFilter] = React.useState<'all' | 'uploads' | 'tips' | 'general'>('all'); + const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const pushState = usePushSubscription(eventToken); React.useEffect(() => { @@ -306,19 +304,14 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task default: base = center.notifications; } - - if (statusFilter === 'all') return base; - return base.filter((item) => item.status === (statusFilter === 'new' ? 'new' : statusFilter)); - }, [activeTab, center.notifications, unreadNotifications, uploadNotifications, statusFilter]); + return base; + }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); const scopedNotifications = React.useMemo(() => { if (scopeFilter === 'all') { return filteredNotifications; } return filteredNotifications.filter((item) => { - if (scopeFilter === 'uploads') { - return item.type === 'upload_alert' || item.type === 'photo_activity'; - } if (scopeFilter === 'tips') { return item.type === 'support_tip' || item.type === 'achievement_major'; } @@ -332,7 +325,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task type="button" onClick={onToggle} className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30" - aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')} + aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')} > {badgeCount > 0 && ( @@ -374,46 +367,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task activeTab={activeTab} onTabChange={(next) => setActiveTab(next as typeof activeTab)} /> -
- - - +
+
+ {( + [ + { key: 'all', label: t('header.notifications.scope.all', 'Alle') }, + { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, + { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, + ] as const + ).map((option) => ( + + ))} +
-
{center.loading ? ( @@ -440,26 +420,27 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task )) )}
-
-
- - {t('header.notifications.queueLabel', 'Uploads in Warteschlange')} - - {center.queueCount} + {activeTab === 'status' && ( +
+
+ + {t('header.notifications.queueLabel', 'Uploads in Warteschlange')} + {center.queueCount} +
+ { + if (center.unreadCount > 0) { + void center.refresh(); + } + }} + > + {t('header.notifications.queueCta', 'Verlauf')} + +
- { - if (center.unreadCount > 0) { - void center.refresh(); - } - }} - > - {t('header.notifications.queueCta', 'Upload-Verlauf öffnen')} - - -
+ )} {taskProgress && (
@@ -484,6 +465,12 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, task
)} +
, typeof document !== 'undefined' ? document.body : undefined )} @@ -719,7 +706,7 @@ function NotificationStatusBar({ const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied'; return ( -
+
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 37090d3..399bd57 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -322,7 +322,7 @@ export default function GalleryPage() { styleOverride={{ borderRadius: radius, fontFamily: headingFont }} /> {loading &&

{t('galleryPage.loading', 'Lade…')}

} -
+
{list.map((p: GalleryPhoto) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const createdLabel = p.created_at @@ -371,13 +371,6 @@ export default function GalleryPage() { {p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}
-
- {localizedTaskTitle && ( - - {localizedTaskTitle} - - )} -
- + +
+
+ +
+
+ +
+ {currentIndexVal + 1} / {currentPhotos.length} +
+
+
+ + +
-
- {currentIndexVal > 0 && ( - - )} - - {currentIndexVal < currentPhotos.length - 1 && ( - + {currentIndexVal > 0 && ( + + )} + {t('lightbox.photoAlt') { + console.error('Image load error:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {currentIndexVal < currentPhotos.length - 1 && ( + + )} +
+ +
+
+ + {uploaderInitial} + +
+ {photo?.uploader_name ? ( +

{photo.uploader_name}

+ ) : ( +

{t('galleryPage.photo.anonymous', 'Gast')}

+ )} + {createdLabel ?

{createdLabel}

: null} +
+
+
+ {task ? ( + + {t('lightbox.taskLabel')}: {task.title} + + ) : null} +
+ + + +
+
+
+ + {taskLoading && !task && ( +
+
+ {t('lightbox.loadingTask')} +
)}
- {/* Task Info Overlay */} - {task && ( -
-
-
{t('lightbox.taskLabel')}: {task.title}
- {taskLoading && ( -
{t('lightbox.loadingTask')}
- )} -
-
- )} - - {/* Photo Display */} -
- {t('lightbox.photoAlt') { - console.error('Image load error:', e); - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> -
- - {/* Loading state for task */} - {taskLoading && !task && ( -
-
-
-
{t('lightbox.loadingTask')}
-
-
- )} - ; export function usePollGalleryDelta(token: string, locale: LocaleCode) { @@ -60,6 +67,12 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) { const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({ ...(photo as Photo), session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null, + uploader_name: + typeof photo.uploader_name === 'string' + ? (photo.uploader_name as string) + : typeof photo.guest_name === 'string' + ? (photo.guest_name as string) + : null, })); if (newPhotos.length > 0) { diff --git a/tests/Feature/Console/PhotoboothCleanupCommandTest.php b/tests/Feature/Console/PhotoboothCleanupCommandTest.php index 749c736..b98c92f 100644 --- a/tests/Feature/Console/PhotoboothCleanupCommandTest.php +++ b/tests/Feature/Console/PhotoboothCleanupCommandTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Console; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\PhotoboothSetting; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -23,15 +24,18 @@ class PhotoboothCleanupCommandTest extends TestCase 'control_service_base_url' => 'https://control.test', ]); - $event = Event::factory()->create([ - 'photobooth_enabled' => true, - 'photobooth_username' => 'pbcleanup', - 'photobooth_status' => 'active', - 'photobooth_path' => '/photobooth/demo', - 'photobooth_expires_at' => now()->subDay(), - ]); - $event->photobooth_password = 'CLEANUP'; - $event->save(); + $event = Event::factory()->create(); + $setting = EventPhotoboothSetting::factory() + ->for($event) + ->create([ + 'enabled' => true, + 'mode' => 'ftp', + 'username' => 'pbcleanup', + 'password' => 'CLEANUP', + 'status' => 'active', + 'path' => '/photobooth/demo', + 'expires_at' => now()->subDay(), + ]); Http::fake([ 'https://control.test/*' => Http::response(['ok' => true], 200), @@ -40,11 +44,11 @@ class PhotoboothCleanupCommandTest extends TestCase $this->artisan('photobooth:cleanup-expired') ->assertExitCode(0); - $event->refresh(); + $setting->refresh(); - $this->assertFalse($event->photobooth_enabled); - $this->assertNull($event->photobooth_username); - $this->assertNotNull($event->photobooth_last_deprovisioned_at); + $this->assertFalse($setting->enabled); + $this->assertNull($setting->username); + $this->assertNotNull($setting->last_deprovisioned_at); Http::assertSent(fn ($request) => $request->url() === 'https://control.test/users/pbcleanup'); } diff --git a/tests/Feature/Photobooth/PhotoboothControllerTest.php b/tests/Feature/Photobooth/PhotoboothControllerTest.php index cb14a1a..d5cbb68 100644 --- a/tests/Feature/Photobooth/PhotoboothControllerTest.php +++ b/tests/Feature/Photobooth/PhotoboothControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Photobooth; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\PhotoboothSetting; use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; @@ -58,11 +59,13 @@ class PhotoboothControllerTest extends TenantTestCase ->assertJsonPath('data.username', fn ($value) => is_string($value) && strlen($value) <= 10); $event->refresh(); - $this->assertTrue($event->photobooth_enabled); - $this->assertNotNull($event->photobooth_username); - $this->assertNotNull($event->photobooth_password); - $username = $event->photobooth_username; - $firstPassword = $event->photobooth_password; + $setting = $event->photoboothSetting; + $this->assertNotNull($setting); + $this->assertTrue($setting->enabled); + $this->assertNotNull($setting->username); + $this->assertNotNull($setting->password); + $username = $setting->username; + $firstPassword = $setting->password; Http::assertSent(fn ($request) => $request->url() === 'https://control.test/users' && $request['username'] === $username); @@ -72,7 +75,7 @@ class PhotoboothControllerTest extends TenantTestCase ->assertJsonPath('data.enabled', true); $event->refresh(); - $this->assertNotSame($firstPassword, $event->photobooth_password); + $this->assertNotSame($firstPassword, $event->photoboothSetting?->password); Http::assertSent(fn ($request) => $request->url() === "https://control.test/users/{$username}/rotate"); } @@ -82,14 +85,18 @@ class PhotoboothControllerTest extends TenantTestCase { $event = Event::factory()->for($this->tenant)->create([ 'slug' => 'photobooth-disable', - 'photobooth_enabled' => true, - 'photobooth_username' => 'pb123456', - 'photobooth_path' => '/photobooth/demo', - 'photobooth_status' => 'active', - 'photobooth_expires_at' => now()->subDay(), ]); - $event->photobooth_password = 'SECRET12'; - $event->save(); + EventPhotoboothSetting::factory() + ->for($event) + ->create([ + 'enabled' => true, + 'mode' => 'ftp', + 'username' => 'pb123456', + 'password' => 'SECRET12', + 'path' => '/photobooth/demo', + 'status' => 'active', + 'expires_at' => now()->subDay(), + ]); Http::fake([ 'https://control.test/*' => Http::response(['ok' => true], 200), @@ -102,8 +109,8 @@ class PhotoboothControllerTest extends TenantTestCase ->assertJsonPath('data.username', null); $event->refresh(); - $this->assertFalse($event->photobooth_enabled); - $this->assertNull($event->photobooth_username); + $this->assertFalse($event->photoboothSetting?->enabled); + $this->assertNull($event->photoboothSetting?->username); Http::assertSent(fn ($request) => $request->url() === 'https://control.test/users/pb123456'); } diff --git a/tests/Feature/Photobooth/PhotoboothFilterTest.php b/tests/Feature/Photobooth/PhotoboothFilterTest.php index 8df7442..ef84308 100644 --- a/tests/Feature/Photobooth/PhotoboothFilterTest.php +++ b/tests/Feature/Photobooth/PhotoboothFilterTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Photobooth; use App\Models\Event; +use App\Models\EventPhotoboothSetting; use App\Models\Photo; use App\Models\Tenant; use App\Services\EventJoinTokenService; @@ -22,18 +23,25 @@ class PhotoboothFilterTest extends TestCase ->for($tenant) ->create([ 'status' => 'published', - 'photobooth_enabled' => true, + ]); + EventPhotoboothSetting::factory() + ->for($event) + ->create([ + 'enabled' => true, + 'mode' => 'ftp', ]); Photo::factory()->create([ 'event_id' => $event->id, 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, 'guest_name' => Photo::SOURCE_PHOTOBOOTH, + 'status' => 'approved', ]); Photo::factory()->create([ 'event_id' => $event->id, 'ingest_source' => Photo::SOURCE_GUEST_PWA, + 'status' => 'approved', ]); /** @var EventJoinTokenService $tokens */ diff --git a/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php b/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php index 77f60f2..31a6d30 100644 --- a/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php +++ b/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php @@ -6,6 +6,7 @@ use App\Jobs\ProcessPhotoSecurityScan; use App\Models\Emotion; use App\Models\Event; use App\Models\EventPackage; +use App\Models\EventPhotoboothSetting; use App\Models\MediaStorageTarget; use App\Models\Package; use App\Models\Photo; @@ -53,12 +54,15 @@ class PhotoboothIngestCommandTest extends TestCase ->create([ 'slug' => 'demo-event', 'status' => 'published', - 'photobooth_enabled' => true, ]); - $event->update([ - 'photobooth_path' => $tenant->slug.'/'.$event->id, - ]); + $setting = EventPhotoboothSetting::factory() + ->for($event) + ->create([ + 'enabled' => true, + 'mode' => 'ftp', + 'path' => $tenant->slug.'/'.$event->id, + ]); $package = Package::factory()->create(['max_photos' => 5]); EventPackage::create([ @@ -68,7 +72,8 @@ class PhotoboothIngestCommandTest extends TestCase 'used_photos' => 0, ]); - Storage::disk('photobooth')->put($event->photobooth_path.'/sample.jpg', $this->sampleImage()); + Storage::disk('photobooth')->makeDirectory($setting->path); + Storage::disk('photobooth')->put($setting->path.'/sample.jpg', $this->sampleImage()); Emotion::factory()->create(); Bus::fake(); @@ -81,7 +86,7 @@ class PhotoboothIngestCommandTest extends TestCase 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, ]); - Storage::disk('photobooth')->assertMissing($event->photobooth_path.'/sample.jpg'); + Storage::disk('photobooth')->assertMissing($setting->path.'/sample.jpg'); Bus::assertDispatched(ProcessPhotoSecurityScan::class); }