From 447a90a7422770475345313e9001327b7e6723cc Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 10 Nov 2025 16:23:09 +0100 Subject: [PATCH] added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system. --- .env.example | 22 + .../Commands/CheckUploadQueuesCommand.php | 184 +++++++++ .../DeactivateExpiredPhotoboothAccounts.php | 42 ++ .../DispatchStorageArchiveCommand.php | 106 +++++ .../Commands/MonitorStorageCommand.php | 194 +++++++++ .../Commands/PhotoboothIngestCommand.php | 51 +++ app/Console/Commands/SyncHelpCenter.php | 28 ++ .../Concerns/InteractsWithCacheLocks.php | 40 ++ .../CoolifyActionLogResource.php | 70 ++++ .../Pages/ManageCoolifyActionLogs.php | 16 + .../Pages/EditPhotoboothSetting.php | 21 + .../Pages/ListPhotoboothSettings.php | 24 ++ .../PhotoboothSettingResource.php | 78 ++++ .../Schemas/PhotoboothSettingForm.php | 60 +++ .../Schemas/PhotoboothSettingInfolist.php | 16 + .../Tables/PhotoboothSettingsTable.php | 36 ++ .../SuperAdmin/Pages/CoolifyDeployments.php | 127 ++++++ .../Widgets/CoolifyPlatformHealth.php | 62 +++ .../Controllers/Api/EventPublicController.php | 9 +- app/Http/Controllers/Api/HelpController.php | 129 ++++++ .../Api/Tenant/PhotoController.php | 36 +- .../Api/Tenant/PhotoboothController.php | 79 ++++ app/Http/Resources/Tenant/PhotoResource.php | 3 +- .../Tenant/PhotoboothStatusResource.php | 76 ++++ app/Models/CoolifyActionLog.php | 24 ++ app/Models/Event.php | 31 ++ app/Models/Photo.php | 31 +- app/Models/PhotoboothSetting.php | 49 +++ .../Filament/SuperAdminPanelProvider.php | 4 + app/Services/Coolify/CoolifyClient.php | 138 +++++++ app/Services/Help/HelpSyncService.php | 129 ++++++ .../Photobooth/ControlServiceClient.php | 75 ++++ .../Photobooth/CredentialGenerator.php | 46 +++ .../Photobooth/PhotoboothIngestService.php | 263 ++++++++++++ .../Photobooth/PhotoboothProvisioner.php | 163 ++++++++ app/Support/Help/HelpRepository.php | 54 +++ bootstrap/app.php | 7 + config/coolify.php | 11 + config/filesystems.php | 7 + config/help.php | 37 ++ config/logging.php | 8 + config/photobooth.php | 28 ++ config/storage-monitor.php | 39 +- cron/archive_dispatcher.sh | 25 +- cron/storage_monitor.sh | 26 +- cron/upload_queue_health.sh | 25 +- database/factories/PhotoFactory.php | 6 +- ...91757_create_photobooth_settings_table.php | 34 ++ ...add_photobooth_columns_to_events_table.php | 46 +++ ...4831_add_ingest_source_to_photos_table.php | 36 ++ ...0552_add_extra_columns_to_photos_table.php | 84 ++++ ...04229_create_coolify_action_logs_table.php | 33 ++ docs/deployment/coolify.md | 93 +++++ docs/deployment/docker.md | 3 +- docs/help/README.md | 81 ++++ docs/help/admin/admin-issue-resolution.de.md | 37 ++ docs/help/admin/admin-issue-resolution.en.md | 37 ++ docs/help/admin/event-prep-checklist.de.md | 38 ++ docs/help/admin/event-prep-checklist.en.md | 38 ++ docs/help/admin/index.de.md | 25 ++ docs/help/admin/index.en.md | 25 ++ docs/help/admin/live-ops-control.de.md | 39 ++ docs/help/admin/live-ops-control.en.md | 39 ++ docs/help/admin/post-event-wrapup.de.md | 33 ++ docs/help/admin/post-event-wrapup.en.md | 33 ++ .../admin/tenant-dashboard-overview.de.md | 34 ++ .../admin/tenant-dashboard-overview.en.md | 34 ++ docs/help/guest/getting-started.de.md | 36 ++ docs/help/guest/getting-started.en.md | 36 ++ docs/help/guest/index.de.md | 29 ++ docs/help/guest/index.en.md | 29 ++ docs/help/guest/offline-sync.de.md | 40 ++ docs/help/guest/offline-sync.en.md | 40 ++ docs/help/guest/privacy-and-support.de.md | 38 ++ docs/help/guest/privacy-and-support.en.md | 38 ++ docs/help/guest/uploading-photos.de.md | 39 ++ docs/help/guest/uploading-photos.en.md | 39 ++ docs/help/templates/article.de.md | 30 ++ docs/help/templates/article.en.md | 30 ++ docs/photobooth_ftp/README.md | 102 +++++ docs/photobooth_ftp/control_service.md | 97 +++++ docs/photobooth_ftp/ops_playbook.md | 53 +++ docs/queue-supervisor/README.md | 12 + public/manifest.json | 2 +- resources/js/admin/api.ts | 137 ++++++ resources/js/admin/constants.ts | 1 + resources/js/admin/i18n/locales/de/auth.json | 8 +- .../js/admin/i18n/locales/de/common.json | 2 +- .../js/admin/i18n/locales/de/dashboard.json | 30 +- .../js/admin/i18n/locales/de/management.json | 6 +- .../js/admin/i18n/locales/de/onboarding.json | 4 +- resources/js/admin/i18n/locales/en/auth.json | 8 +- .../js/admin/i18n/locales/en/common.json | 2 +- .../js/admin/i18n/locales/en/dashboard.json | 30 +- .../js/admin/i18n/locales/en/management.json | 12 +- .../js/admin/i18n/locales/en/onboarding.json | 6 +- .../js/admin/pages/EventPhotoboothPage.tsx | 389 ++++++++++++++++++ resources/js/admin/pages/FaqPage.tsx | 293 ++++++++++--- resources/js/admin/pages/SettingsPage.tsx | 4 +- resources/js/admin/router.tsx | 2 + resources/js/guest/components/FiltersBar.tsx | 4 +- .../js/guest/components/GalleryPreview.tsx | 22 +- .../js/guest/components/settings-sheet.tsx | 25 +- resources/js/guest/i18n/messages.ts | 50 +++ resources/js/guest/pages/GalleryPage.tsx | 25 +- resources/js/guest/pages/HelpArticlePage.tsx | 121 ++++++ resources/js/guest/pages/HelpCenterPage.tsx | 153 +++++++ .../js/guest/polling/usePollGalleryDelta.ts | 7 +- resources/js/guest/router.tsx | 24 ++ resources/js/guest/services/helpApi.ts | 160 +++++++ .../pages/coolify-deployments.blade.php | 69 ++++ .../widgets/coolify-platform-health.blade.php | 48 +++ routes/api.php | 12 + routes/console.php | 12 - tests/Feature/Api/HelpControllerTest.php | 59 +++ .../Console/CheckUploadQueuesCommandTest.php | 78 ++++ .../DispatchStorageArchiveCommandTest.php | 97 +++++ tests/Feature/Console/HelpSyncCommandTest.php | 23 ++ .../Console/MonitorStorageCommandTest.php | 78 ++++ .../Console/PhotoboothCleanupCommandTest.php | 51 +++ .../Photobooth/PhotoboothControllerTest.php | 110 +++++ .../Photobooth/PhotoboothFilterTest.php | 49 +++ .../PhotoboothIngestCommandTest.php | 93 +++++ 123 files changed, 6398 insertions(+), 153 deletions(-) create mode 100644 app/Console/Commands/CheckUploadQueuesCommand.php create mode 100644 app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php create mode 100644 app/Console/Commands/DispatchStorageArchiveCommand.php create mode 100644 app/Console/Commands/MonitorStorageCommand.php create mode 100644 app/Console/Commands/PhotoboothIngestCommand.php create mode 100644 app/Console/Commands/SyncHelpCenter.php create mode 100644 app/Console/Concerns/InteractsWithCacheLocks.php create mode 100644 app/Filament/Resources/CoolifyActionLogs/CoolifyActionLogResource.php create mode 100644 app/Filament/Resources/CoolifyActionLogs/Pages/ManageCoolifyActionLogs.php create mode 100644 app/Filament/Resources/PhotoboothSettings/Pages/EditPhotoboothSetting.php create mode 100644 app/Filament/Resources/PhotoboothSettings/Pages/ListPhotoboothSettings.php create mode 100644 app/Filament/Resources/PhotoboothSettings/PhotoboothSettingResource.php create mode 100644 app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingForm.php create mode 100644 app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingInfolist.php create mode 100644 app/Filament/Resources/PhotoboothSettings/Tables/PhotoboothSettingsTable.php create mode 100644 app/Filament/SuperAdmin/Pages/CoolifyDeployments.php create mode 100644 app/Filament/Widgets/CoolifyPlatformHealth.php create mode 100644 app/Http/Controllers/Api/HelpController.php create mode 100644 app/Http/Controllers/Api/Tenant/PhotoboothController.php create mode 100644 app/Http/Resources/Tenant/PhotoboothStatusResource.php create mode 100644 app/Models/CoolifyActionLog.php create mode 100644 app/Models/PhotoboothSetting.php create mode 100644 app/Services/Coolify/CoolifyClient.php create mode 100644 app/Services/Help/HelpSyncService.php create mode 100644 app/Services/Photobooth/ControlServiceClient.php create mode 100644 app/Services/Photobooth/CredentialGenerator.php create mode 100644 app/Services/Photobooth/PhotoboothIngestService.php create mode 100644 app/Services/Photobooth/PhotoboothProvisioner.php create mode 100644 app/Support/Help/HelpRepository.php create mode 100644 config/coolify.php create mode 100644 config/help.php create mode 100644 config/photobooth.php create mode 100644 database/migrations/2025_11_10_091757_create_photobooth_settings_table.php create mode 100644 database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php create mode 100644 database/migrations/2025_11_10_094831_add_ingest_source_to_photos_table.php create mode 100644 database/migrations/2025_11_10_100552_add_extra_columns_to_photos_table.php create mode 100644 database/migrations/2025_11_10_104229_create_coolify_action_logs_table.php create mode 100644 docs/deployment/coolify.md create mode 100644 docs/help/README.md create mode 100644 docs/help/admin/admin-issue-resolution.de.md create mode 100644 docs/help/admin/admin-issue-resolution.en.md create mode 100644 docs/help/admin/event-prep-checklist.de.md create mode 100644 docs/help/admin/event-prep-checklist.en.md create mode 100644 docs/help/admin/index.de.md create mode 100644 docs/help/admin/index.en.md create mode 100644 docs/help/admin/live-ops-control.de.md create mode 100644 docs/help/admin/live-ops-control.en.md create mode 100644 docs/help/admin/post-event-wrapup.de.md create mode 100644 docs/help/admin/post-event-wrapup.en.md create mode 100644 docs/help/admin/tenant-dashboard-overview.de.md create mode 100644 docs/help/admin/tenant-dashboard-overview.en.md create mode 100644 docs/help/guest/getting-started.de.md create mode 100644 docs/help/guest/getting-started.en.md create mode 100644 docs/help/guest/index.de.md create mode 100644 docs/help/guest/index.en.md create mode 100644 docs/help/guest/offline-sync.de.md create mode 100644 docs/help/guest/offline-sync.en.md create mode 100644 docs/help/guest/privacy-and-support.de.md create mode 100644 docs/help/guest/privacy-and-support.en.md create mode 100644 docs/help/guest/uploading-photos.de.md create mode 100644 docs/help/guest/uploading-photos.en.md create mode 100644 docs/help/templates/article.de.md create mode 100644 docs/help/templates/article.en.md create mode 100644 docs/photobooth_ftp/README.md create mode 100644 docs/photobooth_ftp/control_service.md create mode 100644 docs/photobooth_ftp/ops_playbook.md create mode 100644 resources/js/admin/pages/EventPhotoboothPage.tsx create mode 100644 resources/js/guest/pages/HelpArticlePage.tsx create mode 100644 resources/js/guest/pages/HelpCenterPage.tsx create mode 100644 resources/js/guest/services/helpApi.ts create mode 100644 resources/views/filament/super-admin/pages/coolify-deployments.blade.php create mode 100644 resources/views/filament/widgets/coolify-platform-health.blade.php create mode 100644 tests/Feature/Api/HelpControllerTest.php create mode 100644 tests/Feature/Console/CheckUploadQueuesCommandTest.php create mode 100644 tests/Feature/Console/DispatchStorageArchiveCommandTest.php create mode 100644 tests/Feature/Console/HelpSyncCommandTest.php create mode 100644 tests/Feature/Console/MonitorStorageCommandTest.php create mode 100644 tests/Feature/Console/PhotoboothCleanupCommandTest.php create mode 100644 tests/Feature/Photobooth/PhotoboothControllerTest.php create mode 100644 tests/Feature/Photobooth/PhotoboothFilterTest.php create mode 100644 tests/Feature/Photobooth/PhotoboothIngestCommandTest.php diff --git a/.env.example b/.env.example index 66bb372..b3ab393 100644 --- a/.env.example +++ b/.env.example @@ -116,4 +116,26 @@ SECURITY_AV_TIMEOUT=60 SECURITY_STRIP_EXIF=true SECURITY_SCAN_QUEUE=media-security +# Photobooth / FTP ingestion +PHOTOBOOTH_CONTROL_BASE_URL= +PHOTOBOOTH_CONTROL_TOKEN= +PHOTOBOOTH_CONTROL_TIMEOUT=5 +PHOTOBOOTH_FTP_HOST=ftp.internal +PHOTOBOOTH_FTP_PORT=2121 +PHOTOBOOTH_USERNAME_PREFIX=pb +PHOTOBOOTH_USERNAME_LENGTH=8 +PHOTOBOOTH_PASSWORD_LENGTH=8 +PHOTOBOOTH_RATE_LIMIT_PER_MINUTE=20 +PHOTOBOOTH_EXPIRY_GRACE_DAYS=1 +PHOTOBOOTH_IMPORT_DISK=photobooth +PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth +PHOTOBOOTH_IMPORT_MAX_FILES=50 +PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp + +COOLIFY_API_BASE_URL= +COOLIFY_API_TOKEN= +COOLIFY_WEB_URL= +COOLIFY_API_TIMEOUT=5 +COOLIFY_SERVICE_IDS={"app":"svc_app","queue":"svc_queue","scheduler":"svc_scheduler","ftp":"svc_ftp","control":"svc_control"} + diff --git a/app/Console/Commands/CheckUploadQueuesCommand.php b/app/Console/Commands/CheckUploadQueuesCommand.php new file mode 100644 index 0000000..8a80231 --- /dev/null +++ b/app/Console/Commands/CheckUploadQueuesCommand.php @@ -0,0 +1,184 @@ +acquireCommandLock('storage:queue-health', $lockSeconds, (bool) $this->option('force')); + + if ($lock === false) { + $this->warn('Queue health check already running.'); + + return self::SUCCESS; + } + + $connection = config('queue.default'); + $thresholds = config('storage-monitor.queue_health.thresholds', []); + if (empty($thresholds)) { + $thresholds = [ + 'default' => ['warning' => 100, 'critical' => 300], + 'media-storage' => ['warning' => 200, 'critical' => 500], + 'media-security' => ['warning' => 50, 'critical' => 150], + ]; + } + + try { + $queueSummaries = []; + $alerts = []; + + foreach ($thresholds as $queueName => $limits) { + $size = $this->readQueueSize($queueManager, $connection, (string) $queueName); + $failed = $this->countFailedJobs((string) $queueName); + $severity = $this->determineQueueSeverity($size, $limits); + + if ($severity !== 'ok') { + $alerts[] = [ + 'queue' => $queueName, + 'type' => 'size', + 'severity' => $severity, + 'size' => $size, + ]; + } + + if ($failed > 0) { + $alerts[] = [ + 'queue' => $queueName, + 'type' => 'failed_jobs', + 'severity' => $failed >= 10 ? 'critical' : 'warning', + 'failed' => $failed, + ]; + } + + $queueSummaries[] = [ + 'queue' => $queueName, + 'size' => $size, + 'failed' => $failed, + 'severity' => $severity, + 'limits' => $limits, + ]; + } + + $stalledMinutes = max(0, (int) config('storage-monitor.queue_health.stalled_minutes', 10)); + $stalledAssets = 0; + if ($stalledMinutes > 0) { + $stalledAssets = EventMediaAsset::query() + ->where('status', 'pending') + ->where('created_at', '<=', now()->subMinutes($stalledMinutes)) + ->count(); + + if ($stalledAssets > 0) { + $alerts[] = [ + 'type' => 'pending_assets', + 'severity' => 'warning', + 'older_than_minutes' => $stalledMinutes, + 'count' => $stalledAssets, + ]; + } + } + + $snapshot = [ + 'generated_at' => now()->toIso8601String(), + 'connection' => $connection, + 'queues' => $queueSummaries, + 'alerts' => $alerts, + 'stalled_assets' => $stalledAssets, + ]; + + $cacheTtl = max(1, (int) config('storage-monitor.queue_health.cache_minutes', 10)); + Cache::put('storage:queue-health:last', $snapshot, now()->addMinutes($cacheTtl)); + + Log::channel('storage-jobs')->info('Upload queue health snapshot generated', [ + 'queues' => count($queueSummaries), + 'alerts' => count($alerts), + ]); + + $this->info(sprintf( + 'Checked %d queue(s); %d alert(s).', + count($queueSummaries), + count($alerts) + )); + + return self::SUCCESS; + } finally { + if ($lock instanceof Lock) { + $lock->release(); + } + } + } + + private function readQueueSize(QueueManager $manager, ?string $connection, string $queue): int + { + try { + return $manager->connection($connection)->size($queue); + } catch (\Throwable $exception) { + Log::channel('storage-jobs')->warning('Unable to read queue size', [ + 'queue' => $queue, + 'connection' => $connection, + 'message' => $exception->getMessage(), + ]); + + return -1; + } + } + + private function countFailedJobs(string $queue): int + { + $table = config('queue.failed.table', 'failed_jobs'); + + if (! $this->failedJobsTableExists($table)) { + return 0; + } + + return (int) DB::table($table)->where('queue', $queue)->count(); + } + + private function failedJobsTableExists(string $table): bool + { + static $cache = []; + + if (array_key_exists($table, $cache)) { + return $cache[$table]; + } + + return $cache[$table] = Schema::hasTable($table); + } + + private function determineQueueSeverity(int $size, array $limits): string + { + if ($size < 0) { + return 'unknown'; + } + + $critical = (int) ($limits['critical'] ?? 0); + $warning = (int) ($limits['warning'] ?? 0); + + if ($critical > 0 && $size >= $critical) { + return 'critical'; + } + + if ($warning > 0 && $size >= $warning) { + return 'warning'; + } + + return 'ok'; + } +} diff --git a/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php b/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php new file mode 100644 index 0000000..188b225 --- /dev/null +++ b/app/Console/Commands/DeactivateExpiredPhotoboothAccounts.php @@ -0,0 +1,42 @@ +where('photobooth_enabled', true) + ->whereNotNull('photobooth_expires_at') + ->where('photobooth_expires_at', '<=', now()) + ->chunkById(50, function ($events) use (&$total, $provisioner) { + foreach ($events as $event) { + try { + $provisioner->disable($event); + $total++; + } catch (\Throwable $exception) { + Log::error('Failed to disable expired photobooth account', [ + 'event_id' => $event->id, + 'message' => $exception->getMessage(), + ]); + } + } + }); + + $this->info(sprintf('Photobooth cleanup complete (%d accounts disabled).', $total)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/DispatchStorageArchiveCommand.php b/app/Console/Commands/DispatchStorageArchiveCommand.php new file mode 100644 index 0000000..c3d57c5 --- /dev/null +++ b/app/Console/Commands/DispatchStorageArchiveCommand.php @@ -0,0 +1,106 @@ +acquireCommandLock('storage:archive-dispatcher', $lockSeconds, (bool) $this->option('force')); + + if ($lock === false) { + $this->warn('Another archive dispatcher run is already executing.'); + + return self::SUCCESS; + } + + $eventLockTtl = (int) config('storage-monitor.archive.event_lock_seconds', 3600); + $graceDays = max(0, (int) config('storage-monitor.archive.grace_days', 3)); + $cutoff = now()->subDays($graceDays); + $chunkSize = max(1, (int) config('storage-monitor.archive.chunk', 25)); + $maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100)); + $eventId = $this->option('event'); + $dispatched = 0; + + try { + $query = Event::query() + ->with('eventPackages:id,event_id,gallery_expires_at') + ->whereHas('mediaAssets', function ($builder) { + $builder->where('status', '!=', 'archived'); + }); + + if ($eventId) { + $query->whereKey($eventId); + } else { + $query->where(function ($builder) use ($cutoff) { + $builder->where('status', 'archived') + ->orWhereHas('eventPackages', function ($packages) use ($cutoff) { + $packages->whereNotNull('gallery_expires_at') + ->where('gallery_expires_at', '<=', $cutoff); + }); + }); + } + + $query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) { + foreach ($events as $event) { + if ($dispatched >= $maxDispatch) { + return false; + } + + $eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl); + if ($eventLock === false) { + Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [ + 'event_id' => $event->id, + ]); + + continue; + } + + try { + ArchiveEventMediaAssets::dispatch($event->id); + $dispatched++; + + Log::channel('storage-jobs')->info('Archive job dispatched', [ + 'event_id' => $event->id, + 'queue' => 'media-storage', + ]); + } finally { + if ($eventLock instanceof Lock) { + $eventLock->release(); + } + } + } + + return null; + }); + + $this->info(sprintf('Dispatched %d archive job(s).', $dispatched)); + Log::channel('storage-jobs')->info('Archive dispatch run finished', [ + 'dispatched' => $dispatched, + 'event_limit' => $eventId, + ]); + + return self::SUCCESS; + } finally { + if ($lock instanceof Lock) { + $lock->release(); + } + } + } +} diff --git a/app/Console/Commands/MonitorStorageCommand.php b/app/Console/Commands/MonitorStorageCommand.php new file mode 100644 index 0000000..ec7cda9 --- /dev/null +++ b/app/Console/Commands/MonitorStorageCommand.php @@ -0,0 +1,194 @@ +acquireCommandLock('storage:monitor', $lockSeconds, (bool) $this->option('force')); + + if ($lock === false) { + $this->warn('storage:monitor is already running on another worker.'); + + return self::SUCCESS; + } + + try { + $targets = MediaStorageTarget::active()->get(); + + if ($targets->isEmpty()) { + $this->info('No media storage targets available.'); + + return self::SUCCESS; + } + + $assetStats = $this->buildAssetStatistics(); + $thresholds = $this->capacityThresholds(); + $alerts = []; + $snapshotTargets = []; + + foreach ($targets as $target) { + $capacity = $this->storageHealth->getCapacity($target); + $assets = $assetStats[$target->id] ?? [ + 'total' => 0, + 'bytes' => 0, + 'by_status' => [], + ]; + + $severity = $this->determineCapacitySeverity($capacity, $thresholds); + if ($severity !== 'ok') { + $alerts[] = [ + 'target' => $target->key, + 'type' => 'capacity', + 'severity' => $severity, + 'percentage' => $capacity['percentage'] ?? null, + 'status' => $capacity['status'] ?? null, + ]; + } + + $failedCount = $assets['by_status']['failed']['count'] ?? 0; + if ($failedCount > 0) { + $alerts[] = [ + 'target' => $target->key, + 'type' => 'failed_assets', + 'severity' => 'warning', + 'failed' => $failedCount, + ]; + } + + $snapshotTargets[] = [ + 'id' => $target->id, + 'key' => $target->key, + 'name' => $target->name, + 'is_hot' => (bool) $target->is_hot, + 'capacity' => $capacity, + 'assets' => $assets, + ]; + } + + $snapshot = [ + 'generated_at' => now()->toIso8601String(), + 'targets' => $snapshotTargets, + 'alerts' => $alerts, + ]; + + $ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15)); + Cache::put('storage:monitor:last', $snapshot, now()->addMinutes($ttlMinutes)); + + Log::channel('storage-jobs')->info('Storage monitor snapshot generated', [ + 'targets' => count($snapshotTargets), + 'alerts' => count($alerts), + ]); + + $this->info(sprintf( + 'Storage monitor finished: %d targets, %d alerts.', + count($snapshotTargets), + count($alerts) + )); + + return self::SUCCESS; + } finally { + if ($lock instanceof Lock) { + $lock->release(); + } + } + } + + /** + * @return array + */ + private function buildAssetStatistics(): array + { + return EventMediaAsset::query() + ->selectRaw('media_storage_target_id, status, COUNT(*) as total_count, COALESCE(SUM(size_bytes), 0) as total_bytes') + ->groupBy('media_storage_target_id', 'status') + ->get() + ->groupBy('media_storage_target_id') + ->map(function ($rows) { + $byStatus = []; + $totalCount = 0; + $totalBytes = 0; + + foreach ($rows as $row) { + $count = (int) ($row->total_count ?? 0); + $bytes = (int) ($row->total_bytes ?? 0); + + $totalCount += $count; + $totalBytes += $bytes; + $byStatus[$row->status] = [ + 'count' => $count, + 'bytes' => $bytes, + ]; + } + + ksort($byStatus); + + return [ + 'total' => $totalCount, + 'bytes' => $totalBytes, + 'by_status' => $byStatus, + ]; + }) + ->all(); + } + + private function capacityThresholds(): array + { + $warning = (int) config('storage-monitor.capacity_thresholds.warning', 75); + $critical = (int) config('storage-monitor.capacity_thresholds.critical', 90); + + if ($warning > $critical) { + [$warning, $critical] = [$critical, $warning]; + } + + return [ + 'warning' => $warning, + 'critical' => $critical, + ]; + } + + private function determineCapacitySeverity(array $capacity, array $thresholds): string + { + $status = $capacity['status'] ?? 'ok'; + if ($status !== 'ok') { + return $status; + } + + $percentage = $capacity['percentage'] ?? null; + if ($percentage === null) { + return 'unknown'; + } + + if ($percentage >= ($thresholds['critical'] ?? 95)) { + return 'critical'; + } + + if ($percentage >= ($thresholds['warning'] ?? 75)) { + return 'warning'; + } + + return 'ok'; + } +} diff --git a/app/Console/Commands/PhotoboothIngestCommand.php b/app/Console/Commands/PhotoboothIngestCommand.php new file mode 100644 index 0000000..36dd9c6 --- /dev/null +++ b/app/Console/Commands/PhotoboothIngestCommand.php @@ -0,0 +1,51 @@ +option('event'); + $maxFiles = $this->option('max-files'); + $processedTotal = 0; + $skippedTotal = 0; + + $query = Event::query() + ->where('photobooth_enabled', true) + ->whereNotNull('photobooth_path'); + + if ($eventId) { + $query->whereKey($eventId); + } + + $query->chunkById(25, function ($events) use ($ingestService, $maxFiles, &$processedTotal, &$skippedTotal) { + foreach ($events as $event) { + $summary = $ingestService->ingest($event, $maxFiles ? (int) $maxFiles : null); + $processedTotal += $summary['processed'] ?? 0; + $skippedTotal += $summary['skipped'] ?? 0; + + $this->line(sprintf( + 'Event #%d (%s): %d imported, %d skipped', + $event->id, + $event->slug ?? 'event', + $summary['processed'] ?? 0, + $summary['skipped'] ?? 0 + )); + } + }); + + $this->info(sprintf('Photobooth ingest finished. Processed: %d, Skipped: %d', $processedTotal, $skippedTotal)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncHelpCenter.php b/app/Console/Commands/SyncHelpCenter.php new file mode 100644 index 0000000..e83a0f1 --- /dev/null +++ b/app/Console/Commands/SyncHelpCenter.php @@ -0,0 +1,28 @@ +sync(); + + foreach ($result as $audience => $locales) { + foreach ($locales as $locale => $count) { + $this->components->info(sprintf('Synced %d %s/%s articles', $count, $audience, $locale)); + } + } + + $this->components->success('Help center cache updated.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Concerns/InteractsWithCacheLocks.php b/app/Console/Concerns/InteractsWithCacheLocks.php new file mode 100644 index 0000000..3c135f3 --- /dev/null +++ b/app/Console/Concerns/InteractsWithCacheLocks.php @@ -0,0 +1,40 @@ +forceRelease(); + } + + if ($lock->get()) { + return $lock; + } + + return false; + } catch (\BadMethodCallException $exception) { + Log::channel('storage-jobs')->debug('Cache store does not support locks for command', [ + 'lock' => $name, + 'store' => config('cache.default'), + 'exception' => $exception->getMessage(), + ]); + + return null; + } + } +} diff --git a/app/Filament/Resources/CoolifyActionLogs/CoolifyActionLogResource.php b/app/Filament/Resources/CoolifyActionLogs/CoolifyActionLogResource.php new file mode 100644 index 0000000..8477a0c --- /dev/null +++ b/app/Filament/Resources/CoolifyActionLogs/CoolifyActionLogResource.php @@ -0,0 +1,70 @@ +columns([ + Tables\Columns\TextColumn::make('created_at') + ->label('Timestamp') + ->sortable() + ->dateTime(), + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('service_id') + ->label('Service') + ->searchable() + ->copyable() + ->limit(30), + Tables\Columns\BadgeColumn::make('action') + ->label('Action') + ->colors([ + 'warning' => 'restart', + 'info' => 'redeploy', + 'gray' => 'logs', + ]) + ->sortable(), + Tables\Columns\TextColumn::make('status_code') + ->label('HTTP') + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + Tables\Actions\ViewAction::make(), + ]) + ->toolbarActions([ + // + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ManageCoolifyActionLogs::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/CoolifyActionLogs/Pages/ManageCoolifyActionLogs.php b/app/Filament/Resources/CoolifyActionLogs/Pages/ManageCoolifyActionLogs.php new file mode 100644 index 0000000..d5c07a2 --- /dev/null +++ b/app/Filament/Resources/CoolifyActionLogs/Pages/ManageCoolifyActionLogs.php @@ -0,0 +1,16 @@ +getResource()::getUrl('index'); + } +} diff --git a/app/Filament/Resources/PhotoboothSettings/Pages/ListPhotoboothSettings.php b/app/Filament/Resources/PhotoboothSettings/Pages/ListPhotoboothSettings.php new file mode 100644 index 0000000..6847915 --- /dev/null +++ b/app/Filament/Resources/PhotoboothSettings/Pages/ListPhotoboothSettings.php @@ -0,0 +1,24 @@ + ListPhotoboothSettings::route('/'), + 'edit' => EditPhotoboothSetting::route('/{record}/edit'), + ]; + } + + public static function canCreate(?Model $record = null): bool + { + return false; + } + + public static function canDelete(?Model $record = null): bool + { + return false; + } + + public static function canDeleteAny(): bool + { + return false; + } +} diff --git a/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingForm.php b/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingForm.php new file mode 100644 index 0000000..eda985a --- /dev/null +++ b/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingForm.php @@ -0,0 +1,60 @@ +components([ + Section::make(__('FTP-Verbindung')) + ->description(__('Globale Parameter für den vsftpd-Container.')) + ->schema([ + TextInput::make('ftp_port') + ->numeric() + ->required() + ->minValue(1) + ->maxValue(65535) + ->helperText(__('Standard: Port 2121 innerhalb des internen Netzwerks.')), + TextInput::make('rate_limit_per_minute') + ->label(__('Uploads pro Minute')) + ->numeric() + ->required() + ->minValue(1) + ->maxValue(200) + ->helperText(__('Harte Rate-Limits für Photobooth-Clients.')), + TextInput::make('expiry_grace_days') + ->label(__('Ablauf (Tage nach Eventende)')) + ->numeric() + ->required() + ->minValue(0) + ->maxValue(14), + ])->columns(3), + Section::make(__('Sicherheit & Steuerung')) + ->schema([ + Toggle::make('require_ftps') + ->label(__('FTPS erzwingen')) + ->helperText(__('Aktivieren, wenn nur verschlüsselte FTP-Verbindungen erlaubt sein sollen.')), + TagsInput::make('allowed_ip_ranges') + ->label(__('Erlaubte IP-Ranges (optional)')) + ->placeholder('10.0.0.0/24') + ->helperText(__('Liste optionaler CIDR-Ranges für Control-Service Allowlisting.')), + TextInput::make('control_service_base_url') + ->label(__('Control-Service URL')) + ->url() + ->maxLength(191) + ->helperText(__('REST-Endpunkt des Provisioning-Sidecars (z. B. http://control:8080).')), + TextInput::make('control_service_token_identifier') + ->label(__('Token Referenz')) + ->maxLength(191) + ->helperText(__('Bezeichner des Secrets im Secrets-Store (keine Klartext-Tokens speichern).')), + ])->columns(2), + ]); + } +} diff --git a/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingInfolist.php b/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingInfolist.php new file mode 100644 index 0000000..08fbaec --- /dev/null +++ b/app/Filament/Resources/PhotoboothSettings/Schemas/PhotoboothSettingInfolist.php @@ -0,0 +1,16 @@ +components([ + // + ]); + } +} diff --git a/app/Filament/Resources/PhotoboothSettings/Tables/PhotoboothSettingsTable.php b/app/Filament/Resources/PhotoboothSettings/Tables/PhotoboothSettingsTable.php new file mode 100644 index 0000000..b57c036 --- /dev/null +++ b/app/Filament/Resources/PhotoboothSettings/Tables/PhotoboothSettingsTable.php @@ -0,0 +1,36 @@ +columns([ + Tables\Columns\TextColumn::make('ftp_port') + ->label(__('Port')) + ->sortable(), + Tables\Columns\TextColumn::make('rate_limit_per_minute') + ->label(__('Uploads/Minute')) + ->sortable(), + Tables\Columns\TextColumn::make('expiry_grace_days') + ->label(__('Ablauf +Tage')) + ->sortable(), + Tables\Columns\IconColumn::make('require_ftps') + ->label(__('FTPS')) + ->boolean(), + Tables\Columns\TextColumn::make('updated_at') + ->since() + ->label(__('Aktualisiert')), + ]) + ->recordActions([ + Tables\Actions\EditAction::make(), + ]) + ->headerActions([]) + ->bulkActions([]); + } +} diff --git a/app/Filament/SuperAdmin/Pages/CoolifyDeployments.php b/app/Filament/SuperAdmin/Pages/CoolifyDeployments.php new file mode 100644 index 0000000..6247ee0 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/CoolifyDeployments.php @@ -0,0 +1,127 @@ +coolifyWebUrl = config('coolify.web_url'); + $this->refreshServices($client); + $this->refreshLogs(); + } + + public function restart(string $serviceId): void + { + $this->performAction($serviceId, 'restart'); + } + + public function redeploy(string $serviceId): void + { + $this->performAction($serviceId, 'redeploy'); + } + + protected function performAction(string $serviceId, string $action): void + { + $client = app(CoolifyClient::class); + + if (! $this->isKnownService($serviceId)) { + Notification::make() + ->danger() + ->title('Unknown service') + ->body("The service ID {$serviceId} is not configured.") + ->send(); + + return; + } + + try { + $action === 'restart' + ? $client->restartService($serviceId, auth()->user()) + : $client->redeployService($serviceId, auth()->user()); + + Notification::make() + ->success() + ->title(ucfirst($action).' requested') + ->body("Coolify accepted the {$action} action for {$serviceId}.") + ->send(); + } catch (\Throwable $exception) { + Notification::make() + ->danger() + ->title('Coolify request failed') + ->body($exception->getMessage()) + ->send(); + } + + $this->refreshServices($client); + $this->refreshLogs(); + } + + protected function refreshServices(CoolifyClient $client): void + { + $serviceMap = config('coolify.services', []); + $results = []; + + foreach ($serviceMap as $label => $id) { + try { + $status = $client->serviceStatus($id); + $results[] = [ + 'label' => ucfirst($label), + 'service_id' => $id, + 'status' => Arr::get($status, 'data.status', 'unknown'), + ]; + } catch (\Throwable $e) { + $results[] = [ + 'label' => ucfirst($label), + 'service_id' => $id, + 'status' => 'error', + ]; + } + } + + $this->services = $results; + } + + protected function refreshLogs(): void + { + $this->recentLogs = CoolifyActionLog::query() + ->with('user') + ->latest() + ->limit(5) + ->get() + ->map(fn ($log) => [ + 'created_at' => $log->created_at->diffForHumans(), + 'user' => $log->user?->name ?? 'System', + 'service_id' => $log->service_id, + 'action' => $log->action, + 'status_code' => $log->status_code, + ]) + ->toArray(); + } + + protected function isKnownService(string $serviceId): bool + { + return in_array($serviceId, array_values(config('coolify.services', [])), true); + } +} diff --git a/app/Filament/Widgets/CoolifyPlatformHealth.php b/app/Filament/Widgets/CoolifyPlatformHealth.php new file mode 100644 index 0000000..fc8fa6f --- /dev/null +++ b/app/Filament/Widgets/CoolifyPlatformHealth.php @@ -0,0 +1,62 @@ + $this->loadServices(), + ]; + } + + protected function loadServices(): array + { + $client = app(CoolifyClient::class); + $serviceMap = config('coolify.services', []); + $results = []; + + foreach ($serviceMap as $label => $serviceId) { + try { + $status = $client->serviceStatus($serviceId); + $results[] = [ + 'label' => ucfirst($label), + 'service_id' => $serviceId, + 'status' => Arr::get($status, 'data.status', 'unknown'), + 'cpu' => Arr::get($status, 'data.metrics.cpu_percent'), + 'memory' => Arr::get($status, 'data.metrics.memory_percent'), + 'last_deploy' => Arr::get($status, 'data.last_deployment.finished_at'), + ]; + } catch (\Throwable $exception) { + $results[] = [ + 'label' => ucfirst($label), + 'service_id' => $serviceId, + 'status' => 'unreachable', + 'error' => $exception->getMessage(), + ]; + } + } + + if (empty($results)) { + return [ + [ + 'label' => 'Coolify', + 'service_id' => '-', + 'status' => 'unconfigured', + 'error' => 'Set COOLIFY_SERVICE_IDS in .env to enable monitoring.', + ], + ]; + } + + return $results; + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 51b2187..f2d0aab 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1251,6 +1251,8 @@ class EventPublicController extends BaseController 'photos.emotion_id', 'photos.task_id', 'photos.guest_name', + 'photos.created_at', + 'photos.ingest_source', 'tasks.title as task_title', ]) ->where('photos.event_id', $eventId) @@ -1258,7 +1260,9 @@ class EventPublicController extends BaseController ->limit(60); // MyPhotos filter - if ($filter === 'myphotos' && $deviceId !== 'anon') { + if ($filter === 'photobooth') { + $query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH); + } elseif ($filter === 'myphotos' && $deviceId !== 'anon') { $query->where('guest_name', $deviceId); } @@ -1276,6 +1280,8 @@ class EventPublicController extends BaseController $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); } + $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN; + return $r; }); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); @@ -1495,6 +1501,7 @@ class EventPublicController extends BaseController 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, + 'ingest_source' => Photo::SOURCE_GUEST_PWA, // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), diff --git a/app/Http/Controllers/Api/HelpController.php b/app/Http/Controllers/Api/HelpController.php new file mode 100644 index 0000000..5bd95a7 --- /dev/null +++ b/app/Http/Controllers/Api/HelpController.php @@ -0,0 +1,129 @@ +resolveContext($request); + + $articles = $this->getArticles($audience, $locale) + ->map(fn ($article) => Arr::only($article, config('help.list_fields'))) + ->values(); + + return response()->json([ + 'data' => $articles, + ]); + } + + public function show(Request $request, string $slug): JsonResponse + { + [$audience, $locale] = $this->resolveContext($request); + + $article = $this->getArticle($audience, $locale, $slug); + + abort_if(! $article, 404, 'Help article not found.'); + + return response()->json([ + 'data' => $article, + ]); + } + + /** + * @return array{string, string} + */ + private function resolveContext(Request $request): array + { + $this->attemptTokenAuthentication($request); + + $audience = Str::of($request->string('audience', 'guest'))->lower()->value(); + $locale = Str::of($request->string('locale', config('help.default_locale')))->lower()->value(); + + if ($audience === 'admin' && ! $request->user()) { + abort(401, 'Authentication required for admin help content.'); + } + + if (! in_array($audience, config('help.audiences', []), true)) { + abort(400, 'Invalid audience supplied.'); + } + + return [$audience, $locale]; + } + + private function attemptTokenAuthentication(Request $request): void + { + if ($request->user()) { + return; + } + + $bearer = $request->bearerToken(); + + if (! $bearer) { + return; + } + + $token = PersonalAccessToken::findToken($bearer); + + if (! $token) { + return; + } + + $user = $token->tokenable; + + if (! $user) { + return; + } + + if (method_exists($user, 'withAccessToken')) { + $user->withAccessToken($token); + } + + Auth::setUser($user); + $request->setUserResolver(fn () => $user); + } + + private function getArticles(string $audience, string $locale) + { + try { + return $this->repository->list($audience, $locale); + } catch (RuntimeException $e) { + $fallback = config('help.fallback_locale'); + + if ($locale === $fallback) { + throw $e; + } + + return $this->repository->list($audience, $fallback); + } + } + + private function getArticle(string $audience, string $locale, string $slug): ?array + { + try { + $article = $this->repository->find($audience, $locale, $slug); + } catch (RuntimeException $e) { + $fallback = config('help.fallback_locale'); + + if ($locale !== $fallback) { + return $this->repository->find($audience, $fallback, $slug); + } + + throw $e; + } + + return $article; + } +} diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index ca49bbb..99e2f11 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -153,14 +153,12 @@ class PhotoController extends Controller $thumbnailPath = $thumbnailRelative; } - // Create photo record - $photo = Photo::create([ + $photoAttributes = [ 'event_id' => $event->id, - 'filename' => $filename, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), - 'path' => $path, + 'file_path' => $path, 'thumbnail_path' => $thumbnailPath, 'width' => null, // Filled below 'height' => null, @@ -168,7 +166,17 @@ class PhotoController extends Controller 'uploader_id' => null, 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - ]); + 'ingest_source' => Photo::SOURCE_TENANT_ADMIN, + ]; + + if (Photo::supportsFilenameColumn()) { + $photoAttributes['filename'] = $filename; + } + if (Photo::hasColumn('path')) { + $photoAttributes['path'] = $path; + } + + $photo = Photo::create($photoAttributes); // Record primary asset metadata $checksum = hash_file('sha256', $file->getRealPath()); @@ -663,19 +671,27 @@ class PhotoController extends Controller $thumbnailPath = $thumbnailRelative; } - // Create photo record - $photo = Photo::create([ + $photoAttributes = [ 'event_id' => $event->id, - 'filename' => $filename, 'original_name' => $request->original_name, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), - 'path' => $path, + 'file_path' => $path, 'thumbnail_path' => $thumbnailPath, 'status' => 'pending', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - ]); + 'ingest_source' => Photo::SOURCE_TENANT_ADMIN, + ]; + + if (Photo::supportsFilenameColumn()) { + $photoAttributes['filename'] = $filename; + } + if (Photo::hasColumn('path')) { + $photoAttributes['path'] = $path; + } + + $photo = Photo::create($photoAttributes); [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); diff --git a/app/Http/Controllers/Api/Tenant/PhotoboothController.php b/app/Http/Controllers/Api/Tenant/PhotoboothController.php new file mode 100644 index 0000000..f75293f --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/PhotoboothController.php @@ -0,0 +1,79 @@ +assertEventBelongsToTenant($request, $event); + + return $this->resource($event); + } + + public function enable(Request $request, Event $event): JsonResponse + { + $this->assertEventBelongsToTenant($request, $event); + + $event->loadMissing('tenant'); + $updated = $this->provisioner->enable($event); + + return response()->json([ + 'message' => __('Photobooth-Zugang aktiviert.'), + 'data' => $this->resource($updated), + ]); + } + + public function rotate(Request $request, Event $event): JsonResponse + { + $this->assertEventBelongsToTenant($request, $event); + + $event->loadMissing('tenant'); + $updated = $this->provisioner->rotate($event); + + return response()->json([ + 'message' => __('Zugangsdaten neu generiert.'), + 'data' => $this->resource($updated), + ]); + } + + public function disable(Request $request, Event $event): JsonResponse + { + $this->assertEventBelongsToTenant($request, $event); + + $event->loadMissing('tenant'); + $updated = $this->provisioner->disable($event); + + return response()->json([ + 'message' => __('Photobooth-Zugang deaktiviert.'), + 'data' => $this->resource($updated), + ]); + } + + protected function resource(Event $event): PhotoboothStatusResource + { + return PhotoboothStatusResource::make([ + 'event' => $event->fresh(), + 'settings' => PhotoboothSetting::current(), + ]); + } + + protected function assertEventBelongsToTenant(Request $request, Event $event): void + { + $tenantId = (int) $request->attributes->get('tenant_id'); + + if ($tenantId !== (int) $event->tenant_id) { + abort(403, 'Event gehört nicht zu diesem Tenant.'); + } + } +} diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index a85dff1..53a1d73 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -34,6 +34,7 @@ class PhotoResource extends JsonResource 'is_liked' => false, 'uploaded_at' => $this->created_at->toISOString(), 'uploader_name' => $this->guest_name ?? null, + 'ingest_source' => $this->ingest_source, 'event' => [ 'id' => $this->event->id, 'name' => $this->event->name, @@ -57,4 +58,4 @@ class PhotoResource extends JsonResource { return url("storage/events/{$this->event->slug}/thumbnails/{$this->filename}"); } -} \ No newline at end of file +} diff --git a/app/Http/Resources/Tenant/PhotoboothStatusResource.php b/app/Http/Resources/Tenant/PhotoboothStatusResource.php new file mode 100644 index 0000000..7805d08 --- /dev/null +++ b/app/Http/Resources/Tenant/PhotoboothStatusResource.php @@ -0,0 +1,76 @@ + + */ + public function toArray(Request $request): array + { + $payload = $this->resolvePayload(); + /** @var Event $event */ + $event = $payload['event']; + /** @var PhotoboothSetting $settings */ + $settings = $payload['settings']; + + $password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password; + + return [ + 'enabled' => (bool) $event->photobooth_enabled, + 'status' => $event->photobooth_status, + 'username' => $event->photobooth_username, + 'password' => $password, + 'path' => $event->photobooth_path, + 'ftp_url' => $this->buildFtpUrl($event, $settings, $password), + 'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(), + 'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute, + 'ftp' => [ + 'host' => config('photobooth.ftp.host'), + 'port' => $settings->ftp_port, + 'require_ftps' => (bool) $settings->require_ftps, + ], + ]; + } + + /** + * @return array{event: Event, settings: PhotoboothSetting} + */ + protected function resolvePayload(): array + { + $resource = $this->resource; + + if ($resource instanceof Event) { + return [ + 'event' => $resource, + 'settings' => PhotoboothSetting::current(), + ]; + } + + return [ + 'event' => $resource['event'] ?? $resource, + 'settings' => $resource['settings'] ?? PhotoboothSetting::current(), + ]; + } + + protected function buildFtpUrl(Event $event, PhotoboothSetting $settings, ?string $password): ?string + { + $host = config('photobooth.ftp.host'); + $username = $event->photobooth_username; + + if (! $host || ! $username || ! $password) { + return null; + } + + $scheme = $settings->require_ftps ? 'ftps' : 'ftp'; + $port = $settings->ftp_port ?: config('photobooth.ftp.port', 21); + + return sprintf('%s://%s:%s@%s:%d', $scheme, $username, $password, $host, $port); + } +} diff --git a/app/Models/CoolifyActionLog.php b/app/Models/CoolifyActionLog.php new file mode 100644 index 0000000..3f727b9 --- /dev/null +++ b/app/Models/CoolifyActionLog.php @@ -0,0 +1,24 @@ + 'array', + 'response' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 64e6406..b6c9b97 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -3,11 +3,13 @@ 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; class Event extends Model { @@ -22,6 +24,13 @@ class Event extends Model 'is_active' => 'boolean', 'name' => 'array', 'description' => 'array', + 'photobooth_enabled' => 'boolean', + 'photobooth_expires_at' => 'datetime', + 'photobooth_metadata' => 'array', + ]; + + protected $hidden = [ + 'photobooth_password_encrypted', ]; protected static function booted(): void @@ -159,4 +168,26 @@ 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; + } } diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 3159fa2..eb649f5 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -6,17 +6,29 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use App\Models\EventMediaAsset; +use Illuminate\Support\Facades\Schema; use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation; use Znck\Eloquent\Traits\BelongsToThrough; class Photo extends Model { - use HasFactory; use BelongsToThrough; + use HasFactory; + + public const SOURCE_GUEST_PWA = 'guest_pwa'; + + public const SOURCE_TENANT_ADMIN = 'tenant_admin'; + + public const SOURCE_PHOTOBOOTH = 'photobooth'; + + public const SOURCE_UNKNOWN = 'unknown'; + + protected static ?array $columnCache = null; protected $table = 'photos'; + protected $guarded = []; + protected $casts = [ 'is_featured' => 'boolean', 'metadata' => 'array', @@ -26,6 +38,7 @@ class Photo extends Model protected $attributes = [ 'security_scan_status' => 'pending', + 'ingest_source' => self::SOURCE_GUEST_PWA, ]; public function mediaAsset(): BelongsTo @@ -63,6 +76,20 @@ class Photo extends Model return $this->hasMany(PhotoLike::class); } + public static function supportsFilenameColumn(): bool + { + return static::hasColumn('filename'); + } + + public static function hasColumn(string $column): bool + { + if (static::$columnCache === null) { + static::$columnCache = Schema::getColumnListing((new self)->getTable()); + } + + return in_array($column, static::$columnCache, true); + } + public function tenant(): BelongsToThroughRelation { return $this->belongsToThrough( diff --git a/app/Models/PhotoboothSetting.php b/app/Models/PhotoboothSetting.php new file mode 100644 index 0000000..d9fa02f --- /dev/null +++ b/app/Models/PhotoboothSetting.php @@ -0,0 +1,49 @@ + 'boolean', + 'allowed_ip_ranges' => 'array', + ]; + + protected static function booted(): void + { + static::saved(fn () => static::flushCache()); + static::deleted(fn () => static::flushCache()); + } + + public static function current(): self + { + return Cache::remember('photobooth.settings', now()->addMinutes(10), function () { + $defaults = [ + 'ftp_port' => config('photobooth.ftp.port', 2121), + 'rate_limit_per_minute' => config('photobooth.rate_limit_per_minute', 20), + 'expiry_grace_days' => config('photobooth.expiry_grace_days', 1), + 'require_ftps' => false, + 'allowed_ip_ranges' => null, + 'control_service_base_url' => config('photobooth.control_service.base_url'), + 'control_service_token_identifier' => null, + ]; + + return static::query()->firstOrCreate(['id' => 1], $defaults); + }); + } + + public static function flushCache(): void + { + Cache::forget('photobooth.settings'); + } + + public function ftpHost(): ?string + { + return config('photobooth.ftp.host'); + } +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index ea88ae9..9701475 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -4,7 +4,9 @@ namespace App\Providers\Filament; use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\PostResource; +use App\Filament\Resources\CoolifyActionLogs\CoolifyActionLogResource; use App\Filament\Resources\LegalPageResource; +use App\Filament\Widgets\CoolifyPlatformHealth; use App\Filament\Widgets\CreditAlertsWidget; use App\Filament\Widgets\PlatformStatsWidget; use App\Filament\Widgets\RevenueTrendWidget; @@ -59,6 +61,7 @@ class SuperAdminPanelProvider extends PanelProvider TopTenantsByRevenue::class, TopTenantsByUploads::class, \App\Filament\Widgets\StorageCapacityWidget::class, + CoolifyPlatformHealth::class, ]) ->middleware([ EncryptCookies::class, @@ -84,6 +87,7 @@ class SuperAdminPanelProvider extends PanelProvider PostResource::class, CategoryResource::class, LegalPageResource::class, + CoolifyActionLogResource::class, ]) ->authGuard('web'); diff --git a/app/Services/Coolify/CoolifyClient.php b/app/Services/Coolify/CoolifyClient.php new file mode 100644 index 0000000..12dd6db --- /dev/null +++ b/app/Services/Coolify/CoolifyClient.php @@ -0,0 +1,138 @@ +cached("coolify.service.$serviceId", fn () => $this->get("/services/{$serviceId}"), 30); + } + + public function recentDeployments(string $serviceId, int $limit = 5): array + { + return $this->cached("coolify.deployments.$serviceId", function () use ($serviceId, $limit) { + $response = $this->get("/services/{$serviceId}/deployments?per_page={$limit}"); + + return Arr::get($response, 'data', []); + }, 60); + } + + public function restartService(string $serviceId, ?Authenticatable $actor = null): array + { + return $this->dispatchAction($serviceId, 'restart', function () use ($serviceId) { + return $this->post("/services/{$serviceId}/actions/restart"); + }, $actor); + } + + public function redeployService(string $serviceId, ?Authenticatable $actor = null): array + { + return $this->dispatchAction($serviceId, 'redeploy', function () use ($serviceId) { + return $this->post("/services/{$serviceId}/actions/redeploy"); + }, $actor); + } + + protected function cached(string $key, callable $callback, int $seconds): mixed + { + return Cache::remember($key, now()->addSeconds($seconds), $callback); + } + + protected function get(string $path): array + { + $response = $this->request()->get($path); + + if ($response->failed()) { + $this->logFailure('GET', $path, $response); + throw new RequestException($response); + } + + return $response->json() ?? []; + } + + protected function post(string $path, array $payload = []): array + { + $response = $this->request()->post($path, $payload); + + if ($response->failed()) { + $this->logFailure('POST', $path, $response); + throw new RequestException($response); + } + + return $response->json() ?? []; + } + + protected function request(): PendingRequest + { + $baseUrl = config('coolify.api.base_url'); + $token = config('coolify.api.token'); + $timeout = config('coolify.api.timeout', 5); + + if (! $baseUrl || ! $token) { + throw new \RuntimeException('Coolify API is not configured.'); + } + + return $this->http + ->baseUrl($baseUrl) + ->timeout($timeout) + ->acceptJson() + ->withToken($token); + } + + protected function logFailure(string $method, string $path, \Illuminate\Http\Client\Response $response): void + { + Log::error('[Coolify] API request failed', [ + 'method' => $method, + 'path' => $path, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + + protected function dispatchAction(string $serviceId, string $action, callable $callback, ?Authenticatable $actor = null): array + { + $payload = []; + + try { + $response = $callback(); + } catch (\Throwable $exception) { + $this->logAction($serviceId, $action, $payload, [ + 'error' => $exception->getMessage(), + ], null, $actor); + + throw $exception; + } + + $this->logAction($serviceId, $action, $payload, $response, $response['status'] ?? null, $actor); + + return $response; + } + + protected function logAction( + string $serviceId, + string $action, + array $payload, + array $response, + ?int $status, + ?Authenticatable $actor = null, + ): void { + CoolifyActionLog::create([ + 'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(), + 'service_id' => $serviceId, + 'action' => $action, + 'payload' => $payload, + 'response' => $response, + 'status_code' => $status, + ]); + } +} +use App\Models\CoolifyActionLog; +use Illuminate\Contracts\Auth\Authenticatable; diff --git a/app/Services/Help/HelpSyncService.php b/app/Services/Help/HelpSyncService.php new file mode 100644 index 0000000..e121f53 --- /dev/null +++ b/app/Services/Help/HelpSyncService.php @@ -0,0 +1,129 @@ +addExtension(new CommonMarkCoreExtension); + $this->converter = new MarkdownConverter($environment); + } + + /** + * @return array> + */ + public function sync(): array + { + $sourcePath = base_path(config('help.source_path')); + + if (! $this->files->exists($sourcePath)) { + throw new RuntimeException('Help source directory not found: '.$sourcePath); + } + + $articles = collect(); + + foreach (config('help.audiences', []) as $audience) { + $audiencePath = $sourcePath.DIRECTORY_SEPARATOR.$audience; + + if (! $this->files->isDirectory($audiencePath)) { + continue; + } + + $files = $this->files->allFiles($audiencePath); + + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if ($file->getExtension() !== 'md') { + continue; + } + + $parsed = $this->parseFile($file); + $articles->push($parsed); + } + } + + $disk = config('help.disk'); + $compiledPath = trim(config('help.compiled_path'), '/'); + $written = []; + + foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) { + [$audience, $locale] = explode('::', $key); + $path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale); + Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + Cache::forget($this->cacheKey($audience, $locale)); + $written[$audience][$locale] = $group->count(); + } + + return $written; + } + + private function parseFile(SplFileInfo $file): array + { + $contents = $this->files->get($file->getPathname()); + + if (! Str::startsWith($contents, "---\n")) { + throw new RuntimeException('Missing front matter in '.$file->getPathname()); + } + + $pattern = '/^---\s*\n(?P.*?)-{3}\s*\n(?P.*)$/s'; + + if (! preg_match($pattern, $contents, $matches)) { + throw new RuntimeException('Unable to parse front matter for '.$file->getPathname()); + } + + $frontMatter = Yaml::parse(trim($matches['yaml'] ?? '')) ?? []; + $frontMatter = array_map(static fn ($value) => $value ?? null, $frontMatter); + + $this->validateFrontMatter($frontMatter, $file->getPathname()); + + $body = trim($matches['body'] ?? ''); + $html = trim($this->converter->convert($body)->getContent()); + $updatedAt = now()->setTimestamp($file->getMTime())->toISOString(); + + return array_merge($frontMatter, [ + 'body_markdown' => $body, + 'body_html' => $html, + 'source_path' => $file->getRelativePathname(), + 'updated_at' => $updatedAt, + ]); + } + + private function validateFrontMatter(array $frontMatter, string $path): void + { + $required = config('help.required_front_matter', []); + + foreach ($required as $key) { + if (! Arr::exists($frontMatter, $key)) { + throw new RuntimeException(sprintf('Missing front matter key "%s" in %s', $key, $path)); + } + } + + $audiences = config('help.audiences', []); + $audience = $frontMatter['audience']; + + if (! in_array($audience, $audiences, true)) { + throw new RuntimeException(sprintf('Invalid audience "%s" in %s', $audience, $path)); + } + } + + private function cacheKey(string $audience, string $locale): string + { + return sprintf('help.%s.%s', $audience, $locale); + } +} diff --git a/app/Services/Photobooth/ControlServiceClient.php b/app/Services/Photobooth/ControlServiceClient.php new file mode 100644 index 0000000..f6e2381 --- /dev/null +++ b/app/Services/Photobooth/ControlServiceClient.php @@ -0,0 +1,75 @@ +send('post', '/users', $payload, $settings); + } + + public function rotateUser(string $username, array $payload = [], ?PhotoboothSetting $settings = null): array + { + return $this->send('post', "/users/{$username}/rotate", $payload, $settings); + } + + public function deleteUser(string $username, ?PhotoboothSetting $settings = null): array + { + return $this->send('delete', "/users/{$username}", [], $settings); + } + + public function syncConfig(array $payload, ?PhotoboothSetting $settings = null): array + { + return $this->send('post', '/config', $payload, $settings); + } + + protected function send(string $method, string $path, array $payload = [], ?PhotoboothSetting $settings = null): array + { + $response = $this->request($settings)->{$method}($path, $payload); + + if ($response->failed()) { + $message = sprintf('Photobooth control request failed for %s', $path); + Log::error($message, [ + 'path' => $path, + 'payload' => Arr::except($payload, ['password']), + 'status' => $response->status(), + 'body' => $response->json() ?? $response->body(), + ]); + + throw new RequestException($response); + } + + return $response->json() ?? []; + } + + protected function request(?PhotoboothSetting $settings = null): PendingRequest + { + $settings ??= PhotoboothSetting::current(); + + $baseUrl = $settings->control_service_base_url ?? config('photobooth.control_service.base_url'); + $token = config('photobooth.control_service.token'); + + if (! $baseUrl || ! $token) { + throw new RuntimeException('Photobooth control service is not configured.'); + } + + $timeout = config('photobooth.control_service.timeout', 5); + + return $this->httpFactory + ->baseUrl($baseUrl) + ->timeout($timeout) + ->withToken($token) + ->acceptJson(); + } +} diff --git a/app/Services/Photobooth/CredentialGenerator.php b/app/Services/Photobooth/CredentialGenerator.php new file mode 100644 index 0000000..c2a187d --- /dev/null +++ b/app/Services/Photobooth/CredentialGenerator.php @@ -0,0 +1,46 @@ +usernameLength)); + $prefix = substr($this->usernamePrefix, 0, $maxLength - 3); + $tenantMarker = strtoupper(substr($event->tenant?->slug ?? $event->tenant?->name ?? 'x', 0, 1)); + + $remaining = $maxLength - strlen($prefix) - 1; + $randomSegment = $this->randomSegment(max(3, $remaining)); + + return strtoupper($prefix.$tenantMarker.substr($randomSegment, 0, $remaining)); + } + + public function generatePassword(): string + { + $length = min(8, max(6, $this->passwordLength)); + + return $this->randomSegment($length); + } + + protected function randomSegment(int $length): string + { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $poolSize = strlen($alphabet); + $value = ''; + + for ($i = 0; $i < $length; $i++) { + $value .= $alphabet[random_int(0, $poolSize - 1)]; + } + + return $value; + } +} diff --git a/app/Services/Photobooth/PhotoboothIngestService.php b/app/Services/Photobooth/PhotoboothIngestService.php new file mode 100644 index 0000000..897e25e --- /dev/null +++ b/app/Services/Photobooth/PhotoboothIngestService.php @@ -0,0 +1,263 @@ +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->package->max_photos; + + return $limit !== null + && $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'); + } +} diff --git a/app/Services/Photobooth/PhotoboothProvisioner.php b/app/Services/Photobooth/PhotoboothProvisioner.php new file mode 100644 index 0000000..2f2b410 --- /dev/null +++ b/app/Services/Photobooth/PhotoboothProvisioner.php @@ -0,0 +1,163 @@ +loadMissing('tenant'); + + return DB::transaction(function () use ($event, $settings) { + $username = $this->generateUniqueUsername($event, $settings); + $password = $this->credentialGenerator->generatePassword(); + $path = $this->buildPath($event); + $expiresAt = $this->resolveExpiry($event, $settings); + + $payload = [ + 'username' => $username, + 'password' => $password, + 'path' => $path, + 'rate_limit_per_minute' => $settings->rate_limit_per_minute, + 'expires_at' => $expiresAt?->toIso8601String(), + 'ftp_port' => $settings->ftp_port, + ]; + + $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' => [ + '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); + }); + }); + } + + public function rotate(Event $event, ?PhotoboothSetting $settings = null): Event + { + $settings ??= PhotoboothSetting::current(); + + if (! $event->photobooth_enabled || ! $event->photobooth_username) { + return $this->enable($event, $settings); + } + + return DB::transaction(function () use ($event, $settings) { + $password = $this->credentialGenerator->generatePassword(); + $expiresAt = $this->resolveExpiry($event, $settings); + + $payload = [ + 'password' => $password, + 'expires_at' => $expiresAt?->toIso8601String(), + 'rate_limit_per_minute' => $settings->rate_limit_per_minute, + ]; + + $this->client->rotateUser($event->photobooth_username, $payload, $settings); + + $event->forceFill([ + 'photobooth_password' => $password, + 'photobooth_expires_at' => $expiresAt, + 'photobooth_status' => 'active', + 'photobooth_last_provisioned_at' => now(), + ])->save(); + + return tap($event->refresh(), function (Event $refreshed) use ($password) { + $refreshed->setAttribute('plain_photobooth_password', $password); + }); + }); + } + + public function disable(Event $event, ?PhotoboothSetting $settings = null): Event + { + if (! $event->photobooth_username) { + return $event; + } + + $settings ??= PhotoboothSetting::current(); + + return DB::transaction(function () use ($event, $settings) { + try { + $this->client->deleteUser($event->photobooth_username, $settings); + } catch (\Throwable $exception) { + Log::warning('Photobooth account deletion failed', [ + 'event_id' => $event->id, + 'username' => $event->photobooth_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(), + ])->save(); + + return $event->refresh(); + }); + } + + protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface + { + $eventEnd = $event->date ? Carbon::parse($event->date) : now(); + $graceDays = max(0, (int) $settings->expiry_grace_days); + + return $eventEnd->copy() + ->endOfDay() + ->addDays($graceDays); + } + + protected function generateUniqueUsername(Event $event, PhotoboothSetting $settings): string + { + $maxAttempts = 10; + + for ($i = 0; $i < $maxAttempts; $i++) { + $username = $this->credentialGenerator->generateUsername($event); + + $exists = Event::query() + ->where('photobooth_username', $username) + ->whereKeyNot($event->getKey()) + ->exists(); + + if (! $exists) { + return strtolower($username); + } + } + + return strtolower('pb'.Str::random(5)); + } + + protected function buildPath(Event $event): string + { + $tenantKey = $event->tenant?->slug ?? $event->tenant_id; + + return trim((string) $tenantKey, '/').'/'.$event->getKey(); + } +} diff --git a/app/Support/Help/HelpRepository.php b/app/Support/Help/HelpRepository.php new file mode 100644 index 0000000..80eba39 --- /dev/null +++ b/app/Support/Help/HelpRepository.php @@ -0,0 +1,54 @@ +cacheKey($audience, $locale); + + return $this->cache->remember($key, now()->addMinutes(10), fn () => $this->load($audience, $locale)); + } + + public function find(string $audience, string $locale, string $slug): ?array + { + return $this->list($audience, $locale) + ->first(fn ($article) => Arr::get($article, 'slug') === $slug); + } + + private function load(string $audience, string $locale): Collection + { + $disk = config('help.disk'); + $compiledPath = trim(config('help.compiled_path'), '/'); + $path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale); + + if (! Storage::disk($disk)->exists($path)) { + throw new RuntimeException(sprintf('Help cache missing for %s/%s. Run help:sync.', $audience, $locale)); + } + + try { + $contents = Storage::disk($disk)->get($path); + } catch (FileNotFoundException $e) { + throw new RuntimeException($e->getMessage()); + } + + $decoded = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + + return collect($decoded); + } + + private function cacheKey(string $audience, string $locale): string + { + return sprintf('help.%s.%s', $audience, $locale); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 27d92cc..f118d47 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,9 +22,16 @@ return Application::configure(basePath: dirname(__DIR__)) ->withCommands([ \App\Console\Commands\CheckEventPackages::class, \App\Console\Commands\ExportCouponRedemptions::class, + \App\Console\Commands\DeactivateExpiredPhotoboothAccounts::class, + \App\Console\Commands\PhotoboothIngestCommand::class, + \App\Console\Commands\MonitorStorageCommand::class, + \App\Console\Commands\DispatchStorageArchiveCommand::class, + \App\Console\Commands\CheckUploadQueuesCommand::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { $schedule->command('package:check-status')->dailyAt('06:00'); + $schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping(); + $schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping(); }) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ diff --git a/config/coolify.php b/config/coolify.php new file mode 100644 index 0000000..4b1d188 --- /dev/null +++ b/config/coolify.php @@ -0,0 +1,11 @@ + [ + 'base_url' => env('COOLIFY_API_BASE_URL'), + 'token' => env('COOLIFY_API_TOKEN'), + 'timeout' => (int) env('COOLIFY_API_TIMEOUT', 5), + ], + 'web_url' => env('COOLIFY_WEB_URL'), + 'services' => json_decode(env('COOLIFY_SERVICE_IDS', '{}'), true) ?? [], +]; diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd..981c1b6 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -47,6 +47,13 @@ return [ 'report' => false, ], + 'photobooth' => [ + 'driver' => 'local', + 'root' => env('PHOTOBOOTH_IMPORT_ROOT', storage_path('app/photobooth')), + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/config/help.php b/config/help.php new file mode 100644 index 0000000..4473cef --- /dev/null +++ b/config/help.php @@ -0,0 +1,37 @@ + 'docs/help', + 'disk' => 'local', + 'compiled_path' => 'help', + 'audiences' => ['guest', 'admin'], + 'default_locale' => 'en', + 'fallback_locale' => 'en', + 'required_front_matter' => [ + 'title', + 'locale', + 'slug', + 'audience', + 'summary', + 'version_introduced', + 'status', + 'translation_state', + 'last_reviewed_at', + 'owner', + ], + 'list_fields' => [ + 'slug', + 'title', + 'summary', + 'version_introduced', + 'requires_app_version', + 'status', + 'translation_state', + 'last_reviewed_at', + 'owner', + 'related', + 'audience', + 'locale', + 'updated_at', + ], +]; diff --git a/config/logging.php b/config/logging.php index 8d94292..1b0d765 100644 --- a/config/logging.php +++ b/config/logging.php @@ -73,6 +73,14 @@ return [ 'replace_placeholders' => true, ], + 'storage-jobs' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/storage-jobs.log'), + 'level' => env('LOG_LEVEL', 'info'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/photobooth.php b/config/photobooth.php new file mode 100644 index 0000000..b294c68 --- /dev/null +++ b/config/photobooth.php @@ -0,0 +1,28 @@ + [ + 'base_url' => env('PHOTOBOOTH_CONTROL_BASE_URL'), + 'token' => env('PHOTOBOOTH_CONTROL_TOKEN'), + 'timeout' => (int) env('PHOTOBOOTH_CONTROL_TIMEOUT', 5), + ], + 'ftp' => [ + 'host' => env('PHOTOBOOTH_FTP_HOST'), + 'port' => (int) env('PHOTOBOOTH_FTP_PORT', 2121), + ], + 'credentials' => [ + 'username_prefix' => env('PHOTOBOOTH_USERNAME_PREFIX', 'pb'), + 'username_length' => (int) env('PHOTOBOOTH_USERNAME_LENGTH', 8), + 'password_length' => (int) env('PHOTOBOOTH_PASSWORD_LENGTH', 8), + ], + 'rate_limit_per_minute' => (int) env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20), + 'expiry_grace_days' => (int) env('PHOTOBOOTH_EXPIRY_GRACE_DAYS', 1), + 'import' => [ + 'disk' => env('PHOTOBOOTH_IMPORT_DISK', 'photobooth'), + 'max_files_per_run' => (int) env('PHOTOBOOTH_IMPORT_MAX_FILES', 50), + 'allowed_extensions' => array_values(array_filter(array_map( + fn ($ext) => strtolower(trim($ext)), + explode(',', env('PHOTOBOOTH_ALLOWED_EXTENSIONS', 'jpg,jpeg,png,webp')) + ))), + ], +]; diff --git a/config/storage-monitor.php b/config/storage-monitor.php index 248b9a8..16474aa 100644 --- a/config/storage-monitor.php +++ b/config/storage-monitor.php @@ -6,5 +6,42 @@ return [ ], 'queue_failure_alerts' => env('STORAGE_QUEUE_FAILURE_ALERTS', true), -]; + 'capacity_thresholds' => [ + 'warning' => (int) env('STORAGE_CAPACITY_WARNING', 75), + 'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90), + ], + + 'monitor' => [ + 'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300), + 'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15), + ], + + 'archive' => [ + 'grace_days' => (int) env('STORAGE_ARCHIVE_GRACE_DAYS', 3), + 'lock_seconds' => (int) env('STORAGE_ARCHIVE_LOCK_SECONDS', 1800), + 'event_lock_seconds' => (int) env('STORAGE_ARCHIVE_EVENT_LOCK_SECONDS', 3600), + 'chunk' => (int) env('STORAGE_ARCHIVE_CHUNK', 25), + 'max_dispatch' => (int) env('STORAGE_ARCHIVE_MAX_DISPATCH', 100), + ], + + 'queue_health' => [ + 'lock_seconds' => (int) env('STORAGE_QUEUE_HEALTH_LOCK_SECONDS', 120), + 'cache_minutes' => (int) env('STORAGE_QUEUE_HEALTH_CACHE_MINUTES', 10), + 'stalled_minutes' => (int) env('STORAGE_QUEUE_STALLED_MINUTES', 10), + 'thresholds' => [ + 'default' => [ + 'warning' => (int) env('STORAGE_QUEUE_DEFAULT_WARNING', 100), + 'critical' => (int) env('STORAGE_QUEUE_DEFAULT_CRITICAL', 300), + ], + 'media-storage' => [ + 'warning' => (int) env('STORAGE_QUEUE_MEDIA_STORAGE_WARNING', 200), + 'critical' => (int) env('STORAGE_QUEUE_MEDIA_STORAGE_CRITICAL', 500), + ], + 'media-security' => [ + 'warning' => (int) env('STORAGE_QUEUE_MEDIA_SECURITY_WARNING', 50), + 'critical' => (int) env('STORAGE_QUEUE_MEDIA_SECURITY_CRITICAL', 150), + ], + ], + ], +]; diff --git a/cron/archive_dispatcher.sh b/cron/archive_dispatcher.sh index 3f7c2b2..d76a9d0 100644 --- a/cron/archive_dispatcher.sh +++ b/cron/archive_dispatcher.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Archive dispatcher cron skeleton +# Archive dispatcher cron job # Run nightly to move completed events to cold storage set -euo pipefail @@ -8,6 +8,25 @@ set -euo pipefail APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$APP_DIR" -# Replace with finalized artisan command that queues archive jobs -/usr/bin/env php artisan storage:archive-pending --quiet +LOG_DIR="$APP_DIR/storage/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/cron-archive-dispatcher.log" +LOCK_FILE="$LOG_DIR/archive_dispatcher.lock" +exec 200>"$LOCK_FILE" +if ! flock -n 200; then + exit 0 +fi + +timestamp() { + date --iso-8601=seconds +} + +echo "[$(timestamp)] Starting storage:archive-pending" >> "$LOG_FILE" +if /usr/bin/env php artisan storage:archive-pending --no-interaction --quiet >> "$LOG_FILE" 2>&1; then + echo "[$(timestamp)] storage:archive-pending completed" >> "$LOG_FILE" +else + status=$? + echo "[$(timestamp)] storage:archive-pending failed (exit $status)" >> "$LOG_FILE" + exit $status +fi diff --git a/cron/storage_monitor.sh b/cron/storage_monitor.sh index 3726b63..00f8f33 100644 --- a/cron/storage_monitor.sh +++ b/cron/storage_monitor.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Storage monitor cron skeleton +# Storage monitor cron job # Usage: configure cron to run every 5 minutes set -euo pipefail @@ -8,7 +8,25 @@ set -euo pipefail APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$APP_DIR" -# Collect storage statistics and cache them for the dashboard -# Customize the artisan command once implemented -/usr/bin/env php artisan storage:monitor --quiet +LOG_DIR="$APP_DIR/storage/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/cron-storage-monitor.log" +LOCK_FILE="$LOG_DIR/storage_monitor.lock" +exec 201>"$LOCK_FILE" +if ! flock -n 201; then + exit 0 +fi + +timestamp() { + date --iso-8601=seconds +} + +echo "[$(timestamp)] Starting storage:monitor" >> "$LOG_FILE" +if /usr/bin/env php artisan storage:monitor --no-interaction --quiet >> "$LOG_FILE" 2>&1; then + echo "[$(timestamp)] storage:monitor completed" >> "$LOG_FILE" +else + status=$? + echo "[$(timestamp)] storage:monitor failed (exit $status)" >> "$LOG_FILE" + exit $status +fi diff --git a/cron/upload_queue_health.sh b/cron/upload_queue_health.sh index e119e6c..3fbf61b 100644 --- a/cron/upload_queue_health.sh +++ b/cron/upload_queue_health.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Upload queue health cron skeleton +# Upload queue health cron job # Schedule every 10 minutes to detect stalled uploads set -euo pipefail @@ -8,6 +8,25 @@ set -euo pipefail APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$APP_DIR" -# Artisan command should inspect queue lengths and stuck assets -/usr/bin/env php artisan storage:check-upload-queues --quiet +LOG_DIR="$APP_DIR/storage/logs" +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/cron-upload-queue-health.log" +LOCK_FILE="$LOG_DIR/upload_queue_health.lock" +exec 202>"$LOCK_FILE" +if ! flock -n 202; then + exit 0 +fi + +timestamp() { + date --iso-8601=seconds +} + +echo "[$(timestamp)] Starting storage:check-upload-queues" >> "$LOG_FILE" +if /usr/bin/env php artisan storage:check-upload-queues --no-interaction --quiet >> "$LOG_FILE" 2>&1; then + echo "[$(timestamp)] storage:check-upload-queues completed" >> "$LOG_FILE" +else + status=$? + echo "[$(timestamp)] storage:check-upload-queues failed (exit $status)" >> "$LOG_FILE" + exit $status +fi diff --git a/database/factories/PhotoFactory.php b/database/factories/PhotoFactory.php index 19e4bdd..8821405 100644 --- a/database/factories/PhotoFactory.php +++ b/database/factories/PhotoFactory.php @@ -19,11 +19,12 @@ class PhotoFactory extends Factory 'emotion_id' => Emotion::factory(), 'task_id' => null, 'guest_name' => $this->faker->name(), - 'file_path' => 'photos/' . Str::uuid() . '.jpg', - 'thumbnail_path' => 'photos/thumbnails/' . Str::uuid() . '.jpg', + 'file_path' => 'photos/'.Str::uuid().'.jpg', + 'thumbnail_path' => 'photos/thumbnails/'.Str::uuid().'.jpg', 'likes_count' => $this->faker->numberBetween(0, 25), 'is_featured' => false, 'metadata' => ['factory' => true], + 'ingest_source' => \App\Models\Photo::SOURCE_GUEST_PWA, ]; } @@ -45,4 +46,3 @@ class PhotoFactory extends Factory }); } } - diff --git a/database/migrations/2025_11_10_091757_create_photobooth_settings_table.php b/database/migrations/2025_11_10_091757_create_photobooth_settings_table.php new file mode 100644 index 0000000..ba70b54 --- /dev/null +++ b/database/migrations/2025_11_10_091757_create_photobooth_settings_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedSmallInteger('ftp_port')->default(2121); + $table->unsignedInteger('rate_limit_per_minute')->default(20); + $table->unsignedTinyInteger('expiry_grace_days')->default(1); + $table->boolean('require_ftps')->default(false); + $table->json('allowed_ip_ranges')->nullable(); + $table->string('control_service_base_url')->nullable(); + $table->string('control_service_token_identifier')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('photobooth_settings'); + } +}; 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 new file mode 100644 index 0000000..7920614 --- /dev/null +++ b/database/migrations/2025_11_10_091812_add_photobooth_columns_to_events_table.php @@ -0,0 +1,46 @@ +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_10_094831_add_ingest_source_to_photos_table.php b/database/migrations/2025_11_10_094831_add_ingest_source_to_photos_table.php new file mode 100644 index 0000000..e58bcb9 --- /dev/null +++ b/database/migrations/2025_11_10_094831_add_ingest_source_to_photos_table.php @@ -0,0 +1,36 @@ +string('ingest_source', 32) + ->default('guest_pwa') + ->after('guest_name'); + $table->index('ingest_source'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + if (Schema::hasColumn('photos', 'ingest_source')) { + $table->dropIndex('photos_ingest_source_index'); + $table->dropColumn('ingest_source'); + } + }); + } +}; diff --git a/database/migrations/2025_11_10_100552_add_extra_columns_to_photos_table.php b/database/migrations/2025_11_10_100552_add_extra_columns_to_photos_table.php new file mode 100644 index 0000000..2412a6b --- /dev/null +++ b/database/migrations/2025_11_10_100552_add_extra_columns_to_photos_table.php @@ -0,0 +1,84 @@ +string('filename')->nullable()->after('guest_name'); + } + + if (! Schema::hasColumn('photos', 'original_name')) { + $table->string('original_name')->nullable()->after('filename'); + } + + if (! Schema::hasColumn('photos', 'mime_type')) { + $table->string('mime_type', 191)->nullable()->after('original_name'); + } + + if (! Schema::hasColumn('photos', 'size')) { + $table->unsignedBigInteger('size')->nullable()->after('mime_type'); + } + + if (! Schema::hasColumn('photos', 'width')) { + $table->unsignedInteger('width')->nullable()->after('thumbnail_path'); + } + + if (! Schema::hasColumn('photos', 'height')) { + $table->unsignedInteger('height')->nullable()->after('width'); + } + + if (! Schema::hasColumn('photos', 'status')) { + $table->string('status', 32)->default('pending')->after('height'); + } + + if (! Schema::hasColumn('photos', 'uploader_id')) { + $table->foreignId('uploader_id') + ->nullable() + ->after('status') + ->constrained('users') + ->nullOnDelete(); + } + + if (! Schema::hasColumn('photos', 'ip_address')) { + $table->string('ip_address', 45)->nullable()->after('uploader_id'); + } + + if (! Schema::hasColumn('photos', 'user_agent')) { + $table->text('user_agent')->nullable()->after('ip_address'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + foreach (['user_agent', 'ip_address'] as $column) { + if (Schema::hasColumn('photos', $column)) { + $table->dropColumn($column); + } + } + + if (Schema::hasColumn('photos', 'uploader_id')) { + $table->dropConstrainedForeignId('uploader_id'); + } + + foreach (['status', 'height', 'width', 'size', 'mime_type', 'original_name', 'filename'] as $column) { + if (Schema::hasColumn('photos', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2025_11_10_104229_create_coolify_action_logs_table.php b/database/migrations/2025_11_10_104229_create_coolify_action_logs_table.php new file mode 100644 index 0000000..dd41914 --- /dev/null +++ b/database/migrations/2025_11_10_104229_create_coolify_action_logs_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('service_id', 64); + $table->string('action', 64); + $table->json('payload')->nullable(); + $table->json('response')->nullable(); + $table->unsignedSmallInteger('status_code')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('coolify_action_logs'); + } +}; diff --git a/docs/deployment/coolify.md b/docs/deployment/coolify.md new file mode 100644 index 0000000..4073755 --- /dev/null +++ b/docs/deployment/coolify.md @@ -0,0 +1,93 @@ +# Coolify Deployment Guide + +Coolify provides a managed Docker host with service orchestration, logs, metrics, CI hooks, and secret management. This document outlines how to run Fotospiel (including the Photobooth FTP stack) on Coolify and how to prepare for SuperAdmin observability. + +## 1. Services to deploy + +| Service | Notes | +|---------|-------| +| **Laravel App** | Build from this repo. Expose port 8080. Attach environment variables from `.env`. | +| **Scheduler** | Clone the app container; command `php artisan schedule:work`. | +| **Queue workers** | Use `docs/queue-supervisor/queue-worker.sh` scripts (default, media-storage, media-security). | +| **Horizon (optional)** | Add service executing `docs/queue-supervisor/horizon.sh`. | +| **Redis / Database** | Use Coolify managed services or bring your own (RDS/Aurora). | +| **vsftpd container** | Host FTP on port 2121 and mount the shared Photobooth volume. | +| **Photobooth Control Service** | Lightweight API (Go/Node/Laravel Octane) that Coolify can redeploy alongside vsftpd. | + +### Volumes + +- `storage-app` (Laravel `storage`, uploads, compiled views). +- `photobooth` (shared between vsftpd, control-service, and Laravel). +- Database/Redis volumes if self-hosted. + +Mount these volumes in Coolify under “Persistent Storage” for each service. + +## 2. Environment & Secrets + +Configure the following keys inside Coolify’s “Environment Variables” panel: + +- All standard Laravel vars (`APP_KEY`, `DB_*`, `QUEUE_CONNECTION`, `AWS_*` etc.). +- Photobooth block (as documented in `.env.example`): `PHOTOBOOTH_CONTROL_*`, `PHOTOBOOTH_FTP_HOST/PORT`, `PHOTOBOOTH_IMPORT_*`. +- New Coolify integration vars (planned for SuperAdmin widgets): + +``` +COOLIFY_API_BASE_URL=https://coolify.example.com/api/v1 +COOLIFY_API_TOKEN=... # generated per project +COOLIFY_SERVICE_IDS={"app":"svc_xxx","ftp":"svc_yyy"} +``` + +Store the JSON mapping so Laravel knows which Coolify “service” controls the app, queue, vsftpd, etc. + +## 3. Deploy steps + +1. Add the Git repository to Coolify (build hook). Configure the Dockerfile build args if needed. +2. Define services: + - **App**: HTTP worker (build & run). Health check `/up`. + - **Scheduler**: same image, command `php artisan schedule:work`. + - **Queue**: command `/var/www/html/docs/queue-supervisor/queue-worker.sh default`. + - Additional queue types as separate services. +3. Configure networks so all services share the same internal bridge, allowing Redis/DB connectivity. +4. Attach the `photobooth` volume to both vsftpd and the Laravel app. +5. Run `php artisan migrate --force` from Coolify’s “One-off command” console after the first deploy. +6. Seed storage targets if necessary (`php artisan db:seed --class=MediaStorageTargetSeeder --force`). + +## 4. Metrics & Controls for SuperAdmin + +To surface Coolify data inside the platform: + +1. **API Token** – create a Coolify PAT with read access to services and optional “actions” scope. +2. **Laravel config** – introduce `config/coolify.php` with base URL, token, and service IDs. +3. **Service client** – wrap Coolify endpoints: + - `GET /services/{id}` → CPU/RAM, status, last deploy, git SHA. + - `POST /services/{id}/actions/restart` for restart buttons. + - `GET /deployments/{id}/logs` for tailing last deploy logs. +4. **Filament widgets** – in SuperAdmin dashboard add: + - **Platform Health**: per service status (App, Queue, Scheduler, vsftpd, Control Service). + - **Recent Deploys**: table of the last Coolify deployments and commit messages. + - **Actions**: buttons (with confirmations) to restart vsftpd or re-run `photobooth:ingest` service. + +Ensure all requests are audited (database table) and require SuperAdmin role. + +## 5. FTP container controls + +Coolify makes it easier to: + +- View vsftpd metrics (CPU, memory, network) directly; replicate those values in SuperAdmin via the API. +- Trigger redeploys of the vsftpd service when Photobooth settings change (Laravel can call Coolify’s redeploy endpoint). +- Inspect container logs from SuperAdmin by proxying `GET /services/{id}/logs?tail=200`. + +## 6. Monitoring & Alerts + +- Configure Coolify Webhooks (Deploy succeeded/failed, service unhealthy) → point to a Laravel route, mark incidents in `photobooth_metadata`. +- Use Coolify’s built-in notifications (Slack, email) for infrastructure-level alerts; complement with Laravel notifications for application-level events (e.g., ingest failures). + +## 7. Production readiness checklist + +1. All services built and running in Coolify with health checks. +2. Volumes backed up (database snapshots + `storage` tarball). +3. Photobooth shared volume mounted & writeable by vsftpd + Laravel. +4. Environment variables set (APP_KEY, DB creds, Photobooth block, Coolify API token). +5. Scheduler & queue services logging to Coolify. +6. SuperAdmin Filament widgets wired to Coolify API (optional but recommended). + +With this setup you can manage deployments, restarts, and metrics centrally while still using Laravel’s built-in scheduler and worker scripts. The next step is implementing the `CoolifyClient` + Filament widgets described above. diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 2fcc0b5..3fbe761 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -2,6 +2,8 @@ This guide describes the recommended, repeatable way to run the Fotospiel platform in Docker for production or high-fidelity staging environments. It pairs a multi-stage build (PHP-FPM + asset pipeline) with a Compose stack that includes Nginx, worker processes, Redis, and MySQL. +> **Coolify users:** see `docs/deployment/coolify.md` for service definitions, secrets, and how to wire the same containers (web, queue, scheduler, vsftpd) inside Coolify. That document builds on the base Docker instructions below. + ## 1. Prerequisites - Docker Engine 24+ and Docker Compose v2. @@ -106,4 +108,3 @@ Because the app image keeps the authoritative copy of the code, each container r - Hook into your observability stack (e.g., ship container logs to Loki or ELK). With the provided configuration you can bootstrap a consistent Docker-based deployment across environments while keeping queue workers, migrations, and asset builds manageable. Adjust service definitions as needed for staging vs. production. - diff --git a/docs/help/README.md b/docs/help/README.md new file mode 100644 index 0000000..9cf67dc --- /dev/null +++ b/docs/help/README.md @@ -0,0 +1,81 @@ +# Help System Blueprint + +This folder defines the bilingual help center that serves both guest app users and customer admins. It stores the source Markdown, translation metadata, and the publishing workflow that feeds the in-app help experiences. + +## Goals +- Give each audience (Guests vs. Admins) tailored, task-based documentation in German and English. +- Keep a single Git-tracked source of truth with translation status and review history. +- Deliver lightweight, searchable payloads to both apps (offline capable for guests, richer navigation for admins). +- Allow non-developers to contribute through Filament while still publishing Markdown to the repo. + +## Directory Layout +``` +docs/help/ +├── README.md # This blueprint +├── templates/ # Authoring templates per locale +├── guest/ # Guest-focused articles (paired locales) +│ ├── index.en.md +│ └── index.de.md +└── admin/ # Customer admin articles (paired locales) + ├── index.en.md + └── index.de.md +``` +- Articles live in the audience folder and follow the naming pattern `..md` (e.g., `offline-sync.en.md`). +- Each article includes YAML front matter to describe metadata used by the app and Filament resource. + +## Front Matter Contract +```yaml +title: "Getting Started" +locale: en +slug: getting-started +audience: guest # guest | admin | shared +summary: "Install the guest app and join an event in under a minute." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" # optional semver filter for the apps +status: published # draft | review | published +translation_state: aligned # draft | needs_update | aligned +last_reviewed_at: 2025-01-10 +owner: cx-team@fotospiel.app # rotational owner or team list +related: + - slug: offline-sync + - slug: privacy-basics +``` +- `translation_state` tracks whether the paired locale matches the canonical source. CI should warn when one locale lags. +- `requires_app_version` lets an app hide docs when the installed build lacks that feature. + +## Authoring & Translation Workflow +1. **Plan** – open an issue referencing the article slug and target locales. +2. **Draft** – copy the locale template, write the content, keep paragraphs short for mobile. +3. **Review** – tech review (feature owner) + linguistic review (native speaker). Update `status` and `last_reviewed_at`. +4. **Translate** – for the paired locale, mark `translation_state` accordingly and link PRs together. +5. **Publish** – merge to `main`. A CI job should rebuild the Markdown cache and push structured JSON to storage (e.g., S3 or Redis) consumed by Laravel. + +> Tip: prefer screenshots only in admin docs; guest docs focus on concise steps + illustrations already shipped in the app. + +## Delivery Architecture +- **Source of truth**: Markdown in this folder. +- **Processing**: an Artisan job (`help:sync`) parses Markdown via `league/commonmark`, validates front matter, generates per-locale JSON bundles, and stores them under `storage/app/help//.json`. +- **API**: + - `GET /api/help?audience=guest&locale=de` → paginated list with `title`, `summary`, `slug`, `version_introduced`, `updated_at`. + - `GET /api/help/{slug}` → full article body + related slugs. + - Guests access anonymously; admins require auth middleware (so we can show restricted docs). +- **Caching**: + - Cache list + article JSON for 10 minutes in Redis; bust cache whenever the sync job runs. + - Guest app prefetches JSON during first online session and stores it in IndexedDB for offline reading. + - Admin app keeps only recent items client-side but relies on online search for canonical versions. +- **Search**: + - During sync, build Lunr/MiniSearch indexes per audience/locale and ship them to the app bundles for offline search. + +## Contextual Access +- **Guest App**: add `?` entry points (floating button, settings, upload errors) that deep-link to slugs. Offer article-level language toggles. +- **Admin App / Filament**: show contextual “Need help?” links near complex forms, routing to `/help/{slug}` in an in-app modal. Provide a global Help center route with sidebar nav and server-driven breadcrumbs. +- **Standalone Web**: optional `/help` public site generated from the same Markdown using Vite/React and server-side rendering for SEO. + +## Governance & Backlog +- Track work in `docs/todo/help.md` (create if needed) and link issues to article slugs. +- Add CI checks: + - Ensure every `.en.md` file has a matching `.de.md` file with equal `slug`/`version_introduced`. + - Validate required front matter keys. +- Future enhancements: + - Integrate with Paddle customer portal for billing-related admin help. + - Add analytics event (non-PII) for article views through the app to measure usefulness. diff --git a/docs/help/admin/admin-issue-resolution.de.md b/docs/help/admin/admin-issue-resolution.de.md new file mode 100644 index 0000000..b15d06c --- /dev/null +++ b/docs/help/admin/admin-issue-resolution.de.md @@ -0,0 +1,37 @@ +--- +title: "Troubleshooting & Incident-Playbooks" +locale: de +slug: admin-issue-resolution +audience: admin +summary: "Leitfäden für typische Admin-Vorfälle – von hängenden Uploads bis zu Billing-Sperren." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: reliability@fotospiel.app +related: + - slug: live-ops-control + - slug: privacy-and-support +--- + +## Upload-Vorfälle +| Symptom | Diagnose | Lösung | +| --- | --- | --- | +| Warteschlange >10 Min fest | Live-Ops-Health-Widget prüfen | `php artisan media:backfill-thumbnails --tenant=XYZ` ausführen, Event neu öffnen | +| Einzelner Gast blockiert | Geräte-Limit erreicht | Limit unter Event → Upload-Regeln erhöhen oder Gast bittet Entwürfe zu löschen | +| Fotos ohne EXIF | Gast importiert Screenshots | Kein Fehler; Hinweis geben, dass EXIF optional ist | + +## Zugriffsprobleme +- **Admin kommt nicht rein**: Prüfen, ob Einladung akzeptiert wurde; über *Team → Einladung erneut senden* resetten. Bei SSO Pflicht Zuordnung kontrollieren. +- **Gast kann nicht beitreten**: Event-Status muss *Published* sein; direkten Join-Link `https://app.fotospiel.com/join/` teilen. + +## Billing & Quoten +- Paddle-Webhook-Fehler sperrt Uploads: `storage/logs/paddle.log` prüfen, Webhook im Paddle-Dashboard erneut senden, anschließend Abo-Status toggeln. +- Speicher zu 90 % voll: Archivierung vorziehen oder Add-on im Paddle-Kundenportal buchen. + +## Kommunikationsvorlagen +Nutze die vorformulierten Antworten in `docs/content/fotospiel_howto_artikel_detailliert.md`, um Messaging konsistent zu halten. + +### Weitere Hilfe +Eskalation an reliability@fotospiel.app mit Event-ID, Kunde und Zeitstempel. Screenshots/Logs anhängen, wenn verfügbar. diff --git a/docs/help/admin/admin-issue-resolution.en.md b/docs/help/admin/admin-issue-resolution.en.md new file mode 100644 index 0000000..eb9ffbe --- /dev/null +++ b/docs/help/admin/admin-issue-resolution.en.md @@ -0,0 +1,37 @@ +--- +title: "Troubleshooting & Issue Resolution" +locale: en +slug: admin-issue-resolution +audience: admin +summary: "Playbooks for the most common admin-side incidents, from stuck uploads to billing locks." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: reliability@fotospiel.app +related: + - slug: live-ops-control + - slug: privacy-and-support +--- + +## Upload incidents +| Symptom | Diagnosis | Fix | +| --- | --- | --- | +| Queue stuck >10 min | Check Live Ops health widget | Run `php artisan media:backfill-thumbnails --tenant=XYZ` then reopen event | +| Specific guest blocked | Guest reached per-device limit | Increase limit under Event → Upload rules or ask them to clear drafts | +| Photos missing EXIF | Guest imported screenshots | No action; remind them that EXIF is optional | + +## Access issues +- **Admin cannot log in**: verify invite accepted; reset via *Team → Resend invite*. Check SSO mapping if enforced. +- **Guest cannot join**: confirm event status is *Published* and share direct join URL `https://app.fotospiel.com/join/`. + +## Billing & quotas +- Paddle webhook failure locks uploads: check `storage/logs/paddle.log`, re-send webhook via Paddle dashboard, then toggle the subscription status. +- Storage 90% full: run archive early or purchase add-on via Paddle customer portal. + +## Communication templates +Reuse the canned responses under `docs/content/fotospiel_howto_artikel_detailliert.md` to keep messaging consistent. + +### Need more help? +Escalate to reliability@fotospiel.app with the event ID, customer account, and timestamp. Attach screenshots/logs when possible. diff --git a/docs/help/admin/event-prep-checklist.de.md b/docs/help/admin/event-prep-checklist.de.md new file mode 100644 index 0000000..3fd992d --- /dev/null +++ b/docs/help/admin/event-prep-checklist.de.md @@ -0,0 +1,38 @@ +--- +title: "Checkliste Event-Vorbereitung" +locale: de +slug: event-prep-checklist +audience: admin +summary: "48-Stunden-Countdown, damit Geräte, Gäste und Automationen ready sind, bevor es losgeht." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: ops@fotospiel.app +related: + - slug: live-ops-control + - slug: post-event-wrapup +--- + +## 48–24 Stunden vorher +- [ ] Event in der Admin-App mit korrekter Zeitzone + Aufbewahrungsfrist anlegen. +- [ ] Titelbild (1200×630) hochladen und Übersetzungen für Titel/Beschreibung prüfen. +- [ ] Gästelisten importieren oder QR-Badges erzeugen. +- [ ] Push-Vorlagen testen (Reminder, Achievement-Freischaltung). + +## 24–2 Stunden vorher +- [ ] `tenant:attach-demo-event` im Staging ausführen, um den Ablauf mit dem Team zu proben. +- [ ] Join-QR nahe Eingang und Fotoboxen ausdrucken oder anzeigen. +- [ ] WLAN-SSID/Passwort-Beschilderung vorbereiten. +- [ ] Moderationsregeln mit Kundenvertrag abgleichen (z. B. explizite Inhalte blocken, Freigabe nötig). +- [ ] Paddle/RevenueCat-Status prüfen (alle Ampeln auf Grün). + +## Letzte 2 Stunden +- [ ] Demodaten aus dem Live-Event entfernen. +- [ ] Gäste-App auf Testgeräten öffnen und den Schnellstart durchspielen. +- [ ] Live-Ops-Ansicht auf Tablet/Laptop in Bühnennähe starten. +- [ ] Team zu Eskalationswegen briefen (Supportkontakte, Ersatzgeräte, Foto-Guidelines). + +### Weitere Hilfe +Siehe `live-ops-control` für Echtzeit-Monitoring oder melde dich bei ops@fotospiel.app. diff --git a/docs/help/admin/event-prep-checklist.en.md b/docs/help/admin/event-prep-checklist.en.md new file mode 100644 index 0000000..a6bb72e --- /dev/null +++ b/docs/help/admin/event-prep-checklist.en.md @@ -0,0 +1,38 @@ +--- +title: "Event Preparation Checklist" +locale: en +slug: event-prep-checklist +audience: admin +summary: "A 48-hour countdown to ensure devices, guests, and automations are ready before doors open." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: ops@fotospiel.app +related: + - slug: live-ops-control + - slug: post-event-wrapup +--- + +## 48–24 hours before +- [ ] Create the event in the Admin app with correct timezone + retention policy. +- [ ] Upload cover artwork (1200×630) and ensure translations exist for titles/descriptions. +- [ ] Import guest lists or generate QR badges if needed. +- [ ] Test push notification templates (reminders, achievement unlocks). + +## 24–2 hours before +- [ ] Run `tenant:attach-demo-event` in staging to rehearse workflow with staff. +- [ ] Print or display the join QR near entrance and photobooth areas. +- [ ] Prepare onsite Wi-Fi SSID/password signage. +- [ ] Confirm that automatic moderation rules match the client contract (e.g., block explicit content, require approval). +- [ ] Verify Paddle/RevenueCat status dashboards show green. + +## Final 2 hours +- [ ] Clear demo data from the live event. +- [ ] Open the guest app on test devices and complete the getting-started flow. +- [ ] Start the Live Ops screen on a tablet/laptop near the stage. +- [ ] Brief staff on escalation paths (support contacts, backup devices, photo guidelines). + +### Need more help? +Open `live-ops-control` for real-time monitoring tips or reach out to ops@fotospiel.app. diff --git a/docs/help/admin/index.de.md b/docs/help/admin/index.de.md new file mode 100644 index 0000000..052f333 --- /dev/null +++ b/docs/help/admin/index.de.md @@ -0,0 +1,25 @@ +--- +title: "Hilfecenter für Event-Admins" +locale: de +slug: admin-help-index +audience: admin +summary: "Betriebsleitfäden für Event-Verantwortliche: Onboarding, Setup, Live-Steuerung und Nachbereitung." +version_introduced: 2025.4 +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: cx-team@fotospiel.app +related: [] +--- + +Hier findest du alle Informationen, die du als Event-Admin für einen reibungslosen Ablauf brauchst. Die Artikel sind entlang des Event-Lebenszyklus sortiert: + +| Phase | Leitfragen | Artikel-Slug | +| --- | --- | --- | +| Konto & Team | Wie lade ich Mitarbeitende ein und setze Branding auf? | `tenant-dashboard-overview` | +| Event-Vorbereitung | Welche Checkliste erledige ich vor Einlass? | `event-prep-checklist` | +| Live-Betrieb | Wie überwache ich Uploads, moderiere Inhalte und sende Hinweise? | `live-ops-control` | +| Abschluss & Compliance | Wie funktionieren Export, Archiv und Datenschutz? | `post-event-wrapup` | +| Troubleshooting | Was tun bei Upload-Problemen, Geräteverlust oder Billing-Fragen? | `admin-issue-resolution` | + +Nutze die Navigationsleiste in der Admin-App für den Schnellzugriff oder öffne `/help/admin` im Desktop-Browser für die vollständige Ansicht mit Breadcrumbs und verwandten Artikeln. diff --git a/docs/help/admin/index.en.md b/docs/help/admin/index.en.md new file mode 100644 index 0000000..696c334 --- /dev/null +++ b/docs/help/admin/index.en.md @@ -0,0 +1,25 @@ +--- +title: "Customer Admin Help Center" +locale: en +slug: admin-help-index +audience: admin +summary: "Operational playbooks for event owners: onboarding, event setup, live control, and post-event delivery." +version_introduced: 2025.4 +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: cx-team@fotospiel.app +related: [] +--- + +This portal collects everything event admins need to configure customer accounts, run events smoothly, and resolve issues quickly. Articles are grouped by lifecycle: + +| Phase | Key Questions | Article Slug | +| --- | --- | --- | +| Account Setup | How do I invite staff and configure branding? | `tenant-dashboard-overview` | +| Event Preparation | What checklists should I complete before doors open? | `event-prep-checklist` | +| Live Operations | How do I monitor uploads, moderate content, and trigger announcements? | `live-ops-control` | +| Wrap-up & Compliance | How are exports, archives, and privacy handled? | `post-event-wrapup` | +| Troubleshooting | How to handle upload issues, device loss, billing, etc. | `admin-issue-resolution` | + +Use the navigation sidebar inside the admin app for faster access, or open `/help/admin` in a desktop browser for the full layout with breadcrumbs and related links. diff --git a/docs/help/admin/live-ops-control.de.md b/docs/help/admin/live-ops-control.de.md new file mode 100644 index 0000000..8a9f755 --- /dev/null +++ b/docs/help/admin/live-ops-control.de.md @@ -0,0 +1,39 @@ +--- +title: "Live-Ops-Steuerung" +locale: de +slug: live-ops-control +audience: admin +summary: "Uploads überwachen, Inhalte moderieren und Durchsagen versenden, während das Event läuft." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: ops@fotospiel.app +related: + - slug: event-prep-checklist + - slug: admin-issue-resolution +--- + +## Dashboard-Widgets +- **Upload-Durchsatz** – Fotos/Minute, farblich markiert bei Rückständen >25. +- **Gerätegesundheit** – Top-Geräte mit Fehlern (Berechtigung verweigert, Speicher voll). +- **Moderationswarteschlange** – gemeldete Fotos zur Freigabe; Moderator:innen zuweisen. +- **Ankündigungen** – Push/Banner erstellen; Sprachversionen möglich. + +## Typischer Ablauf +1. Live-Ops-Seite auf Tablet anheften. Auto-Refresh auf 15 Sekunden stellen. +2. Durchsatz beobachten, sobald Gäste eintreffen; direkt nach Zeremonien sind >40/min üblich. +3. Wächst der Rückstau, Banner senden („Bitte kurz online bleiben“ oder „Serienaufnahme reduzieren“). +4. Gemeldete Inhalte zügig bearbeiten; Policy verlangt Aktion innerhalb von 10 Minuten. +5. *Achievement-Trigger* nutzen, um Badges manuell zu vergeben, falls Automationen ausfallen. + +## Eskalationsmatrix +| Problem | Erste Aktion | Eskalation an | +| --- | --- | --- | +| Upload-Warteschlange fest | Health Check ausführen → Event erneut synchronisieren | Reliability Rufbereitschaft | +| Anstößiger Inhalt | Foto ausblenden → Beweis herunterladen → Veranstalter informieren | Legal Duty Officer | +| Billing-Lock | Paddle-Dashboard prüfen → Zahlungsstatus bestätigen | Finance | + +### Weitere Hilfe +Siehe `admin-issue-resolution` für detailliertes Troubleshooting oder melde dich im Slack-Channel #ops-help. diff --git a/docs/help/admin/live-ops-control.en.md b/docs/help/admin/live-ops-control.en.md new file mode 100644 index 0000000..d2afcf8 --- /dev/null +++ b/docs/help/admin/live-ops-control.en.md @@ -0,0 +1,39 @@ +--- +title: "Live Ops Control" +locale: en +slug: live-ops-control +audience: admin +summary: "Monitor uploads, moderate content, and push announcements while the event is live." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: ops@fotospiel.app +related: + - slug: event-prep-checklist + - slug: admin-issue-resolution +--- + +## Dashboard widgets +- **Upload throughput** – photos/minute, highlighted when backlog >25. +- **Device health** – top devices experiencing errors (permission denied, storage full). +- **Moderation queue** – flagged photos awaiting approval; assign to moderators. +- **Announcements** – compose push/banner messages; supports locale-specific text. + +## Typical workflow +1. Pin the Live Ops page on a tablet. Set auto-refresh to 15 seconds. +2. Watch the throughput graph as doors open; expect spike to 40+/min right after ceremonies. +3. If backlog grows, broadcast a banner reminding guests to stay online or reduce burst uploads. +4. Moderate flagged items quickly; policies require action within 10 minutes. +5. Use the *Achievement trigger* widget to award badges manually if automation criteria fail. + +## Escalation matrix +| Issue | First action | Escalate to | +| --- | --- | --- | +| Upload queue stuck | Run health check → re-sync event | Reliability on-call | +| Offensive content | Hide photo → download evidence → notify organizer | Legal duty officer | +| Billing lock | Check Paddle dashboard → confirm payment status | Finance | + +### Need more help? +Open `admin-issue-resolution` for detailed troubleshooting or ping #ops-help in Slack. diff --git a/docs/help/admin/post-event-wrapup.de.md b/docs/help/admin/post-event-wrapup.de.md new file mode 100644 index 0000000..25331a3 --- /dev/null +++ b/docs/help/admin/post-event-wrapup.de.md @@ -0,0 +1,33 @@ +--- +title: "Nachbereitung & Abschluss" +locale: de +slug: post-event-wrapup +audience: admin +summary: "Highlights exportieren, Daten archivieren und Datenschutzpflichten binnen 72 Stunden erfüllen." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: success@fotospiel.app +related: + - slug: event-prep-checklist + - slug: privacy-and-support +--- + +## Erste 24 Stunden +- Dankes-Push/E-Mail mit kuratierten Highlights senden (bis zu 40 Fotos auswählen → *Link teilen*). +- Admin-CSV exportieren (Uploads, Likes, Meldungen) für die eigene Ablage. +- Moderationswarteschlange prüfen, damit keine Meldung offen bleibt. + +## Innerhalb von 72 Stunden +- Aktion *Archivieren & Bereinigen* starten (Einstellungen → Datenlebenszyklus). Kopiert Medien in Cold Storage und löscht temporäre Caches. +- Gästen Download-Links bereitstellen, falls vertraglich zugesagt. +- Bei eingegangenen DSGVO-Löschanfragen Abschluss bestätigen und Ticket-ID dokumentieren. + +## Optionaler Follow-up +- Event als Vorlage duplizieren für zukünftige Produktionen. +- Erkenntnisse oder Verbesserungswünsche in `docs/todo/` festhalten. + +### Weitere Hilfe +Wende dich an success@fotospiel.app oder konsultiere die Legal-Pages-Ressource für Compliance-Formulierungen. diff --git a/docs/help/admin/post-event-wrapup.en.md b/docs/help/admin/post-event-wrapup.en.md new file mode 100644 index 0000000..193da72 --- /dev/null +++ b/docs/help/admin/post-event-wrapup.en.md @@ -0,0 +1,33 @@ +--- +title: "Post-Event Wrap-up" +locale: en +slug: post-event-wrapup +audience: admin +summary: "Export highlights, archive data, and fulfill privacy obligations within 72 hours after the event." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: success@fotospiel.app +related: + - slug: event-prep-checklist + - slug: privacy-and-support +--- + +## First 24 hours +- Send thank-you push/email with curated highlights (select up to 40 photos → *Share link*). +- Export admin CSV (uploads, likes, reports) for your records. +- Review moderation queue to ensure no reports remain unresolved. + +## Within 72 hours +- Trigger the *Archive & purge* action (Settings → Data Lifecycle). This copies media to cold storage and deletes transient caches. +- Provide guests with download links if promised in the contract. +- If GDPR deletion requests were filed, confirm completion and record the ticket ID. + +## Optional follow-up +- Duplicate the event as a template for future productions. +- Update `docs/todo/` with learnings or improvements for the product team. + +### Need more help? +Reach success@fotospiel.app or consult the Legal Pages resource for compliance wording. diff --git a/docs/help/admin/tenant-dashboard-overview.de.md b/docs/help/admin/tenant-dashboard-overview.de.md new file mode 100644 index 0000000..1a4aac6 --- /dev/null +++ b/docs/help/admin/tenant-dashboard-overview.de.md @@ -0,0 +1,34 @@ +--- +title: "Überblick: Kunden-Dashboard" +locale: de +slug: tenant-dashboard-overview +audience: admin +summary: "Mitarbeitende einladen, Branding konfigurieren und globale Kundeneinstellungen verstehen." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: onboarding@fotospiel.app +related: + - slug: event-prep-checklist + - slug: admin-issue-resolution +--- + +## Wann lesen? +Direkt nach dem Zugriff auf einen neuen Kunden oder wenn neue Mitarbeitende eingearbeitet werden. Das Kunden-Dashboard befindet sich in der Admin-App (Filament) und bietet auf Desktop dieselben Optionen. + +## Hauptbereiche +1. **Home** – Überblick über laufende Events, Speicherauslastung und offene Meldungen. +2. **Team** – Admins per E-Mail einladen, Rollen vergeben (Owner, Manager, Moderator). SSO via Azure AD/Google möglich, falls im Kundenkonto aktiviert. +3. **Branding** – Logos hochladen, Akzentfarben wählen, lokalisierten Begrüßungstext für die Gäste-App setzen. +4. **Rechtliches** – Impressum/Datenschutz/AGB über die Legal-Ressource pflegen; Änderungen greifen sofort. +5. **Integrationen** – Paddle-Keys, RevenueCat-App-IDs, Webhooks und Zapier-Tokens verwalten. Keine Secrets in Dokumente kopieren. + +## Best Practices +- Mindestens zwei Owner-Rollen für Redundanz halten. +- Branding oder Automationen zuerst im Staging-Kundenkonto testen. +- Einladungen im Änderungslog (`docs/changes/`) dokumentieren. + +### Weitere Hilfe +Siehe `event-prep-checklist` für Event-Vorbereitung oder kontaktiere cx-team@fotospiel.app für Onboarding-Support. diff --git a/docs/help/admin/tenant-dashboard-overview.en.md b/docs/help/admin/tenant-dashboard-overview.en.md new file mode 100644 index 0000000..508573c --- /dev/null +++ b/docs/help/admin/tenant-dashboard-overview.en.md @@ -0,0 +1,34 @@ +--- +title: "Customer Control Center Overview" +locale: en +slug: tenant-dashboard-overview +audience: admin +summary: "Invite staff, configure branding, and understand how customer-wide settings affect every event." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: onboarding@fotospiel.app +related: + - slug: event-prep-checklist + - slug: admin-issue-resolution +--- + +## When to read this +Right after receiving access to a new customer account or when onboarding new staff. The Customer Control Center lives in the Admin app (Filament) and mirrors most options on desktop. + +## Key areas +1. **Home** – snapshot of live events, storage usage, unresolved reports. +2. **Team** – invite admins via email, assign roles (Owner, Manager, Moderator). SSO via Azure AD/Google is available if enabled in customer settings. +3. **Branding** – upload logos, choose accent colors, set localized welcome text shown in the guest app. +4. **Legal pages** – edit Impressum/Privacy/AGB via the Legal resource; changes propagate instantly. +5. **Integrations** – manage Paddle keys, RevenueCat app IDs, webhooks, and Zapier tokens. Never paste secrets into articles. + +## Best practices +- Keep at least two Owner-level accounts for redundancy. +- Use the staging customer account to test branding or automation before touching production. +- Document invitations in the change log (`docs/changes/`). + +### Need more help? +See `event-prep-checklist` for event-level prep or contact cx-team@fotospiel.app for onboarding assistance. diff --git a/docs/help/guest/getting-started.de.md b/docs/help/guest/getting-started.de.md new file mode 100644 index 0000000..3355860 --- /dev/null +++ b/docs/help/guest/getting-started.de.md @@ -0,0 +1,36 @@ +--- +title: "Schnellstart: Event betreten" +locale: de +slug: getting-started +audience: guest +summary: "Fotospiel-App installieren, Event beitreten und die Grundgesten in unter zwei Minuten lernen." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: guest-success@fotospiel.app +related: + - slug: uploading-photos + - slug: offline-sync +--- + +## Wann lesen? +Du hast einen Event-Code oder QR-Link erhalten und möchtest sofort loslegen. Voraussetzung: aktueller mobiler Browser (Safari, Chrome, Edge, Samsung Internet) und einmalige Online-Verbindung für den Erstabgleich. + +## Schritte +1. **Einladungslink öffnen oder QR scannen.** Der Browser zeigt die Startseite der Fotospiel-Gäste-App. +2. **Installation für Vollbild aktivieren.** Tippe auf *Zum Home-Bildschirm* (iOS) bzw. *App installieren* (Android). Optional, aber empfohlen für Offline-Modus und schnellere Uploads. +3. **Event-Code eingeben.** Sechs Zeichen, Groß-/Kleinschreibung egal. Nach QR-Scan wird das Feld automatisch befüllt. +4. **Anzeigenamen wählen.** Dieser erscheint in der Event-Ansicht neben deinen Uploads. Kein Konto oder E-Mail nötig. +5. **Kamera- & Speicherzugriff erlauben.** Wähle „Einmal erlauben“ oder „Beim Verwenden der App“, damit Fotospiel Fotos speichern kann. +6. **Startpaket synchronisieren.** Die App lädt Alben, Achievements und Upload-Regeln herunter. Ein Fortschrittsbalken zeigt den Abschluss für den Offline-Modus. +7. **Gesten entdecken.** Nach oben wischen öffnet die Kamera, links/rechts wechselt das Album, Langdruck auf einem Foto ermöglicht Like oder Meldung. + +## Tipps +- Lege die App vor dem Event in die Dock/App-Leiste, damit du sie schnell wiederfindest. +- Teilen sich mehrere Gäste ein Gerät, setze den Anzeigenamen unter Einstellungen → Profil zwischen den Sessions zurück. +- Screenshots verlassen dein Gerät nur, wenn du sie aktiv hochlädst. + +### Weitere Hilfe +Siehe `uploading-photos` für Bearbeitungs- und Batch-Uploads oder `privacy-and-support` für Fragen zum Datenschutz. diff --git a/docs/help/guest/getting-started.en.md b/docs/help/guest/getting-started.en.md new file mode 100644 index 0000000..eb88059 --- /dev/null +++ b/docs/help/guest/getting-started.en.md @@ -0,0 +1,36 @@ +--- +title: "Quick Start: Join an Event" +locale: en +slug: getting-started +audience: guest +summary: "Install the Fotospiel app, join an event, and learn the core gestures in under two minutes." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: guest-success@fotospiel.app +related: + - slug: uploading-photos + - slug: offline-sync +--- + +## When to read this +You just received an event code or QR and want to start sharing photos. This guide assumes you have a modern mobile browser (Safari, Chrome, Edge, Samsung Internet) and basic connectivity once for the initial sync. + +## Steps +1. **Open the invite link or scan the QR.** The browser launches the Fotospiel guest app landing page. +2. **Install for full-screen mode.** Tap *Add to Home Screen* (iOS) or *Install app* (Android). Installation is optional but unlocks offline mode and faster uploads. +3. **Enter the event code.** Six characters, case-insensitive. If you scanned the QR, the field auto-fills. +4. **Choose a display name.** This appears next to your uploads within the event feed. No account or email needed. +5. **Grant camera & storage permissions.** Select “Allow once” or “Allow while using the app” so Fotospiel can capture and store photos locally. +6. **Sync starter pack.** The app downloads current albums, achievements, and upload rules. A progress bar ensures everything is cached offline. +7. **Explore gestures.** Swipe up to open the camera, left/right to switch album tabs, long-press a photo to like or report. + +## Tips +- Pin the app to your dock/home row before the event so you can reopen it instantly. +- If several guests share one device, clear the display name in Settings → Profile between sessions. +- Screenshots never leave your device unless you upload them manually. + +### Need more help? +See `uploading-photos` for editing and batch upload tips, or `privacy-and-support` if you have questions about data retention. diff --git a/docs/help/guest/index.de.md b/docs/help/guest/index.de.md new file mode 100644 index 0000000..461c402 --- /dev/null +++ b/docs/help/guest/index.de.md @@ -0,0 +1,29 @@ +--- +title: "Hilfecenter für Gäste" +locale: de +slug: guest-help-index +audience: guest +summary: "Alle Infos für Teilnehmer:innen, um die Fotospiel-Gäste-App zu installieren, einem Event beizutreten und Erinnerungen zu teilen." +version_introduced: 2025.4 +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: product-support@fotospiel.app +related: [] +--- + +Willkommen im Hilfebereich für Gäste. Starte mit dem Schnellstart, wenn du Fotospiel zum ersten Mal nutzt. Die Tabelle verlinkt auf alle Leitfäden (jeder Artikel liegt auf Deutsch und Englisch vor). + +| Thema | Zweck | Artikel-Slug | +| --- | --- | --- | +| Schnellstart | App installieren, Event-Code eingeben und die wichtigsten Gesten lernen. | `getting-started` | +| Upload-Workflow | Fotos aufnehmen, bearbeiten und mit Hintergrund-Sync hochladen. | `uploading-photos` | +| Offline-Modus & Sync | Teilnehmen auch ohne Netz und sicherstellen, dass nichts verloren geht. | `offline-sync` | +| Datenschutz & Support | Welche Daten gespeichert werden und wie du Hilfe kontaktierst. | `privacy-and-support` | + +### So nutzt du die Hilfe +- **Suche**: Verwende die Suche im Hilfebereich oder die Offline-Suche in den App-Einstellungen. Begriffe wie „Upload-Limit“ oder „Link teilen“ funktionieren. +- **Sprache umschalten**: Klick auf das Globus-Symbol innerhalb jedes Artikels, um zwischen DE/EN zu wechseln. +- **Kontext-Links**: Zahlreiche UI-Elemente mit `?`-Symbol führen direkt zum passenden Abschnitt. + +Mehr Unterstützung? Tippe in der Gäste-App auf *Kontakt zum Support* (Einstellungen → Hilfe) und nenne deine Event-ID. diff --git a/docs/help/guest/index.en.md b/docs/help/guest/index.en.md new file mode 100644 index 0000000..335eca1 --- /dev/null +++ b/docs/help/guest/index.en.md @@ -0,0 +1,29 @@ +--- +title: "Guest Help Center" +locale: en +slug: guest-help-index +audience: guest +summary: "Everything attendees need to install the Fotospiel guest app, join events, and share memories." +version_introduced: 2025.4 +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: product-support@fotospiel.app +related: [] +--- + +Welcome to the guest-focused documentation hub. Start with the Quick Start article if this is your first time using Fotospiel. Use the table below to jump to individual guides (each article has both English and German versions). + +| Topic | Purpose | Article Slug | +| --- | --- | --- | +| Quick Start | Install the app, join an event, and learn the basic gestures. | `getting-started` | +| Upload Workflow | Capture, edit, and upload photos with background sync. | `uploading-photos` | +| Offline Mode & Sync | Keep participating when connectivity drops and ensure nothing is lost. | `offline-sync` | +| Privacy & Support | Understand what data is stored and how to reach support. | `privacy-and-support` | + +### How to use these docs +- **Search**: Use the in-app search bar or offline search in the app settings screen. Keywords like “upload limit” or “share link” are supported. +- **Language toggle**: Switch between EN/DE with the globe icon inside every article. +- **Contextual links**: Many UI screens offer a `?` icon that deep-links directly to the relevant section here. + +Need more help? Tap *Contact Support* inside the guest app → Settings → Help. Provide the event ID so we can assist faster. diff --git a/docs/help/guest/offline-sync.de.md b/docs/help/guest/offline-sync.de.md new file mode 100644 index 0000000..b636088 --- /dev/null +++ b/docs/help/guest/offline-sync.de.md @@ -0,0 +1,40 @@ +--- +title: "Offline-Modus & Synchronisierung" +locale: de +slug: offline-sync +audience: guest +summary: "Auch ohne Netz teilnehmen, Uploads sicher zwischenspeichern und den Status prüfen." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: reliability@fotospiel.app +related: + - slug: uploading-photos + - slug: privacy-and-support +--- + +## Wann lesen? +Du rechnest mit schwacher oder fehlender Verbindung (Gebirge, Keller, Roaming). Sobald die Erst-Synchronisierung abgeschlossen ist, funktioniert die Gäste-App vollständig offline weiter. + +## Was bleibt offline verfügbar? +- Event-Feed (die letzten 250 Fotos) und Albumstruktur. +- Upload-Regeln (Größenlimit, Moderationseinstellungen). +- Entwürfe für Achievements und Sticker. +- Auszug des Hilfecenters (Top‑10 Artikel pro Sprache), sofern du den Hilfebereich mindestens einmal online geöffnet hast. + +## Offline-Workflow +1. **Wie gewohnt aufnehmen.** Alles landet verschlüsselt in der lokalen Warteschlange. +2. **Ausstehende Uploads prüfen.** Achte auf das graue Label *In Warteschlange*. Tags/Notizen lassen sich auch offline ergänzen. +3. **Speicher überwachen.** Ein Banner warnt unter 500 MB freiem Speicher; lösche gesendete Inhalte oder nutze ein anderes Gerät. +4. **Kurz online gehen.** Sobald irgendein Netz verfügbar ist, Fotospiel öffnen. Die Sync startet automatisch und priorisiert die ältesten Elemente. +5. **Abschluss bestätigen.** Ein grüner Hinweis „Alle Uploads übertragen“ erscheint und der Warteschlangen-Zähler springt auf Null. + +## Troubleshooting +- **Bleibt trotz Netz auf „In Warteschlange“?** Flugmodus kurz aktivieren/deaktivieren und App neu öffnen, um den Service Worker zurückzusetzen. +- **Gerätewechsel?** Offline-Warteschlangen verbleiben auf dem ursprünglichen Gerät; kein Abgleich zwischen Geräten. +- **Energiesparmodus** kann Hintergrundsync pausieren. Für große Mengen die App im Vordergrund lassen. + +### Weitere Hilfe +Unter Einstellungen → Hilfe → *Diagnosedaten senden* kannst du (sobald du online bist) anonymisierte Logs plus deine Event-ID an den Support schicken. diff --git a/docs/help/guest/offline-sync.en.md b/docs/help/guest/offline-sync.en.md new file mode 100644 index 0000000..2e6078f --- /dev/null +++ b/docs/help/guest/offline-sync.en.md @@ -0,0 +1,40 @@ +--- +title: "Offline Mode & Sync" +locale: en +slug: offline-sync +audience: guest +summary: "Participate without coverage, queue uploads safely, and know when everything is delivered." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: reliability@fotospiel.app +related: + - slug: uploading-photos + - slug: privacy-and-support +--- + +## When to read this +You expect intermittent connectivity (mountains, cellars, roaming). The guest app is built to keep working offline as long as the initial sync completed. + +## What stays available offline +- Event feed (latest 250 photos) and album structure. +- Upload rules (size limits, moderation settings). +- Draft achievements and stickers. +- Help center excerpt (top 10 articles per locale) if you opened Help at least once online. + +## Offline workflow +1. **Capture as usual.** Everything stores in the encrypted local queue. +2. **Review pending uploads.** Look for the grey *Queued* label. Add tags/notes even while offline. +3. **Monitor storage.** The banner warns if device storage drops below 500 MB; delete sent items or transfer to another device. +4. **Reconnect briefly.** Once any network is available, open Fotospiel. Sync restarts automatically, prioritizing oldest items. +5. **Confirm completion.** A green toast “All uploads delivered” appears and the queue counter returns to zero. + +## Troubleshooting +- **Stuck in “Queued” despite coverage?** Toggle airplane mode off/on, then reopen the app to reset the service worker. +- **Different devices?** Offline queues stay on the original device; there’s no cross-device merge. +- **Battery saver** might pause background sync. Keep the app in the foreground for large batches. + +### Need more help? +Use Settings → Help → *Send diagnostics* once you are online; support receives anonymized logs plus your event ID. diff --git a/docs/help/guest/privacy-and-support.de.md b/docs/help/guest/privacy-and-support.de.md new file mode 100644 index 0000000..68d6df8 --- /dev/null +++ b/docs/help/guest/privacy-and-support.de.md @@ -0,0 +1,38 @@ +--- +title: "Datenschutz & Support" +locale: de +slug: privacy-and-support +audience: guest +summary: "Welche Daten gespeichert werden, wie du Löschungen anstößt und wie du Hilfe erreichst." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: legal@fotospiel.app +related: + - slug: getting-started + - slug: offline-sync +--- + +## Welche Daten speichern wir? +- **Fotos & Bildunterschriften**: Liegen verschlüsselt im Speicher des Kundenkontos für den vom Veranstalter definierten Zeitraum. +- **Session-ID**: Anonymer Token vom Gerät zur Upload-Nachverfolgung; wird zurückgesetzt, wenn du die App-Daten löscht. +- **Geräte-Metadaten**: Nur Modell + Betriebssystem-Version für Crash-Analysen. Keine Standort-, Kontakt- oder Werbe-IDs. + +## Deine Kontrollmöglichkeiten +1. **Einzelne Uploads löschen**: Foto öffnen → `…` → *Aus Event entfernen*. Du kannst nur eigene Inhalte löschen. +2. **Lokalen Cache leeren**: Einstellungen → Speicher → *Gerätekopien löschen*. Entfernt Miniaturen und Entwürfe. +3. **Komplette Löschung anfordern**: Einstellungen → Hilfe → *Datenlöschung anfragen*. E-Mail für Bestätigung angeben; wir leiten an den Event-Admin weiter. + +## Support-Kanäle +- **In-App**: Einstellungen → Hilfe → *Support kontaktieren*. Optional Screenshot + Diagnosepaket anhängen. +- **E-Mail**: guests@fotospiel.app (Event-Code + Gerät nennen). +- **Vor Ort**: Event-Personal ansprechen; sie eskalieren über die Admin-App. + +## Antwortzeiten +- Kritische Probleme (Uploads für gesamtes Event gestört): <15 Minuten. +- Individuelle Lösch- oder Datenschutzanfragen: innerhalb von 48 Stunden. + +### Weitere Hilfe +Rechtsseiten (Impressum, Datenschutz, AGB) findest du unter Einstellungen → Rechtliches. Für kundenspezifische Regelungen kontaktiere den Veranstalter direkt. diff --git a/docs/help/guest/privacy-and-support.en.md b/docs/help/guest/privacy-and-support.en.md new file mode 100644 index 0000000..2f55350 --- /dev/null +++ b/docs/help/guest/privacy-and-support.en.md @@ -0,0 +1,38 @@ +--- +title: "Privacy & Getting Help" +locale: en +slug: privacy-and-support +audience: guest +summary: "Understand what data is stored, how to request deletions, and how to contact support." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: legal@fotospiel.app +related: + - slug: getting-started + - slug: offline-sync +--- + +## Data we store +- **Photos & captions**: Stored on the customer account’s encrypted storage for the retention period defined by the event organizer. +- **Session ID**: Anonymous token generated on your device for upload tracking; resets if you clear app storage. +- **Device metadata**: Only model + OS version, used for crash insights. No location, contacts, or advertising IDs. + +## Your controls +1. **Delete individual uploads**: Open the photo → tap `…` → *Remove from event*. You can delete only your own items. +2. **Erase local cache**: Settings → Storage → *Clear device copies*. This removes cached thumbnails and drafts. +3. **Request full erasure**: Use Settings → Help → *Request data deletion*. Provide email for confirmation; we forward the request to the event admin who controls the customer account. + +## Support channels +- **In-app**: Settings → Help → *Contact support*. Includes optional screenshot + diagnostics bundle. +- **Email**: guests@fotospiel.app (mention event code + device model). +- **On-site**: Ask the event staff to escalate via the customer admin app. + +## Response times +- Critical issues (uploads failing for entire event): <15 minutes. +- Individual deletion or privacy questions: within 48 hours. + +### Need more help? +Review the public legal pages (Impressum, Privacy, AGB) under Settings → Legal, or contact the event organizer directly for customer-specific policies. diff --git a/docs/help/guest/uploading-photos.de.md b/docs/help/guest/uploading-photos.de.md new file mode 100644 index 0000000..1dbdea7 --- /dev/null +++ b/docs/help/guest/uploading-photos.de.md @@ -0,0 +1,39 @@ +--- +title: "Fotos aufnehmen & hochladen" +locale: de +slug: uploading-photos +audience: guest +summary: "Integrierte Kamera nutzen, Aufnahmen bearbeiten und bei Funklöchern auf Hintergrund-Sync setzen." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: guest-success@fotospiel.app +related: + - slug: getting-started + - slug: offline-sync +--- + +## Wann lesen? +Du bist bereits einem Event beigetreten und möchtest verstehen, wie der Aufnahme-Workflow funktioniert, welche Qualitätsgrenzen gelten und was bei Verbindungsproblemen passiert. + +## Schritt für Schritt +1. **Fotospiel-Kamera öffnen.** Vom Feed nach oben wischen oder auf das Auslösersymbol tippen. +2. **Aufnahmemodus wählen.** + - *Einzelfoto*: Standardmodus mit HDR-Anpassung. + - *Serie*: Auslöser halten, bis zu 5 Bilder; die App wählt automatisch das schärfste. + - *Import*: Miniatur antippen, um vorhandene Fotos/Screenshots zu laden. +3. **Anpassungen vornehmen.** Zuschneiden, drehen oder optionalen Text-Sticker hinzufügen. Alles passiert lokal. Mit *Speichern* bestätigen. +4. **Album & Tags setzen.** Ordne das Foto dem passenden Kapitel (z. B. Trauung) zu und füge bei Bedarf Stimmungstags an. Alben sind offline vorhanden. +5. **Upload-Warteschlange prüfen.** Offene Elemente erscheinen im Tab `Uploads` mit Status-Badge: *In Warteschlange*, *Sendet* oder *Erfordert Aktion*. +6. **Hintergrund-Sync abwarten.** Beim Schließen sendet die App noch ca. 30 Sekunden (Systemlimit). Später öffnen setzt den Upload automatisch fort. +7. **Fehler beheben.** Warnsymbol tippen → *Jetzt erneut versuchen* oder *Löschen*. Häufige Ursachen: Flugmodus, entzogene Berechtigungen, Speicher voll. + +## Tipps +- Fotos übernehmen die Gerätezeit. Unter "Event-Zeit verwenden" kannst du die Metadaten angleichen. +- Markiere bis zu 10 wartende Uploads gleichzeitig zum Löschen oder erneuten Senden. +- Likes und Kommentare synchronisieren getrennt und blockieren den Foto-Upload nicht. + +### Weitere Hilfe +Siehe `offline-sync` für längere Offline-Phasen oder kontaktiere den Support unter Einstellungen → Hilfe. diff --git a/docs/help/guest/uploading-photos.en.md b/docs/help/guest/uploading-photos.en.md new file mode 100644 index 0000000..d908c99 --- /dev/null +++ b/docs/help/guest/uploading-photos.en.md @@ -0,0 +1,39 @@ +--- +title: "Capture & Upload Photos" +locale: en +slug: uploading-photos +audience: guest +summary: "Use the built-in camera, edit shots, and rely on background sync if connectivity drops." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: aligned +last_reviewed_at: 2025-02-22 +owner: guest-success@fotospiel.app +related: + - slug: getting-started + - slug: offline-sync +--- + +## When to read this +You already joined an event and want to understand the capture workflow, quality limits, and what happens if uploads fail or the connection disappears. + +## Step-by-step +1. **Open the Fotospiel camera.** Swipe up from the timeline or tap the shutter icon. +2. **Pick a capture mode.** + - *Single shot*: default mode with HDR tuning. + - *Burst*: hold the shutter to capture up to 5 frames; the app picks the sharpest by default. + - *Import*: tap the gallery thumbnail to select existing photos/screenshots. +3. **Apply adjustments.** Crop, rotate, or add the optional text sticker. All edits happen on-device. Tap *Save* to confirm. +4. **Choose the album & tags.** Assign to the correct chapter (e.g., Ceremony) and optionally add mood tags. Albums are cached offline. +5. **Review upload queue.** Pending items appear in the `Uploads` tab with a status pill: *Queued*, *Sending*, or *Needs attention*. +6. **Let background sync finish.** Closing the app keeps uploads going for ~30 seconds (platform limit). Reopen later to resume automatically. +7. **Fix failed uploads.** Tap the warning icon → *Retry now* or *Delete*. Common issues: airplane mode, revoked permissions, storage full. + +## Tips +- Photos inherit the device timestamp; if clocks differ from event time, toggling “Use event time” adjusts metadata. +- Batch-select up to 10 pending uploads to delete or retry at once. +- Likes and comments sync separately and don’t block photo uploads. + +### Need more help? +Read `offline-sync` for long offline stretches or contact support from Settings → Help. diff --git a/docs/help/templates/article.de.md b/docs/help/templates/article.de.md new file mode 100644 index 0000000..1e521df --- /dev/null +++ b/docs/help/templates/article.de.md @@ -0,0 +1,30 @@ +--- +title: "" +locale: de +slug: +audience: guest +summary: "Kurzer Teaser für Listenansichten." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: draft +last_reviewed_at: 2025-02-22 +owner: +related: + - slug: +--- + +> Halte Absätze kurz (max. 3 Sätze) und nutze nummerierte Listen für Abläufe. + +## Wann ist dieser Artikel relevant? +Szenario, Voraussetzungen und erwartetes Ergebnis beschreiben. + +## Schritte +1. Schrittbeschreibung +2. … + +### Tipps +- Optionale Hinweise, Varianten oder FAQs. + +### Weitere Hilfe +Auf Support-Optionen oder verknüpfte Artikel verweisen. diff --git a/docs/help/templates/article.en.md b/docs/help/templates/article.en.md new file mode 100644 index 0000000..73c8967 --- /dev/null +++ b/docs/help/templates/article.en.md @@ -0,0 +1,30 @@ +--- +title: "" +locale: en +slug: <slug> +audience: guest +summary: "1–2 sentence preview for list views." +version_introduced: 2025.4 +requires_app_version: "^3.2.0" +status: draft +translation_state: draft +last_reviewed_at: 2025-02-22 +owner: <team or person> +related: + - slug: <other-slug> +--- + +> Keep paragraphs short (max ~3 sentences) and favor ordered lists for procedures. + +## When to read this +Explain the scenario, prerequisites, and expected outcome. + +## Steps +1. Step explaination +2. … + +### Tips +- Optional tips, variations, or FAQs. + +### Need more help? +Point to support options or related articles. diff --git a/docs/photobooth_ftp/README.md b/docs/photobooth_ftp/README.md new file mode 100644 index 0000000..a165559 --- /dev/null +++ b/docs/photobooth_ftp/README.md @@ -0,0 +1,102 @@ +# Photobooth FTP Ingestion + +This guide explains how to operate the Photobooth FTP workflow end‑to‑end: provisioning FTP users for tenants, running the ingest pipeline, and exposing photobooth photos inside the Guest PWA. + +## Architecture Overview + +1. **vsftpd container** (port `2121`) accepts uploads into a shared volume (default `/var/www/storage/app/photobooth`). Each event receives isolated credentials and a dedicated directory. +2. **Control Service** (REST) provisions FTP accounts. Laravel calls it during enable/rotate/disable actions. +3. **Photobooth settings** (Filament SuperAdmin) define global port, rate limit, expiry grace, and Control Service connection. +4. **Ingest command** copies uploaded files into the event’s storage disk, generates thumbnails, records `photos.ingest_source = photobooth`, and respects package quotas. +5. **Guest PWA filter** consumes `/api/v1/events/{token}/photos?filter=photobooth` to render the “Fotobox” tab. + +``` +Photobooth -> FTP (vsftpd) -> photobooth disk + photobooth:ingest (queue/scheduler) + -> Event media storage (public disk/S3) + -> packages_usage, thumbnails, security scan +``` + +## Environment Variables + +Add the following to `.env` (already scaffolded in `.env.example`): + +```env +PHOTOBOOTH_CONTROL_BASE_URL=https://control.internal/api +PHOTOBOOTH_CONTROL_TOKEN=your-control-token +PHOTOBOOTH_CONTROL_TIMEOUT=5 + +PHOTOBOOTH_FTP_HOST=ftp.internal +PHOTOBOOTH_FTP_PORT=2121 + +PHOTOBOOTH_USERNAME_PREFIX=pb +PHOTOBOOTH_USERNAME_LENGTH=8 +PHOTOBOOTH_PASSWORD_LENGTH=8 + +PHOTOBOOTH_RATE_LIMIT_PER_MINUTE=20 +PHOTOBOOTH_EXPIRY_GRACE_DAYS=1 + +PHOTOBOOTH_IMPORT_DISK=photobooth +PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth +PHOTOBOOTH_IMPORT_MAX_FILES=50 +PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp +``` + +### Filesystem Disk + +`config/filesystems.php` registers a `photobooth` disk that must point to the shared volume where vsftpd writes files. Mount the same directory inside both the FTP container and the Laravel app container. + +## Control Service Contract + +Laravel expects the Control Service to expose: + +``` +POST /users { username, password, path, rate_limit_per_minute, expires_at, ftp_port } +POST /users/{username}/rotate { password, rate_limit_per_minute, expires_at } +DELETE /users/{username} +POST /config { ftp_port, rate_limit_per_minute, expiry_grace_days } +``` + +Authentication is provided via `PHOTOBOOTH_CONTROL_TOKEN` (Bearer token). + +## Scheduler & Commands + +| Command | Purpose | Default schedule | +|---------|---------|------------------| +| `photobooth:ingest [--event=ID] [--max-files=N]` | Pulls files from the Photobooth disk and imports them into the event storage. | every 5 minutes | +| `photobooth:cleanup-expired` | De-provisions FTP accounts after their expiry. | hourly | + +You can run the ingest job manually for a specific event: + +```bash +php artisan photobooth:ingest --event=123 --max-files=20 +``` + +## Tenant Admin UX + +Inside the Event Admin PWA, go to **Event → Fotobox-Uploads** to: + +1. Enable/disable the Photobooth link. +2. Rotate credentials (max 10-char usernames, 8-char passwords). +3. View rate limit + expiry info and copy the ftp:// link. + +## Guest PWA Filter + +The Guest gallery now exposes a “Fotobox” tab (both preview card and full gallery). API usage: + +``` +GET /api/v1/events/{token}/photos?filter=photobooth +Headers: X-Device-Id (optional) +``` + +Response items contain `ingest_source`, allowing the frontend to toggle photobooth-only views. + +## Operational Checklist + +1. **Set env vars** from above and restart the app. +2. **Ensure vsftpd + Control Service** are deployed; verify port 2121 and REST endpoint connectivity. +3. **Mount shared volume** to `/var/www/storage/app/photobooth` (or update `PHOTOBOOTH_IMPORT_ROOT` + `filesystems.disks.photobooth.root`). +4. **Run migrations** (`php artisan migrate`) to create settings/event columns. +5. **Seed default storage target** (e.g., `MediaStorageTarget::create([... 'key' => 'public', ...])`) in non-test environments if not present. +6. **Verify scheduler** (Horizon or cron) is running commands `photobooth:ingest` and `photobooth:cleanup-expired`. +7. **Test end-to-end**: enable Photobooth on a staging event, upload a file via FTP, wait for ingest, and confirm it appears under the Fotobox filter in the PWA. diff --git a/docs/photobooth_ftp/control_service.md b/docs/photobooth_ftp/control_service.md new file mode 100644 index 0000000..2b34a4f --- /dev/null +++ b/docs/photobooth_ftp/control_service.md @@ -0,0 +1,97 @@ +# Photobooth Control Service API + +The control service is a lightweight sidecar responsible for provisioning vsftpd accounts. Laravel talks to it via REST whenever an Event Admin enables, rotates, or disables the Photobooth feature. + +## Authentication + +- **Scheme:** Bearer token. +- **Header:** `Authorization: Bearer ${PHOTOBOOTH_CONTROL_TOKEN}`. +- **Timeout:** Configurable via `PHOTOBOOTH_CONTROL_TIMEOUT` (default 5 s). + +## Endpoints + +| Method & Path | Description | +|---------------|-------------| +| `POST /users` | Create a new FTP account for an event. | +| `POST /users/{username}/rotate` | Rotate credentials / extend expiry for an existing user. | +| `DELETE /users/{username}` | Remove an FTP account (called when an event disables Photobooth or expires). | +| `POST /config` | Optionally push global config changes (port, rate-limit, expiry grace) to the control service. | + +### `POST /users` + +```json +{ + "username": "pbA12345", + "password": "F4P9K2QX", + "path": "tenant-slug/123", + "rate_limit_per_minute": 20, + "expires_at": "2025-06-15T22:59:59Z", + "ftp_port": 2121, + "allowed_ip_ranges": ["1.2.3.4/32"], + "metadata": { + "event_id": 123, + "tenant_id": 5 + } +} +``` + +**Response:** `201 Created` with `{ "ok": true }`. On failure return 4xx/5xx JSON with `error.code` + `message`. + +Implementation tips: + +- Ensure the system user or virtual user’s home directory is set to the provided `path` (prefixed with the shared Photobooth root). +- Apply the rate limit token-bucket before writing to disk (or integrate with HAProxy). +- Store `expires_at` and automatically disable the account when reached (in addition to Laravel’s scheduled cleanup). + +### `POST /users/{username}/rotate` + +```json +{ + "password": "K9M4T6QZ", + "rate_limit_per_minute": 20, + "expires_at": "2025-06-16T22:59:59Z" +} +``` + +- Rotate the password atomically and respond with `{ "ok": true }`. +- If username does not exist return `404` with a descriptive message so Laravel can re-provision. + +### `DELETE /users/{username}` + +No request body. Delete or disable the FTP account, removing access to the assigned directory. + +### `POST /config` + +Optional hook used when SuperAdmins change defaults: + +```json +{ + "ftp_port": 2121, + "rate_limit_per_minute": 20, + "expiry_grace_days": 1 +} +``` + +Use this to reload vsftpd or adjust proxy rules without redeploying the control service. + +## Error Contract + +Return JSON structured as: + +```json +{ + "error": { + "code": "user_exists", + "message": "Username already provisioned", + "context": { "username": "pbA12345" } + } +} +``` + +Laravel treats any non-2xx as fatal and logs the payload (sans password). Prefer descriptive `code` values: `user_exists`, `user_not_found`, `rate_limit_violation`, `invalid_payload`, etc. + +## Observability + +- Emit structured logs for every create/rotate/delete with event + tenant IDs. +- Expose `/health` so Laravel (or uptime monitors) can verify connectivity. +- Consider metrics (e.g., Prometheus) for active accounts, rotations, and failures. diff --git a/docs/photobooth_ftp/ops_playbook.md b/docs/photobooth_ftp/ops_playbook.md new file mode 100644 index 0000000..5e15b48 --- /dev/null +++ b/docs/photobooth_ftp/ops_playbook.md @@ -0,0 +1,53 @@ +# Photobooth Operations Playbook + +Use this checklist when bringing Photobooth FTP online for a tenant or debugging ingest issues. + +## 1. Provisioning Flow + +1. **SuperAdmin config** – set defaults in Filament → Platform Management → Photobooth Settings. +2. **Tenant enablement** – Event Admin opens the event → Fotobox-Uploads → “Photobooth aktivieren”. +3. Laravel generates credentials and calls the control service (`POST /users`). +4. vsftpd accepts uploads at `ftp://username:password@HOST:PORT/`. +5. `photobooth:ingest` copies files into the hot storage disk and applies moderation/security pipelines. + +## 2. Troubleshooting + +| Symptom | Action | +|---------|--------| +| Tenant’s Photobooth page shows “Deaktiviert” immediately | Check `storage/logs/laravel.log` for control-service errors; re-run `photobooth:ingest --event=ID -vv`. | +| Files remain under `/storage/app/photobooth/<tenant>/<event>` | Ensure scheduler (Horizon/cron) runs `photobooth:ingest`; run manual command to force ingestion. | +| Photos missing from guest “Fotobox” tab | Confirm `photos.ingest_source = photobooth` and that `/api/v1/events/{token}/photos?filter=photobooth` returns data. | +| Rate-limit complaints | Inspect control service logs; adjust `PHOTOBOOTH_RATE_LIMIT_PER_MINUTE` and re-save settings (fires `/config`). | +| Credentials leaked/compromised | Click “Zugang neu generieren” in Event Admin; optional `php artisan photobooth:cleanup-expired --event=ID` to force deletion before expiry. | + +## 3. Command Reference + +```bash +# Manually ingest pending files for a single event +php artisan photobooth:ingest --event=123 --max-files=100 + +# Check ingest for all active events (dry run) +php artisan photobooth:ingest --max-files=10 + +# Remove expired accounts (safe to run ad hoc) +php artisan photobooth:cleanup-expired +``` + +## 4. Pre-flight Checklist for New Deployments + +1. `php artisan migrate` +2. Configure `.env` Photobooth variables. +3. Mount shared Photobooth volume in all containers (FTP + Laravel). +4. Verify `MediaStorageTarget` records exist (hot target pointing at the hot disk). +5. Seed baseline emotions (Photobooth ingest assigns `emotion_id` from existing rows). +6. Confirm scheduler runs (Horizon supervisor or system cron). + +## 5. Incident Response + +1. **Identify scope** – which events/tenants are affected? Check ingestion logs for specific usernames/path. +2. **Quarantine** – disable the Photobooth toggle for impacted events via Admin UI. +3. **Remediate** – fix FTP/control issues, rotate credentials, run `photobooth:ingest`. +4. **Audit** – review `photobooth_metadata` on events and `photos.ingest_source`. +5. **Communicate** – notify tenant admins via in-app message or email template referencing incident ID. + +Keep this playbook updated whenever infra/process changes. PRs to `/docs/photobooth_ftp` welcome. diff --git a/docs/queue-supervisor/README.md b/docs/queue-supervisor/README.md index 9442bdd..fb58d29 100644 --- a/docs/queue-supervisor/README.md +++ b/docs/queue-supervisor/README.md @@ -115,3 +115,15 @@ When deploying new code: 2. Run migrations & seeders. 3. Recreate worker/horizon containers: `docker compose up -d --force-recreate queue-worker media-storage-worker horizon`. 4. Tail logs to confirm workers boot cleanly and start consuming jobs. + +### 8. Running inside Coolify + +If you host Fotospiel on Coolify: + +- Create separate Coolify “services” for each worker type using the same image and command snippets above (`queue-worker.sh default`, `media-storage`, etc.). +- Attach the same environment variables and storage volumes defined for the main app. +- Use Coolify’s “One-off command” feature to run migrations or `queue:retry`. +- Expose the Horizon service through Coolify’s HTTP proxy (or keep it internal and access via SSH tunnel). +- Enable health checks so Coolify restarts workers automatically if they exit unexpectedly. + +These services can be observed and restarted from Coolify’s dashboard; the upcoming SuperAdmin integration will surface the same metrics/actions through a dedicated Filament widget. diff --git a/public/manifest.json b/public/manifest.json index 7217f01..c7bd119 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,5 +1,5 @@ { - "name": "Fotospiel Tenant Admin", + "name": "Fotospiel Customer Admin", "short_name": "Fotospiel Admin", "id": "/event-admin", "start_url": "/event-admin/", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 2fcb0f7..de9ccc3 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -98,6 +98,7 @@ export type TenantPhoto = { likes_count: number; uploaded_at: string; uploader_name: string | null; + ingest_source?: string | null; caption?: string | null; }; @@ -114,6 +115,40 @@ export type EventStats = { pending_photos?: number; }; +export type PhotoboothStatus = { + enabled: boolean; + status: string | null; + username: string | null; + password: string | null; + path: string | null; + ftp_url: string | null; + expires_at: string | null; + rate_limit_per_minute: number; + ftp: { + host: string | null; + port: number; + require_ftps: boolean; + }; +}; + +export type HelpCenterArticleSummary = { + slug: string; + title: string; + summary: string; + updated_at?: string; + status?: string; + translation_state?: string; + related?: Array<{ slug: string }>; +}; + +export type HelpCenterArticle = HelpCenterArticleSummary & { + body_html?: string; + body_markdown?: string; + owner?: string; + requires_app_version?: string | null; + version_introduced?: string; +}; + export type PaginationMeta = { current_page: number; last_page: number; @@ -184,6 +219,60 @@ export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus | } } +function resolveHelpLocale(locale?: string): 'de' | 'en' { + if (!locale) { + return 'de'; + } + const normalized = locale.toLowerCase().split('-')[0]; + return normalized === 'en' ? 'en' : 'de'; +} + +export async function fetchHelpCenterArticles(locale?: string): Promise<HelpCenterArticleSummary[]> { + const resolvedLocale = resolveHelpLocale(locale); + + try { + const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale }); + const response = await authorizedFetch(`/api/v1/help?${params.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch help articles'); + } + + const payload = (await response.json()) as { data?: HelpCenterArticleSummary[] }; + return Array.isArray(payload?.data) ? payload.data : []; + } catch (error) { + const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); + emitApiErrorEvent({ message, code: 'help.fetch_list_failed' }); + console.error('[HelpApi] Failed to fetch help articles', error); + throw error; + } +} + +export async function fetchHelpCenterArticle(slug: string, locale?: string): Promise<HelpCenterArticle> { + const resolvedLocale = resolveHelpLocale(locale); + + try { + const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale }); + const response = await authorizedFetch(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch help article'); + } + + const payload = (await response.json()) as { data?: HelpCenterArticle }; + if (!payload?.data) { + throw new Error('Empty help article response'); + } + + return payload.data; + } catch (error) { + const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'); + emitApiErrorEvent({ message, code: 'help.fetch_detail_failed' }); + console.error('[HelpApi] Failed to fetch help article', error); + throw error; + } +} + export type TenantPackageSummary = { id: number; package_id: number; @@ -883,6 +972,38 @@ function eventEndpoint(slug: string): string { return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; } +function photoboothEndpoint(slug: string): string { + return `${eventEndpoint(slug)}/photobooth`; +} + +function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus { + const ftp = (payload.ftp ?? {}) as JsonValue; + + return { + enabled: Boolean(payload.enabled), + status: typeof payload.status === 'string' ? payload.status : null, + username: typeof payload.username === 'string' ? payload.username : null, + password: typeof payload.password === 'string' ? payload.password : null, + path: typeof payload.path === 'string' ? payload.path : null, + ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null, + expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null, + rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0), + ftp: { + host: typeof ftp.host === 'string' ? ftp.host : null, + port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0, + require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps), + }, + }; +} + +async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise<PhotoboothStatus> { + const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init); + const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage); + const body = (payload as { data?: JsonValue }).data ?? (payload as JsonValue); + + return normalizePhotoboothStatus(body ?? {}); +} + export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> { return cachedFetch( CacheKeys.events, @@ -1118,6 +1239,22 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> { return toolkit; } +export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> { + return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status'); +} + +export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> { + return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access'); +} + +export async function rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> { + return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials'); +} + +export async function disableEventPhotobooth(slug: string): Promise<PhotoboothStatus> { + return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access'); +} + export async function submitTenantFeedback(payload: { category: string; sentiment?: 'positive' | 'neutral' | 'negative'; diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 99e32cd..73b2474 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -30,3 +30,4 @@ export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/ev export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`); export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`); +export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`); diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json index 9e84420..d63911d 100644 --- a/resources/js/admin/i18n/locales/de/auth.json +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -14,7 +14,7 @@ "panel_title": "Team Login für Fotospiel", "panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.", "actions_title": "Wähle deine Anmeldemethode", - "actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Tenant-Dashboard zu.", + "actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Kunden-Dashboard zu.", "cta": "Mit Fotospiel-Login fortfahren", "google_cta": "Mit Google anmelden", "open_account_login": "Konto-Login öffnen", @@ -24,12 +24,12 @@ "oauth_errors": { "login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.", "invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.", - "invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.", + "invalid_client": "Die verknüpfte Kunden-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.", "invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.", "invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.", - "tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.", + "tenant_mismatch": "Du hast keinen Zugriff auf das Kundenkonto, das diese Anmeldung angefordert hat.", "google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.", - "google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an." + "google_no_match": "Wir konnten dieses Google-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an." }, "return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.", "support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.", diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 4963c4a..9f9ac30 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -1,6 +1,6 @@ { "app": { - "brand": "Fotospiel Tenant Admin", + "brand": "Fotospiel Kunden-Admin", "languageSwitch": "Sprache", "userMenu": "Konto", "help": "FAQ & Hilfe", diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 1f14ee8..1ce9ebc 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -5,7 +5,7 @@ "guidedSetup": "Guided Setup" }, "welcome": { - "fallbackName": "Tenant-Admin", + "fallbackName": "Kunden-Admin", "greeting": "Hallo {{name}}!", "subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick." }, @@ -24,7 +24,7 @@ }, "overview": { "title": "Kurzer Überblick", - "description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.", + "description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.", "noPackage": "Kein aktives Paket", "stats": { "activePackage": "Aktives Paket", @@ -125,7 +125,7 @@ }, "faq": { "title": "FAQ & Hilfe", - "subtitle": "Antworten und Hinweise rund um den Tenant Admin.", + "subtitle": "Antworten und Hinweise rund um den Kunden-Admin.", "intro": { "title": "Was dich erwartet", "description": "Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt." @@ -148,6 +148,26 @@ "contact": "Support kontaktieren" } }, + "helpCenter": { + "title": "Hilfe & Dokumentation", + "subtitle": "Geführte Anleitungen und Troubleshooting für Event-Admins.", + "search": { + "placeholder": "Suche nach Thema oder Stichwort" + }, + "list": { + "empty": "Keine Artikel gefunden.", + "error": "Hilfe konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "updated": "Aktualisiert {{date}}" + }, + "article": { + "placeholder": "Wähle links einen Artikel aus, um Details zu sehen.", + "loading": "Artikel wird geladen...", + "error": "Artikel konnte nicht geladen werden.", + "updated": "Aktualisiert am {{date}}", + "related": "Verwandte Artikel" + } + }, "dashboard": { "actions": { "newEvent": "Neues Event", @@ -155,7 +175,7 @@ "guidedSetup": "Guided Setup" }, "welcome": { - "fallbackName": "Tenant-Admin", + "fallbackName": "Kunden-Admin", "greeting": "Hallo {{name}}!", "subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick." }, @@ -174,7 +194,7 @@ }, "overview": { "title": "Kurzer Überblick", - "description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.", + "description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.", "noPackage": "Kein aktives Paket", "stats": { "activePackage": "Aktives Paket", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 92790a3..297f9e2 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -60,7 +60,7 @@ }, "transactions": { "title": "Paddle-Transaktionen", - "description": "Neueste Paddle-Transaktionen für diesen Tenant.", + "description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.", "empty": "Noch keine Paddle-Transaktionen.", "labels": { "transactionId": "Transaktion {{id}}", @@ -121,7 +121,7 @@ "empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.", "count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.", "badge": { - "dashboard": "Tenant Dashboard" + "dashboard": "Kunden-Dashboard" } } } @@ -168,7 +168,7 @@ "submit": "Einladung senden" }, "roles": { - "tenantAdmin": "Tenant-Admin", + "tenantAdmin": "Kunden-Admin", "member": "Mitglied", "guest": "Gast" }, diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index d7fcdaa..be36497 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -1,6 +1,6 @@ { "layout": { - "eyebrow": "Fotospiel Tenant Admin", + "eyebrow": "Fotospiel Kunden-Admin", "title": "Willkommen im Event-Erlebnisstudio", "subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie – alles optimiert für mobile Hosts.", "alreadyFamiliar": "Schon vertraut mit Fotospiel?", @@ -180,7 +180,7 @@ "pendingDescription": "Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket." }, "free": { - "description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup weitermachen.", + "description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Kundenkonto zuweisen und direkt mit dem Setup weitermachen.", "activate": "Gratis-Paket aktivieren", "progress": "Aktivierung läuft …", "successTitle": "Gratis-Paket aktiviert", diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json index def7bc5..b285be6 100644 --- a/resources/js/admin/i18n/locales/en/auth.json +++ b/resources/js/admin/i18n/locales/en/auth.json @@ -14,7 +14,7 @@ "panel_title": "Sign in", "panel_copy": "Sign in with your Fotospiel admin access. Sanctum personal access tokens and clear role permissions keep your account protected.", "actions_title": "Choose your sign-in method", - "actions_copy": "Access the tenant dashboard securely with your Fotospiel login or your Google account.", + "actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google account.", "cta": "Continue with Fotospiel login", "google_cta": "Continue with Google", "open_account_login": "Open account login", @@ -24,12 +24,12 @@ "oauth_errors": { "login_required": "Please sign in to your Fotospiel account before continuing.", "invalid_request": "The login request was invalid. Please try again.", - "invalid_client": "We couldn’t find the linked tenant app. Please contact support if this persists.", + "invalid_client": "We couldn’t find the linked customer app. Please contact support if this persists.", "invalid_redirect": "The redirect address is not registered for this app.", "invalid_scope": "The app asked for permissions it cannot receive.", - "tenant_mismatch": "You don’t have access to the tenant that requested this login.", + "tenant_mismatch": "You don’t have access to the customer account that requested this login.", "google_failed": "Google sign-in was not successful. Please try again or use another method.", - "google_no_match": "We couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials." + "google_no_match": "We couldn’t link this Google account to a customer admin. Please sign in with Fotospiel credentials." }, "return_hint": "After signing in you’ll be brought back automatically.", "support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.", diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 3fe7c2f..34abed0 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -1,6 +1,6 @@ { "app": { - "brand": "Fotospiel Tenant Admin", + "brand": "Fotospiel Customer Admin", "languageSwitch": "Language", "userMenu": "Account", "help": "FAQ & Help", diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 0409e3c..22eb156 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -5,7 +5,7 @@ "guidedSetup": "Guided setup" }, "welcome": { - "fallbackName": "Tenant Admin", + "fallbackName": "Customer Admin", "greeting": "Welcome, {{name}}!", "subtitle": "Keep your events, packages, and tasks on track." }, @@ -24,7 +24,7 @@ }, "overview": { "title": "At a glance", - "description": "Key tenant metrics at a glance.", + "description": "Key customer metrics at a glance.", "noPackage": "No active package", "stats": { "activePackage": "Active package", @@ -125,7 +125,7 @@ }, "faq": { "title": "FAQ & Help", - "subtitle": "Answers and hints around the tenant admin.", + "subtitle": "Answers and hints around the customer admin.", "intro": { "title": "What to expect", "description": "We are collecting feedback and will expand this help center step by step." @@ -148,6 +148,26 @@ "contact": "Contact support" } }, + "helpCenter": { + "title": "Help & documentation", + "subtitle": "Structured guides and troubleshooting for customer admins.", + "search": { + "placeholder": "Search by topic or keyword" + }, + "list": { + "empty": "No articles found.", + "error": "Help could not be loaded.", + "retry": "Try again", + "updated": "Updated {{date}}" + }, + "article": { + "placeholder": "Select an article on the left to view details.", + "loading": "Loading article...", + "error": "The article could not be loaded.", + "updated": "Updated on {{date}}", + "related": "Related articles" + } + }, "dashboard": { "actions": { "newEvent": "New Event", @@ -155,7 +175,7 @@ "guidedSetup": "Guided setup" }, "welcome": { - "fallbackName": "Tenant Admin", + "fallbackName": "Customer Admin", "greeting": "Welcome, {{name}}!", "subtitle": "Keep your events, packages, and tasks on track." }, @@ -174,7 +194,7 @@ }, "overview": { "title": "At a glance", - "description": "Key tenant metrics at a glance.", + "description": "Key customer metrics at a glance.", "noPackage": "No active package", "stats": { "activePackage": "Active package", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 13584ad..fb40b3f 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -60,7 +60,7 @@ }, "transactions": { "title": "Paddle transactions", - "description": "Recent Paddle transactions for this tenant.", + "description": "Recent Paddle transactions for this customer account.", "empty": "No Paddle transactions yet.", "labels": { "transactionId": "Transaction {{id}}", @@ -121,7 +121,7 @@ "empty": "No events yet – create your first one to get started.", "count": "{{count}} {{count, plural, one {event} other {events}}} managed.", "badge": { - "dashboard": "Tenant dashboard" + "dashboard": "Customer dashboard" } } } @@ -168,7 +168,7 @@ "submit": "Send invitation" }, "roles": { - "tenantAdmin": "Tenant admin", + "tenantAdmin": "Customer admin", "member": "Member", "guest": "Guest" }, @@ -627,11 +627,11 @@ "eventType": "Event type", "allEventTypes": "All event types", "globalOnly": "Global templates", - "tenantOnly": "Tenant collections" + "tenantOnly": "Customer collections" }, "scope": { "global": "Global template", - "tenant": "Tenant-owned" + "tenant": "Customer-owned" }, "empty": { "title": "No collections yet", @@ -676,7 +676,7 @@ }, "scope": { "global": "Global", - "tenant": "Tenant" + "tenant": "Customer" }, "labels": { "updated": "Updated: {{date}}", diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index c23e878..07041ea 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -1,6 +1,6 @@ { "layout": { - "eyebrow": "Fotospiel Tenant Admin", + "eyebrow": "Fotospiel Customer Admin", "title": "Welcome to your event studio", "subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.", "alreadyFamiliar": "Already familiar with Fotospiel?", @@ -50,7 +50,7 @@ "landingProgress": { "eyebrow": "Onboarding tracker", "title": "Stay aligned with your marketing dashboard", - "description": "Complete these quick wins so the marketing dashboard reflects your latest tenant progress.", + "description": "Complete these quick wins so the marketing dashboard reflects your latest customer progress.", "status": { "complete": "Completed", "pending": "Pending" @@ -180,7 +180,7 @@ "pendingDescription": "You can start preparing the event. An active package is required before going live." }, "free": { - "description": "This package is free. Assign it to your tenant and continue immediately.", + "description": "This package is free. Assign it to your customer account and continue immediately.", "activate": "Activate free package", "progress": "Activating …", "successTitle": "Free package activated", diff --git a/resources/js/admin/pages/EventPhotoboothPage.tsx b/resources/js/admin/pages/EventPhotoboothPage.tsx new file mode 100644 index 0000000..956fefb --- /dev/null +++ b/resources/js/admin/pages/EventPhotoboothPage.tsx @@ -0,0 +1,389 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, ArrowLeft, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { AdminLayout } from '../components/AdminLayout'; +import { + PhotoboothStatus, + TenantEvent, + disableEventPhotobooth, + enableEventPhotobooth, + getEvent, + getEventPhotoboothStatus, + rotateEventPhotobooth, +} from '../api'; +import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage } from '../lib/apiError'; +import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; + +type State = { + event: TenantEvent | null; + status: PhotoboothStatus | null; + loading: boolean; + updating: boolean; + error: string | null; +}; + +export default function EventPhotoboothPage() { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation(['management', 'common']); + + const [state, setState] = React.useState<State>({ + event: null, + status: null, + loading: true, + updating: false, + error: null, + }); + + const load = React.useCallback(async () => { + if (!slug) { + setState((prev) => ({ + ...prev, + loading: false, + error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'), + })); + return; + } + + setState((prev) => ({ ...prev, loading: true, error: null })); + + try { + const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]); + setState({ + event: eventData, + status: statusData, + loading: false, + updating: false, + error: null, + }); + } catch (error) { + if (!isAuthError(error)) { + setState((prev) => ({ + ...prev, + loading: false, + error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')), + })); + } else { + setState((prev) => ({ ...prev, loading: false })); + } + } + }, [slug, t]); + + React.useEffect(() => { + void load(); + }, [load]); + + async function handleEnable(): Promise<void> { + if (!slug) return; + setState((prev) => ({ ...prev, updating: true, error: null })); + + try { + const result = await enableEventPhotobooth(slug); + setState((prev) => ({ + ...prev, + status: result, + updating: false, + })); + } catch (error) { + if (!isAuthError(error)) { + setState((prev) => ({ + ...prev, + updating: false, + error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')), + })); + } else { + setState((prev) => ({ ...prev, updating: false })); + } + } + } + + async function handleRotate(): Promise<void> { + if (!slug) return; + setState((prev) => ({ ...prev, updating: true, error: null })); + + try { + const result = await rotateEventPhotobooth(slug); + setState((prev) => ({ + ...prev, + status: result, + updating: false, + })); + } catch (error) { + if (!isAuthError(error)) { + setState((prev) => ({ + ...prev, + updating: false, + error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')), + })); + } else { + setState((prev) => ({ ...prev, updating: false })); + } + } + } + + async function handleDisable(): Promise<void> { + if (!slug) return; + if (!window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) { + return; + } + + setState((prev) => ({ ...prev, updating: true, error: null })); + + try { + const result = await disableEventPhotobooth(slug); + setState((prev) => ({ + ...prev, + status: result, + updating: false, + })); + } catch (error) { + if (!isAuthError(error)) { + setState((prev) => ({ + ...prev, + updating: false, + error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')), + })); + } else { + setState((prev) => ({ ...prev, updating: false })); + } + } + } + + const { event, status, loading, updating, error } = state; + const title = event + ? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event) }) + : t('management.photobooth.title', 'Fotobox-Uploads'); + const subtitle = t( + 'management.photobooth.subtitle', + 'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.' + ); + + const actions = ( + <div className="flex gap-2"> + {slug ? ( + <Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}> + <ArrowLeft className="mr-2 h-4 w-4" /> + {t('management.photobooth.actions.backToEvent', 'Zur Detailansicht')} + </Button> + ) : null} + <Button variant="ghost" onClick={() => navigate(ADMIN_EVENTS_PATH)}> + {t('management.photobooth.actions.allEvents', 'Zur Eventliste')} + </Button> + </div> + ); + + return ( + <AdminLayout title={title} subtitle={subtitle} actions={actions}> + {error ? ( + <Alert variant="destructive" className="mb-4"> + <AlertTitle>{t('common:messages.error', 'Fehler')}</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> + ) : null} + + {loading ? ( + <PhotoboothSkeleton /> + ) : ( + <div className="space-y-6"> + <StatusCard status={status} /> + <CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} /> + <RateLimitCard status={status} /> + </div> + )} + </AdminLayout> + ); +} + +function resolveEventName(name: TenantEvent['name']): string { + if (typeof name === 'string') { + return name; + } + if (name && typeof name === 'object') { + return Object.values(name)[0] ?? 'Event'; + } + return 'Event'; +} + +function PhotoboothSkeleton() { + return ( + <div className="space-y-4"> + {Array.from({ length: 3 }).map((_, idx) => ( + <div key={idx} className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 shadow-sm"> + <div className="h-4 w-32 animate-pulse rounded bg-slate-200/80" /> + <div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" /> + <div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-slate-100" /> + </div> + ))} + </div> + ); +} + +function StatusCard({ status }: { status: PhotoboothStatus | null }) { + const { t } = useTranslation('management'); + const isActive = Boolean(status?.enabled); + const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800'; + const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />; + + return ( + <Card className="rounded-3xl border border-slate-200/80 shadow-sm"> + <CardHeader className="flex flex-row items-center justify-between gap-3"> + <div> + <CardTitle>{t('photobooth.status.heading', 'Status')}</CardTitle> + <CardDescription> + {isActive + ? t('photobooth.status.active', 'Photobooth-Link ist aktiv.') + : t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')} + </CardDescription> + </div> + <div className="flex items-center gap-3"> + {icon} + <Badge className={badgeColor}> + {isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')} + </Badge> + </div> + </CardHeader> + {status?.expires_at ? ( + <CardContent className="text-sm text-slate-600"> + {t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', { + date: new Date(status.expires_at).toLocaleString(), + })} + </CardContent> + ) : null} + </Card> + ); +} + +type CredentialCardProps = { + status: PhotoboothStatus | null; + updating: boolean; + onEnable: () => Promise<void>; + onRotate: () => Promise<void>; + onDisable: () => Promise<void>; +}; + +function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) { + const { t } = useTranslation('management'); + const isActive = Boolean(status?.enabled); + + return ( + <Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40"> + <CardHeader> + <CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle> + <CardDescription> + {t( + 'photobooth.credentials.description', + 'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.' + )} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid gap-4 md:grid-cols-2"> + <Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} /> + <Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} /> + <Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable /> + <Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive /> + <Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable /> + <Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" /> + </div> + + <div className="flex flex-wrap gap-3"> + {isActive ? ( + <> + <Button onClick={onRotate} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500"> + {updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />} + {t('photobooth.actions.rotate', 'Zugang neu generieren')} + </Button> + <Button variant="outline" onClick={onDisable} disabled={updating}> + <Power className="mr-2 h-4 w-4" /> + {t('photobooth.actions.disable', 'Deaktivieren')} + </Button> + </> + ) : ( + <Button onClick={onEnable} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500"> + {updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />} + {t('photobooth.actions.enable', 'Photobooth aktivieren')} + </Button> + )} + </div> + </CardContent> + </Card> + ); +} + +function RateLimitCard({ status }: { status: PhotoboothStatus | null }) { + const { t } = useTranslation('management'); + const rateLimit = status?.rate_limit_per_minute ?? 20; + + return ( + <Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm"> + <CardHeader className="flex flex-row items-center gap-3"> + <ShieldCheck className="h-5 w-5 text-emerald-500" /> + <div> + <CardTitle>{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')}</CardTitle> + <CardDescription> + {t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', { + count: rateLimit, + })} + </CardDescription> + </div> + </CardHeader> + <CardContent className="text-sm leading-relaxed text-slate-600"> + <p> + {t( + 'photobooth.rateLimit.body', + 'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.' + )} + </p> + <p className="mt-3 text-xs text-slate-500"> + <AlertCircle className="mr-1 inline h-3.5 w-3.5" /> + {t( + 'photobooth.rateLimit.hint', + 'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.' + )} + </p> + </CardContent> + </Card> + ); +} + +type FieldProps = { + label: string; + value: string; + copyable?: boolean; + sensitive?: boolean; + className?: string; +}; + +function Field({ label, value, copyable, sensitive, className }: FieldProps) { + const [copied, setCopied] = React.useState(false); + const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value; + + async function handleCopy() { + if (!copyable || !value || value === '—') return; + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( + <div className={`rounded-2xl border border-slate-200/80 bg-white/70 p-4 shadow-inner ${className ?? ''}`}> + <p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p> + <div className="mt-1 flex items-center justify-between gap-2"> + <span className="truncate text-base font-medium text-slate-900">{showValue}</span> + {copyable ? ( + <Button variant="ghost" size="icon" onClick={handleCopy} aria-label="Copy" disabled={!value || value === '—'}> + {copied ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-slate-500" />} + </Button> + ) : null} + </div> + </div> + ); +} diff --git a/resources/js/admin/pages/FaqPage.tsx b/resources/js/admin/pages/FaqPage.tsx index 69a4fe2..414c1c1 100644 --- a/resources/js/admin/pages/FaqPage.tsx +++ b/resources/js/admin/pages/FaqPage.tsx @@ -1,80 +1,245 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { Loader2, RefreshCcw } from 'lucide-react'; import { AdminLayout } from '../components/AdminLayout'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import type { HelpCenterArticleSummary, HelpCenterArticle } from '../api'; +import { fetchHelpCenterArticles, fetchHelpCenterArticle } from '../api'; + +function normalizeLocale(language: string | undefined): 'de' | 'en' { + const normalized = (language ?? 'de').toLowerCase().split('-')[0]; + return normalized === 'en' ? 'en' : 'de'; +} export default function FaqPage() { - const { t } = useTranslation('dashboard'); + const { t, i18n } = useTranslation('dashboard'); + const helpLocale = normalizeLocale(i18n.language); + const [query, setQuery] = React.useState(''); + const [articles, setArticles] = React.useState<HelpCenterArticleSummary[]>([]); + const [listState, setListState] = React.useState<'loading' | 'ready' | 'error'>('loading'); + const [selectedSlug, setSelectedSlug] = React.useState<string | null>(null); + const [detailState, setDetailState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); + const [articleCache, setArticleCache] = React.useState<Record<string, HelpCenterArticle>>({}); - const entries = [ - { - question: t('faq.events.question', 'Wie arbeite ich mit Events?'), - answer: t( - 'faq.events.answer', - 'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.' - ), - }, - { - question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'), - answer: t( - 'faq.uploads.answer', - 'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.' - ), - }, - { - question: t('faq.support.question', 'Wo erhalte ich Support?'), - answer: t( - 'faq.support.answer', - 'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.' - ), - }, - ]; + const loadArticles = React.useCallback(async () => { + setListState('loading'); + try { + const data = await fetchHelpCenterArticles(helpLocale); + setArticles(data); + setListState('ready'); + } catch (error) { + console.error('[HelpCenter] Failed to load list', error); + setListState('error'); + } + }, [helpLocale]); + + React.useEffect(() => { + setArticles([]); + setArticleCache({}); + setSelectedSlug(null); + loadArticles(); + }, [loadArticles]); + + React.useEffect(() => { + if (!selectedSlug && articles.length > 0) { + setSelectedSlug(articles[0].slug); + } else if (selectedSlug && !articles.some((article) => article.slug === selectedSlug)) { + setSelectedSlug(articles[0]?.slug ?? null); + } + }, [articles, selectedSlug]); + + const loadArticle = React.useCallback(async (slug: string, options?: { bypassCache?: boolean }) => { + if (!slug) { + return; + } + + const bypassCache = options?.bypassCache ?? false; + if (!bypassCache && articleCache[slug]) { + setDetailState('ready'); + return; + } + + setDetailState('loading'); + try { + const article = await fetchHelpCenterArticle(slug, helpLocale); + setArticleCache((prev) => ({ ...prev, [slug]: article })); + setDetailState('ready'); + } catch (error) { + console.error('[HelpCenter] Failed to load article', error); + setDetailState('error'); + } + }, [articleCache, helpLocale]); + + React.useEffect(() => { + if (selectedSlug) { + loadArticle(selectedSlug); + } + }, [selectedSlug, loadArticle]); + + const filteredArticles = React.useMemo(() => { + if (!query.trim()) { + return articles; + } + const needle = query.trim().toLowerCase(); + return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle)); + }, [articles, query]); + + const activeArticle = selectedSlug ? articleCache[selectedSlug] : null; return ( <AdminLayout - title={t('faq.title', 'FAQ & Hilfe')} - subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')} + title={t('helpCenter.title', 'Hilfe & Dokumentation')} + subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')} > - <Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5"> - <CardHeader> - <CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle> - <CardDescription> - {t( - 'faq.intro.description', - 'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.' - )} - </CardDescription> - </CardHeader> - <CardContent className="space-y-6"> - {entries.map((entry) => ( - <div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10"> - <p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p> - <p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p> - </div> - ))} - <div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100"> - <p className="font-semibold"> - {t('faq.cta.needHelp', 'Fehlt dir etwas?')} - </p> - <p className="mt-1 text-sm"> - {t( - 'faq.cta.description', - 'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail – wir erweitern dieses FAQ mit deinen Themen.' + <div className="grid gap-6 lg:grid-cols-[320px,1fr]"> + <Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5"> + <CardHeader> + <CardTitle>{t('helpCenter.title', 'Hilfe & Dokumentation')}</CardTitle> + <CardDescription>{t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <Input + placeholder={t('helpCenter.search.placeholder', 'Suche nach Thema')} + value={query} + onChange={(event) => setQuery(event.target.value)} + /> + <div> + {listState === 'loading' && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + {t('helpCenter.article.loading', 'Lädt...')} + </div> )} - </p> - <Button - size="sm" - variant="secondary" - className="mt-3 rounded-full" - onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')} - > - {t('faq.cta.contact', 'Support kontaktieren')} - </Button> - </div> - </CardContent> - </Card> + {listState === 'error' && ( + <div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100"> + <p>{t('helpCenter.list.error')}</p> + <Button variant="secondary" size="sm" onClick={loadArticles}> + <span className="flex items-center gap-2"> + <RefreshCcw className="h-4 w-4" /> + {t('helpCenter.list.retry')} + </span> + </Button> + </div> + )} + {listState === 'ready' && filteredArticles.length === 0 && ( + <div className="rounded-lg border border-dashed border-slate-200/80 p-4 text-sm text-muted-foreground dark:border-white/10"> + {t('helpCenter.list.empty')} + </div> + )} + {listState === 'ready' && filteredArticles.length > 0 && ( + <div className="space-y-2"> + {filteredArticles.map((article) => { + const isActive = selectedSlug === article.slug; + return ( + <button + key={article.slug} + type="button" + onClick={() => setSelectedSlug(article.slug)} + className={`w-full rounded-xl border p-3 text-left transition-colors ${ + isActive + ? 'border-sky-500 bg-sky-500/5 shadow-sm' + : 'border-slate-200/80 hover:border-sky-400/70' + }`} + > + <div className="flex items-center justify-between gap-3"> + <div> + <p className="font-semibold text-slate-900 dark:text-white">{article.title}</p> + <p className="mt-1 text-sm text-slate-500 dark:text-slate-300 line-clamp-2">{article.summary}</p> + </div> + {article.updated_at && ( + <span className="text-xs text-slate-400 dark:text-slate-500"> + {t('helpCenter.list.updated', { date: formatDate(article.updated_at, i18n.language) })} + </span> + )} + </div> + </button> + ); + })} + </div> + )} + </div> + </CardContent> + </Card> + + <Card className="border-slate-200 bg-white/95 shadow-sm dark:border-white/10 dark:bg-white/5"> + <CardHeader> + <CardTitle>{activeArticle?.title ?? t('helpCenter.article.placeholder')}</CardTitle> + <CardDescription className="text-sm text-slate-500 dark:text-slate-300"> + {activeArticle?.summary ?? ''} + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {!selectedSlug && ( + <p className="text-sm text-muted-foreground">{t('helpCenter.article.placeholder')}</p> + )} + + {selectedSlug && detailState === 'loading' && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + {t('helpCenter.article.loading')} + </div> + )} + + {selectedSlug && detailState === 'error' && ( + <div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100"> + <p>{t('helpCenter.article.error')}</p> + <Button size="sm" variant="secondary" onClick={() => loadArticle(selectedSlug, { bypassCache: true })}> + {t('helpCenter.list.retry')} + </Button> + </div> + )} + + {detailState === 'ready' && activeArticle && ( + <div className="space-y-6"> + {activeArticle.updated_at && ( + <Badge variant="outline" className="border-slate-200 text-xs font-normal text-slate-600 dark:border-white/20 dark:text-slate-300"> + {t('helpCenter.article.updated', { date: formatDate(activeArticle.updated_at, i18n.language) })} + </Badge> + )} + <div + className="prose prose-sm max-w-none text-slate-700 dark:prose-invert" + dangerouslySetInnerHTML={{ __html: activeArticle.body_html ?? activeArticle.body_markdown ?? '' }} + /> + {activeArticle.related && activeArticle.related.length > 0 && ( + <div className="space-y-3"> + <h3 className="text-sm font-semibold text-slate-900 dark:text-white"> + {t('helpCenter.article.related')} + </h3> + <div className="flex flex-wrap gap-2"> + {activeArticle.related.map((rel) => ( + <Button + key={rel.slug} + variant="outline" + size="sm" + onClick={() => setSelectedSlug(rel.slug)} + > + {rel.slug} + </Button> + ))} + </div> + </div> + )} + </div> + )} + </CardContent> + </Card> + </div> </AdminLayout> ); } + +function formatDate(value: string, language: string | undefined): string { + try { + return new Date(value).toLocaleDateString(language ?? 'de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + } catch (error) { + return value; + } +} diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index eda61c7..cd8efc4 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -46,7 +46,7 @@ export default function SettingsPage() { t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }), t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' }) ]; - const accountName = user?.name ?? user?.email ?? 'Tenant Admin'; + const accountName = user?.name ?? user?.email ?? 'Customer Admin'; const heroPrimaryAction = ( <Button size="sm" @@ -140,7 +140,7 @@ export default function SettingsPage() { <p className="text-sm text-slate-600"> {user ? ( <> - Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Tenant Admin'}</span> + Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Customer Admin'}</span> {user.tenant_id && <> - Tenant #{user.tenant_id}</>} </> ) : ( diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 0fae719..fec91d1 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -21,6 +21,7 @@ const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage')); const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage')); const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage')); const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage')); +const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage')); const EngagementPage = React.lazy(() => import('./pages/EngagementPage')); const BillingPage = React.lazy(() => import('./pages/BillingPage')); const TasksPage = React.lazy(() => import('./pages/TasksPage')); @@ -107,6 +108,7 @@ export const router = createBrowserRouter([ { path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> }, { path: 'events/:slug/tasks', element: <EventTasksPage /> }, { path: 'events/:slug/invites', element: <EventInvitesPage /> }, + { path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> }, { path: 'events/:slug/toolkit', element: <EventToolkitPage /> }, { path: 'engagement', element: <EngagementPage /> }, { path: 'tasks', element: <TasksPage /> }, diff --git a/resources/js/guest/components/FiltersBar.tsx b/resources/js/guest/components/FiltersBar.tsx index 499391c..20055b9 100644 --- a/resources/js/guest/components/FiltersBar.tsx +++ b/resources/js/guest/components/FiltersBar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -export type GalleryFilter = 'latest' | 'popular' | 'mine'; +export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) { return ( @@ -10,8 +10,8 @@ export default function FiltersBar({ value, onChange }: { value: GalleryFilter; <ToggleGroupItem value="latest">Neueste</ToggleGroupItem> <ToggleGroupItem value="popular">Beliebt</ToggleGroupItem> <ToggleGroupItem value="mine">Meine</ToggleGroupItem> + <ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem> </ToggleGroup> </div> ); } - diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 76f822d..4f60759 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -6,17 +6,21 @@ import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; type Props = { token: string }; +type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; + export default function GalleryPreview({ token }: Props) { const { photos, loading } = usePollGalleryDelta(token); - const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest'); + const [mode, setMode] = React.useState<PreviewFilter>('latest'); const items = React.useMemo(() => { let arr = photos.slice(); // MyPhotos filter (requires session_id matching) - if (mode === 'myphotos') { + if (mode === 'mine') { const deviceId = getDeviceId(); arr = arr.filter((photo: any) => photo.session_id === deviceId); + } else if (mode === 'photobooth') { + arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth'); } // Sorting @@ -71,15 +75,25 @@ export default function GalleryPreview({ token }: Props) { Popular </button> <button - onClick={() => setMode('myphotos')} + onClick={() => setMode('mine')} className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${ - mode === 'myphotos' + mode === 'mine' ? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105' : 'text-gray-600 hover:bg-pink-50 hover:text-pink-700' }`} > My Photos </button> + <button + onClick={() => setMode('photobooth')} + className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${ + mode === 'photobooth' + ? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105' + : 'text-gray-600 hover:bg-pink-50 hover:text-pink-700' + }`} + > + Fotobox + </button> </div> <Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium"> Alle ansehen → diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index ca5388f..181fdff 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { @@ -13,7 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Alert, AlertDescription } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react'; +import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { LegalMarkdown } from './legal-markdown'; import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext'; @@ -48,11 +49,13 @@ export function SettingsSheet() { const identity = useOptionalGuestIdentity(); const localeContext = useLocale(); const { t } = useTranslation(); + const params = useParams<{ token?: string }>(); const [nameDraft, setNameDraft] = React.useState(identity?.name ?? ''); const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle'); const [savingName, setSavingName] = React.useState(false); const isLegal = view.mode === 'legal'; const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale); + const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; React.useEffect(() => { if (open && identity?.hydrated) { @@ -170,6 +173,7 @@ export function SettingsSheet() { nameStatus={nameStatus} localeContext={localeContext} onOpenLegal={handleOpenLegal} + helpHref={helpHref} /> )} </main> @@ -241,6 +245,7 @@ interface HomeViewProps { slug: (typeof legalPages)[number]['slug'], translationKey: (typeof legalPages)[number]['translationKey'], ) => void; + helpHref: string; } function HomeView({ @@ -254,6 +259,7 @@ function HomeView({ nameStatus, localeContext, onOpenLegal, + helpHref, }: HomeViewProps) { const { t } = useTranslation(); const legalLinks = React.useMemo( @@ -374,6 +380,23 @@ function HomeView({ </CardContent> </Card> + <Card> + <CardHeader className="pb-3"> + <CardTitle> + <div className="flex items-center gap-2"> + <LifeBuoy className="h-4 w-4 text-pink-500" /> + {t('settings.help.title')} + </div> + </CardTitle> + <CardDescription>{t('settings.help.description')}</CardDescription> + </CardHeader> + <CardContent> + <Button asChild className="w-full"> + <Link to={helpHref}>{t('settings.help.cta')}</Link> + </Button> + </CardContent> + </Card> + <Card> <CardHeader> <CardTitle>{t('settings.cache.title')}</CardTitle> diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 89bdea3..b290ea8 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -430,6 +430,11 @@ export const messages: Record<LocaleCode, NestedMessages> = { cleared: 'Cache gelöscht.', note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.', }, + help: { + title: 'Hilfe & Support', + description: 'Öffne das Hilfecenter mit Schritt-für-Schritt-Anleitungen.', + cta: 'Hilfecenter öffnen', + }, footer: { notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.', }, @@ -439,6 +444,26 @@ export const messages: Record<LocaleCode, NestedMessages> = { legalDescription: 'Rechtlicher Hinweis', }, }, + help: { + center: { + title: 'Hilfe & Tipps', + subtitle: 'Antworten für Gäste – nach dem ersten Laden auch offline verfügbar.', + searchPlaceholder: 'Suche nach Thema oder Stichwort', + offlineBadge: 'Offline-Version', + offlineDescription: 'Du siehst eine zwischengespeicherte Version. Geh online für aktuelle Inhalte.', + empty: 'Keine Artikel gefunden.', + error: 'Hilfe konnte nicht geladen werden.', + retry: 'Erneut versuchen', + listTitle: 'Alle Artikel', + }, + article: { + back: 'Zurück zur Übersicht', + updated: 'Aktualisiert am {date}', + relatedTitle: 'Verwandte Artikel', + unavailable: 'Dieser Artikel ist nicht verfügbar.', + reload: 'Neu laden', + }, + }, }, en: { common: { @@ -858,6 +883,11 @@ export const messages: Record<LocaleCode, NestedMessages> = { cleared: 'Cache cleared.', note: 'This only affects this browser and must be repeated per device.', }, + help: { + title: 'Help & support', + description: 'Open the help center for guides and quick answers.', + cta: 'Open help center', + }, footer: { notice: 'Guest area - data is stored locally in the browser.', }, @@ -867,6 +897,26 @@ export const messages: Record<LocaleCode, NestedMessages> = { legalDescription: 'Legal notice', }, }, + help: { + center: { + title: 'Help & tips', + subtitle: 'Guides for guests – available offline after the first sync.', + searchPlaceholder: 'Search by topic or keyword', + offlineBadge: 'Offline copy', + offlineDescription: 'You are viewing cached content. Go online to refresh articles.', + empty: 'No articles found.', + error: 'Help could not be loaded.', + retry: 'Try again', + listTitle: 'All articles', + }, + article: { + back: 'Back to overview', + updated: 'Updated on {date}', + relatedTitle: 'Related articles', + unavailable: 'This article is unavailable.', + reload: 'Reload', + }, + }, }, }; diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index e397858..92be075 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -13,11 +13,19 @@ import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; +const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; + +const parseGalleryFilter = (value: string | null): GalleryFilter => + allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest'; + export default function GalleryPage() { const { token } = useParams<{ token?: string }>(); const navigate = useNavigate(); const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? ''); - const [filter, setFilter] = React.useState<GalleryFilter>('latest'); + const [searchParams, setSearchParams] = useSearchParams(); + const photoIdParam = searchParams.get('photoId'); + const modeParam = searchParams.get('mode'); + const [filter, setFilterState] = React.useState<GalleryFilter>(() => parseGalleryFilter(modeParam)); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); @@ -28,8 +36,16 @@ export default function GalleryPage() { const { t } = useTranslation(); const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE'; - const [searchParams] = useSearchParams(); - const photoIdParam = searchParams.get('photoId'); + useEffect(() => { + setFilterState(parseGalleryFilter(modeParam)); + }, [modeParam]); + + const setFilter = React.useCallback((next: GalleryFilter) => { + setFilterState(next); + const params = new URLSearchParams(searchParams); + params.set('mode', next); + setSearchParams(params, { replace: true }); + }, [searchParams, setSearchParams]); // Auto-open lightbox if photoId in query params useEffect(() => { if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) { @@ -79,6 +95,9 @@ export default function GalleryPage() { arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0)); } else if (filter === 'mine') { arr = arr.filter((p: any) => myPhotoIds.has(p.id)); + } else if (filter === 'photobooth') { + arr = arr.filter((p: any) => p.ingest_source === 'photobooth'); + arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } else { arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()); } diff --git a/resources/js/guest/pages/HelpArticlePage.tsx b/resources/js/guest/pages/HelpArticlePage.tsx new file mode 100644 index 0000000..e5834b0 --- /dev/null +++ b/resources/js/guest/pages/HelpArticlePage.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; +import { Page } from './_util'; +import { useLocale } from '../i18n/LocaleContext'; +import { useTranslation } from '../i18n/useTranslation'; +import { getHelpArticle, type HelpArticleDetail } from '../services/helpApi'; + +export default function HelpArticlePage() { + const params = useParams<{ token?: string; slug: string }>(); + const slug = params.slug; + const { locale } = useLocale(); + const { t } = useTranslation(); + const [article, setArticle] = React.useState<HelpArticleDetail | null>(null); + const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading'); + const [servedFromCache, setServedFromCache] = React.useState(false); + const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; + + const loadArticle = React.useCallback(async () => { + if (!slug) { + setState('error'); + return; + } + setState('loading'); + try { + const result = await getHelpArticle(slug, locale); + setArticle(result.article); + setServedFromCache(result.servedFromCache); + setState('ready'); + } catch (error) { + console.error('[HelpArticle] Failed to load article', error); + setState('error'); + } + }, [slug, locale]); + + React.useEffect(() => { + loadArticle(); + }, [loadArticle]); + + const title = article?.title ?? t('help.article.unavailable'); + + return ( + <Page title={title}> + <div className="mb-4"> + <Button variant="ghost" size="sm" asChild> + <Link to={basePath}> + {t('help.article.back')} + </Link> + </Button> + </div> + + {state === 'loading' && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + {t('common.actions.loading')} + </div> + )} + + {state === 'error' && ( + <div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"> + <p>{t('help.article.unavailable')}</p> + <Button variant="secondary" size="sm" onClick={loadArticle}> + {t('help.article.reload')} + </Button> + </div> + )} + + {state === 'ready' && article && ( + <article className="space-y-6"> + <div className="space-y-2 text-sm text-muted-foreground"> + {article.updated_at && ( + <div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div> + )} + {servedFromCache && ( + <Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100"> + {t('help.center.offlineBadge')} + </Badge> + )} + </div> + <div + className="prose prose-sm max-w-none dark:prose-invert" + dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }} + /> + {article.related && article.related.length > 0 && ( + <section className="space-y-3"> + <h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3> + <div className="flex flex-wrap gap-2"> + {article.related.map((rel) => ( + <Button + key={rel.slug} + variant="outline" + size="sm" + asChild + > + <Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}> + {rel.slug} + </Link> + </Button> + ))} + </div> + </section> + )} + </article> + )} + </Page> + ); +} + +function formatDate(value: string, locale: string): string { + try { + return new Date(value).toLocaleDateString(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + } catch (error) { + return value; + } +} diff --git a/resources/js/guest/pages/HelpCenterPage.tsx b/resources/js/guest/pages/HelpCenterPage.tsx new file mode 100644 index 0000000..2333166 --- /dev/null +++ b/resources/js/guest/pages/HelpCenterPage.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Loader2, RefreshCcw } from 'lucide-react'; +import { Page } from './_util'; +import { useLocale } from '../i18n/LocaleContext'; +import { useTranslation } from '../i18n/useTranslation'; +import { getHelpArticles, type HelpArticleSummary } from '../services/helpApi'; + +export default function HelpCenterPage() { + const params = useParams<{ token?: string }>(); + const { locale } = useLocale(); + const { t } = useTranslation(); + const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]); + const [query, setQuery] = React.useState(''); + const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading'); + const [servedFromCache, setServedFromCache] = React.useState(false); + const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help'; + + const loadArticles = React.useCallback(async (forceRefresh = false) => { + setState('loading'); + try { + const result = await getHelpArticles(locale, { forceRefresh }); + setArticles(result.articles); + setServedFromCache(result.servedFromCache); + setState('ready'); + } catch (error) { + console.error('[HelpCenter] Failed to load articles', error); + setState('error'); + } + }, [locale]); + + React.useEffect(() => { + loadArticles(); + }, [loadArticles]); + + const filteredArticles = React.useMemo(() => { + if (!query.trim()) { + return articles; + } + const needle = query.trim().toLowerCase(); + return articles.filter((article) => + `${article.title} ${article.summary}`.toLowerCase().includes(needle), + ); + }, [articles, query]); + + return ( + <Page title={t('help.center.title')}> + <p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p> + + <div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3"> + <div className="flex flex-col gap-3 sm:flex-row"> + <Input + placeholder={t('help.center.searchPlaceholder')} + value={query} + onChange={(event) => setQuery(event.target.value)} + className="flex-1" + aria-label={t('help.center.searchPlaceholder')} + /> + <Button + variant="outline" + className="sm:w-auto" + onClick={() => loadArticles(true)} + disabled={state === 'loading'} + > + {state === 'loading' ? ( + <span className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + {t('common.actions.loading')} + </span> + ) : ( + <span className="flex items-center gap-2"> + <RefreshCcw className="h-4 w-4" /> + {t('help.center.retry')} + </span> + )} + </Button> + </div> + {servedFromCache && ( + <div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200"> + <Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100"> + {t('help.center.offlineBadge')} + </Badge> + <span>{t('help.center.offlineDescription')}</span> + </div> + )} + </div> + + <section className="mt-6 space-y-4"> + <h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2> + {state === 'loading' && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + {t('common.actions.loading')} + </div> + )} + {state === 'error' && ( + <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"> + <p>{t('help.center.error')}</p> + <Button + variant="secondary" + size="sm" + className="mt-3" + onClick={() => loadArticles(false)} + > + {t('help.center.retry')} + </Button> + </div> + )} + {state === 'ready' && filteredArticles.length === 0 && ( + <div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground"> + {t('help.center.empty')} + </div> + )} + {state === 'ready' && filteredArticles.length > 0 && ( + <div className="space-y-3"> + {filteredArticles.map((article) => ( + <Link + key={article.slug} + to={`${basePath}/${encodeURIComponent(article.slug)}`} + className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60" + > + <div className="flex items-start justify-between gap-3"> + <div> + <h3 className="text-base font-semibold text-foreground">{article.title}</h3> + <p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p> + </div> + <span className="text-xs text-muted-foreground"> + {article.updated_at ? formatDate(article.updated_at, locale) : ''} + </span> + </div> + </Link> + ))} + </div> + )} + </section> + </Page> + ); +} + +function formatDate(value: string, locale: string): string { + try { + return new Date(value).toLocaleDateString(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + } catch (error) { + return value; + } +} diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts index c1fd35f..1d0a87e 100644 --- a/resources/js/guest/polling/usePollGalleryDelta.ts +++ b/resources/js/guest/polling/usePollGalleryDelta.ts @@ -34,9 +34,14 @@ export function usePollGalleryDelta(token: string) { const json = await res.json(); // Handle different response formats - const newPhotos = Array.isArray(json.data) ? json.data : + const rawPhotos = Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : json.photos || []; + + const newPhotos = rawPhotos.map((photo: any) => ({ + ...photo, + session_id: photo?.session_id ?? photo?.guest_name ?? null, + })); if (newPhotos.length > 0) { const added = newPhotos.length; diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 6868cbd..c92bb6b 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -27,6 +27,8 @@ const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage')); const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage')); const SettingsPage = React.lazy(() => import('./pages/SettingsPage')); const LegalPage = React.lazy(() => import('./pages/LegalPage')); +const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage')); +const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage')); const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage')); const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage')); @@ -75,10 +77,14 @@ export const router = createBrowserRouter([ { path: 'photo/:photoId', element: <PhotoLightbox /> }, { path: 'achievements', element: <AchievementsPage /> }, { path: 'slideshow', element: <SlideshowPage /> }, + { path: 'help', element: <HelpCenterPage /> }, + { path: 'help/:slug', element: <HelpArticlePage /> }, ], }, { path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> }, { path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> }, + { path: '/help', element: <HelpStandalone /> }, + { path: '/help/:slug', element: <HelpArticleStandalone /> }, { path: '*', element: <NotFoundPage /> }, ]); @@ -248,3 +254,21 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac </EventBrandingProvider> ); } + +function HelpStandalone() { + const { t } = useTranslation(); + return ( + <SimpleLayout title={t('help.center.title')}> + <HelpCenterPage /> + </SimpleLayout> + ); +} + +function HelpArticleStandalone() { + const { t } = useTranslation(); + return ( + <SimpleLayout title={t('help.center.title')}> + <HelpArticlePage /> + </SimpleLayout> + ); +} diff --git a/resources/js/guest/services/helpApi.ts b/resources/js/guest/services/helpApi.ts new file mode 100644 index 0000000..c013349 --- /dev/null +++ b/resources/js/guest/services/helpApi.ts @@ -0,0 +1,160 @@ +import type { LocaleCode } from '../i18n/messages'; + +export type HelpArticleSummary = { + slug: string; + title: string; + summary: string; + version_introduced?: string; + requires_app_version?: string | null; + status?: string; + translation_state?: string; + last_reviewed_at?: string; + owner?: string; + updated_at?: string; + related?: Array<{ slug: string }>; +}; + +export type HelpArticleDetail = HelpArticleSummary & { + body_markdown?: string; + body_html?: string; + source_path?: string; +}; + +export interface HelpListResult { + articles: HelpArticleSummary[]; + servedFromCache: boolean; +} + +export interface HelpArticleResult { + article: HelpArticleDetail; + servedFromCache: boolean; +} + +const AUDIENCE = 'guest'; +const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours +const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours + +interface CacheRecord<T> { + storedAt: number; + data: T; +} + +function listCacheKey(locale: LocaleCode): string { + return `help:list:${AUDIENCE}:${locale}`; +} + +function detailCacheKey(locale: LocaleCode, slug: string): string { + return `help:article:${AUDIENCE}:${locale}:${slug}`; +} + +function readCache<T>(key: string, ttl: number): CacheRecord<T> | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = window.localStorage.getItem(key); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as CacheRecord<T>; + if (!parsed?.data || !parsed?.storedAt) { + return null; + } + + if (Date.now() - parsed.storedAt > ttl) { + return null; + } + + return parsed; + } catch (error) { + console.warn('[HelpApi] Failed to read cache', error); + return null; + } +} + +function writeCache<T>(key: string, data: T): void { + if (typeof window === 'undefined') { + return; + } + try { + const payload: CacheRecord<T> = { + storedAt: Date.now(), + data, + }; + window.localStorage.setItem(key, JSON.stringify(payload)); + } catch (error) { + console.warn('[HelpApi] Failed to write cache', error); + } +} + +async function requestJson<T>(url: string): Promise<T> { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + }, + credentials: 'same-origin', + }); + + if (!response.ok) { + const error = new Error('Help request failed'); + (error as any).status = response.status; + throw error; + } + + const payload = await response.json(); + return payload as T; +} + +export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> { + const cacheKey = listCacheKey(locale); + const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL); + + if (cached && !options?.forceRefresh) { + return { articles: cached.data, servedFromCache: true }; + } + + try { + const params = new URLSearchParams({ + audience: AUDIENCE, + locale, + }); + const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`); + const articles = Array.isArray(data?.data) ? data.data : []; + writeCache(cacheKey, articles); + return { articles, servedFromCache: false }; + } catch (error) { + if (cached) { + return { articles: cached.data, servedFromCache: true }; + } + console.error('[HelpApi] Failed to fetch help articles', error); + throw error; + } +} + +export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> { + const cacheKey = detailCacheKey(locale, slug); + const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL); + + if (cached) { + return { article: cached.data, servedFromCache: true }; + } + + try { + const params = new URLSearchParams({ + audience: AUDIENCE, + locale, + }); + const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`); + const article = data?.data ?? { slug, title: slug, summary: '' }; + writeCache(cacheKey, article); + return { article, servedFromCache: false }; + } catch (error) { + if (cached) { + return { article: cached.data, servedFromCache: true }; + } + console.error('[HelpApi] Failed to fetch help article', error); + throw error; + } +} diff --git a/resources/views/filament/super-admin/pages/coolify-deployments.blade.php b/resources/views/filament/super-admin/pages/coolify-deployments.blade.php new file mode 100644 index 0000000..568edce --- /dev/null +++ b/resources/views/filament/super-admin/pages/coolify-deployments.blade.php @@ -0,0 +1,69 @@ +<x-filament-panels::page> + <div class="space-y-6"> + <x-filament::section heading="Service Controls"> + <div class="grid gap-4 md:grid-cols-2"> + @foreach($services as $service) + <div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60"> + <div class="flex items-center justify-between"> + <div> + <p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $service['label'] }}</p> + <p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p> + </div> + <span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100"> + {{ ucfirst($service['status'] ?? 'unknown') }} + </span> + </div> + + <div class="mt-4 flex flex-wrap gap-2"> + <x-filament::button size="sm" color="warning" wire:click="restart('{{ $service['service_id'] }}')"> + Restart + </x-filament::button> + <x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $service['service_id'] }}')"> + Redeploy + </x-filament::button> + @if($coolifyWebUrl) + <x-filament::button tag="a" size="sm" color="gray" href="{{ $coolifyWebUrl }}/services/{{ $service['service_id'] }}" target="_blank"> + Open in Coolify + </x-filament::button> + @endif + </div> + </div> + @endforeach + </div> + </x-filament::section> + + <x-filament::section heading="Recent Actions"> + <div class="overflow-x-auto"> + <table class="min-w-full text-sm"> + <thead> + <tr class="text-left text-xs uppercase tracking-wide text-slate-500"> + <th class="px-3 py-2">When</th> + <th class="px-3 py-2">User</th> + <th class="px-3 py-2">Service</th> + <th class="px-3 py-2">Action</th> + <th class="px-3 py-2">Status</th> + </tr> + </thead> + <tbody class="divide-y divide-slate-100"> + @forelse($recentLogs as $log) + <tr> + <td class="px-3 py-2 text-slate-700 dark:text-slate-200">{{ $log['created_at'] }}</td> + <td class="px-3 py-2 text-slate-600">{{ $log['user'] }}</td> + <td class="px-3 py-2 font-mono text-xs">{{ $log['service_id'] }}</td> + <td class="px-3 py-2">{{ ucfirst($log['action']) }}</td> + <td class="px-3 py-2">{{ $log['status_code'] ?? '—' }}</td> + </tr> + @empty + <tr> + <td colspan="5" class="px-3 py-4 text-center text-slate-500">No actions recorded yet.</td> + </tr> + @endforelse + </tbody> + </table> + </div> + <x-filament::link href="{{ route('filament.superadmin.resources.coolify-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600"> + View full log → + </x-filament::link> + </x-filament::section> + </div> +</x-filament-panels::page> diff --git a/resources/views/filament/widgets/coolify-platform-health.blade.php b/resources/views/filament/widgets/coolify-platform-health.blade.php new file mode 100644 index 0000000..a19d41c --- /dev/null +++ b/resources/views/filament/widgets/coolify-platform-health.blade.php @@ -0,0 +1,48 @@ +<x-filament-widgets::widget> + <x-filament::section heading="Infra Status (Coolify)"> + <div class="grid gap-4 md:grid-cols-2"> + @forelse($services as $service) + <div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60"> + <div class="flex items-center justify-between"> + <div> + <p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $service['label'] }}</p> + <p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p> + </div> + <span @class([ + 'rounded-full px-3 py-1 text-xs font-semibold', + 'bg-emerald-100 text-emerald-800' => $service['status'] === 'running', + 'bg-amber-100 text-amber-800' => $service['status'] === 'deploying', + 'bg-rose-100 text-rose-800' => $service['status'] === 'unreachable' || $service['status'] === 'error', + 'bg-slate-100 text-slate-600' => ! in_array($service['status'], ['running', 'deploying', 'unreachable', 'error']), + ])> + {{ ucfirst($service['status']) }} + </span> + </div> + + @if(isset($service['error'])) + <p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $service['error'] }}</p> + @else + <dl class="mt-3 grid grid-cols-3 gap-2 text-xs"> + <div> + <dt class="text-slate-500 dark:text-slate-400">CPU</dt> + <dd class="font-semibold text-slate-900 dark:text-white">{{ $service['cpu'] ?? '—' }}%</dd> + </div> + <div> + <dt class="text-slate-500 dark:text-slate-400">Memory</dt> + <dd class="font-semibold text-slate-900 dark:text-white">{{ $service['memory'] ?? '—' }}%</dd> + </div> + <div> + <dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt> + <dd class="font-semibold text-slate-900 dark:text-white"> + {{ $service['last_deploy'] ? \Illuminate\Support\Carbon::parse($service['last_deploy'])->diffForHumans() : '—' }} + </dd> + </div> + </dl> + @endif + </div> + @empty + <p class="text-sm text-slate-500 dark:text-slate-300">No Coolify services configured.</p> + @endforelse + </div> + </x-filament::section> +</x-filament-widgets::widget> diff --git a/routes/api.php b/routes/api.php index bcb3685..0c586d9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ <?php use App\Http\Controllers\Api\EventPublicController; +use App\Http\Controllers\Api\HelpController; use App\Http\Controllers\Api\Marketing\CouponPreviewController; use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\Tenant\DashboardController; @@ -12,6 +13,7 @@ use App\Http\Controllers\Api\Tenant\EventMemberController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\OnboardingController; +use App\Http\Controllers\Api\Tenant\PhotoboothController; use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\Api\Tenant\ProfileController; use App\Http\Controllers\Api\Tenant\SettingsController; @@ -57,6 +59,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); Route::middleware('throttle:100,1')->group(function () { + Route::get('/help', [HelpController::class, 'index'])->name('help.index'); + Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show'); + Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package'); @@ -154,6 +159,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats'); }); + Route::prefix('photobooth')->middleware('tenant.admin')->group(function () { + Route::get('/', [PhotoboothController::class, 'show'])->name('tenant.events.photobooth.show'); + Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable'); + Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate'); + Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable'); + }); + Route::get('members', [EventMemberController::class, 'index']) ->middleware('tenant.admin') ->name('tenant.events.members.index'); diff --git a/routes/console.php b/routes/console.php index b92fe25..a886cd0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,18 +8,6 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -Artisan::command('storage:monitor', function () { - $this->comment('Storage monitor placeholder – implement metrics collection here.'); -})->purpose('Collect storage capacity statistics for dashboards'); - -Artisan::command('storage:archive-pending', function () { - $this->comment('Archive dispatcher placeholder – enqueue archive jobs here.'); -})->purpose('Dispatch archive jobs for events ready to move to cold storage'); - -Artisan::command('storage:check-upload-queues', function () { - $this->comment('Upload queue health placeholder – verify upload pipelines and report issues.'); -})->purpose('Check upload queues for stalled or failed jobs and alert admins'); - Artisan::command('metrics:package-limits {--reset}', function () { $snapshot = PackageLimitMetrics::snapshot(); diff --git a/tests/Feature/Api/HelpControllerTest.php b/tests/Feature/Api/HelpControllerTest.php new file mode 100644 index 0000000..8d4ad61 --- /dev/null +++ b/tests/Feature/Api/HelpControllerTest.php @@ -0,0 +1,59 @@ +<?php + +namespace Tests\Feature\Api; + +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Laravel\Sanctum\Sanctum; +use Tests\TestCase; + +class HelpControllerTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + $this->artisan('help:sync'); + } + + public function test_guest_help_listing_is_public(): void + { + $response = $this->getJson('/api/v1/help?audience=guest&locale=en'); + + $response->assertOk() + ->assertJsonStructure(['data' => [['slug', 'title', 'summary']]]) + ->assertJsonFragment(['slug' => 'getting-started']); + } + + public function test_guest_help_detail_returns_article(): void + { + $response = $this->getJson('/api/v1/help/getting-started?audience=guest&locale=en'); + + $response->assertOk() + ->assertJsonPath('data.slug', 'getting-started'); + + $this->assertStringContainsString('When to read this', $response->json('data.body_html')); + } + + public function test_admin_help_requires_authentication(): void + { + $this->getJson('/api/v1/help?audience=admin&locale=en')->assertStatus(401); + } + + public function test_admin_help_allows_authenticated_users(): void + { + $user = User::factory()->create([ + 'role' => 'tenant_admin', + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/help?audience=admin&locale=en'); + + $response->assertOk() + ->assertJsonFragment(['slug' => 'tenant-dashboard-overview']); + } +} diff --git a/tests/Feature/Console/CheckUploadQueuesCommandTest.php b/tests/Feature/Console/CheckUploadQueuesCommandTest.php new file mode 100644 index 0000000..2fda4f5 --- /dev/null +++ b/tests/Feature/Console/CheckUploadQueuesCommandTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Tests\Feature\Console; + +use App\Models\Event; +use App\Models\EventMediaAsset; +use App\Models\MediaStorageTarget; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Queue\QueueManager; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Mockery; +use Tests\TestCase; + +class CheckUploadQueuesCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_queue_health_snapshot_detects_alerts(): void + { + config()->set('storage-monitor.queue_health.thresholds', [ + 'media-storage' => ['warning' => 10, 'critical' => 20], + ]); + config()->set('storage-monitor.queue_health.stalled_minutes', 5); + config()->set('storage-monitor.queue_health.cache_minutes', 5); + + $manager = Mockery::mock(QueueManager::class); + $connection = Mockery::mock(\Illuminate\Contracts\Queue\Queue::class); + $manager->shouldReceive('connection')->with(config('queue.default'))->andReturn($connection); + $connection->shouldReceive('size')->with('media-storage')->andReturn(25); + $this->app->instance(QueueManager::class, $manager); + + DB::table('failed_jobs')->insert([ + 'uuid' => (string) Str::uuid(), + 'connection' => 'sync', + 'queue' => 'media-storage', + 'payload' => '{}', + 'exception' => 'Test failure', + 'failed_at' => now(), + ]); + + $target = MediaStorageTarget::create([ + 'key' => 'local-hot', + 'name' => 'Local Hot', + 'driver' => 'local', + 'config' => ['monitor_path' => storage_path('app')], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 100, + ]); + + $event = Event::factory()->create(); + $asset = EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'variant' => 'original', + 'disk' => 'local-hot', + 'path' => 'events/'.$event->id.'/pending.jpg', + 'size_bytes' => 512, + 'status' => 'pending', + ]); + EventMediaAsset::whereKey($asset->id)->update([ + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $this->artisan('storage:check-upload-queues') + ->expectsOutput('Checked 1 queue(s); 3 alert(s).') + ->assertExitCode(0); + + $snapshot = Cache::get('storage:queue-health:last'); + $this->assertNotNull($snapshot); + $this->assertSame('critical', $snapshot['queues'][0]['severity']); + $this->assertGreaterThanOrEqual(1, count($snapshot['alerts'])); + } +} diff --git a/tests/Feature/Console/DispatchStorageArchiveCommandTest.php b/tests/Feature/Console/DispatchStorageArchiveCommandTest.php new file mode 100644 index 0000000..b264ef6 --- /dev/null +++ b/tests/Feature/Console/DispatchStorageArchiveCommandTest.php @@ -0,0 +1,97 @@ +<?php + +namespace Tests\Feature\Console; + +use App\Jobs\ArchiveEventMediaAssets; +use App\Models\Event; +use App\Models\EventMediaAsset; +use App\Models\EventPackage; +use App\Models\MediaStorageTarget; +use App\Models\Package; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; +use Tests\TestCase; + +class DispatchStorageArchiveCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_dispatches_archive_jobs_for_expired_events(): void + { + $target = MediaStorageTarget::create([ + 'key' => 'local-hot', + 'name' => 'Local Hot', + 'driver' => 'local', + 'config' => ['monitor_path' => storage_path('app')], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 100, + ]); + + $event = Event::factory()->create(['status' => 'published']); + $package = Package::factory()->create(['gallery_days' => 1]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 0, + 'purchased_at' => now()->subDays(10), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->subDays(5), + ]); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'variant' => 'original', + 'disk' => 'local-hot', + 'path' => 'events/'.$event->id.'/photo.jpg', + 'size_bytes' => 1024, + 'status' => 'hot', + ]); + + Queue::fake(); + + $this->artisan('storage:archive-pending') + ->expectsOutput('Dispatched 1 archive job(s).') + ->assertExitCode(0); + + Queue::assertPushed(ArchiveEventMediaAssets::class, fn ($job) => $job->eventId === $event->id); + } + + public function test_skips_events_without_pending_assets(): void + { + $target = MediaStorageTarget::create([ + 'key' => 'archive-only', + 'name' => 'Archive Only', + 'driver' => 'local', + 'config' => ['monitor_path' => storage_path('app')], + 'is_hot' => false, + 'is_default' => false, + 'is_active' => true, + 'priority' => 50, + ]); + + $event = Event::factory()->create(['status' => 'archived']); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'variant' => 'original', + 'disk' => 'archive-only', + 'path' => 'events/'.$event->id.'/archived.jpg', + 'size_bytes' => 1024, + 'status' => 'archived', + ]); + + Queue::fake(); + + $this->artisan('storage:archive-pending') + ->expectsOutput('Dispatched 0 archive job(s).') + ->assertExitCode(0); + + Queue::assertNothingPushed(); + } +} diff --git a/tests/Feature/Console/HelpSyncCommandTest.php b/tests/Feature/Console/HelpSyncCommandTest.php new file mode 100644 index 0000000..a517f36 --- /dev/null +++ b/tests/Feature/Console/HelpSyncCommandTest.php @@ -0,0 +1,23 @@ +<?php + +namespace Tests\Feature\Console; + +use Illuminate\Support\Facades\Storage; +use Tests\TestCase; + +class HelpSyncCommandTest extends TestCase +{ + public function test_it_builds_help_cache(): void + { + Storage::fake('local'); + + $this->artisan('help:sync')->assertExitCode(0); + + Storage::disk('local')->assertExists('help/guest/en/articles.json'); + + $payload = json_decode(Storage::disk('local')->get('help/guest/en/articles.json'), true); + + $this->assertNotEmpty($payload); + $this->assertContains('getting-started', array_column($payload, 'slug')); + } +} diff --git a/tests/Feature/Console/MonitorStorageCommandTest.php b/tests/Feature/Console/MonitorStorageCommandTest.php new file mode 100644 index 0000000..c76af3a --- /dev/null +++ b/tests/Feature/Console/MonitorStorageCommandTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Tests\Feature\Console; + +use App\Models\Event; +use App\Models\EventMediaAsset; +use App\Models\MediaStorageTarget; +use App\Services\Storage\StorageHealthService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; +use Mockery; +use Tests\TestCase; + +class MonitorStorageCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_monitor_command_caches_capacity_snapshot(): void + { + $target = MediaStorageTarget::create([ + 'key' => 'local-ssd', + 'name' => 'Local SSD', + 'driver' => 'local', + 'config' => ['monitor_path' => storage_path('app')], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 100, + ]); + + $event = Event::factory()->create(); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'photo_id' => null, + 'variant' => 'original', + 'disk' => 'local-ssd', + 'path' => 'events/'.$event->id.'/photo.jpg', + 'size_bytes' => 2048, + 'status' => 'hot', + ]); + + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'photo_id' => null, + 'variant' => 'thumbnail', + 'disk' => 'local-ssd', + 'path' => 'events/'.$event->id.'/thumb.jpg', + 'size_bytes' => 512, + 'status' => 'failed', + ]); + + $health = Mockery::mock(StorageHealthService::class); + $health->shouldReceive('getCapacity')->andReturn([ + 'status' => 'ok', + 'total' => 100, + 'free' => 10, + 'used' => 90, + 'percentage' => 90, + 'path' => storage_path('app'), + ]); + $this->app->instance(StorageHealthService::class, $health); + + $this->artisan('storage:monitor') + ->expectsOutput('Storage monitor finished: 1 targets, 2 alerts.') + ->assertExitCode(0); + + $snapshot = Cache::get('storage:monitor:last'); + + $this->assertNotNull($snapshot); + $this->assertCount(1, $snapshot['targets']); + $this->assertSame('local-ssd', $snapshot['targets'][0]['key']); + $this->assertSame(2, $snapshot['targets'][0]['assets']['total']); + $this->assertGreaterThanOrEqual(1, count($snapshot['alerts'])); + } +} diff --git a/tests/Feature/Console/PhotoboothCleanupCommandTest.php b/tests/Feature/Console/PhotoboothCleanupCommandTest.php new file mode 100644 index 0000000..749c736 --- /dev/null +++ b/tests/Feature/Console/PhotoboothCleanupCommandTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Tests\Feature\Console; + +use App\Models\Event; +use App\Models\PhotoboothSetting; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use Tests\TestCase; + +class PhotoboothCleanupCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_command_disables_expired_accounts(): void + { + config([ + 'photobooth.control_service.base_url' => 'https://control.test', + 'photobooth.control_service.token' => 'secret-token', + ]); + + PhotoboothSetting::current()->update([ + '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(); + + Http::fake([ + 'https://control.test/*' => Http::response(['ok' => true], 200), + ]); + + $this->artisan('photobooth:cleanup-expired') + ->assertExitCode(0); + + $event->refresh(); + + $this->assertFalse($event->photobooth_enabled); + $this->assertNull($event->photobooth_username); + $this->assertNotNull($event->photobooth_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 new file mode 100644 index 0000000..cb14a1a --- /dev/null +++ b/tests/Feature/Photobooth/PhotoboothControllerTest.php @@ -0,0 +1,110 @@ +<?php + +namespace Tests\Feature\Photobooth; + +use App\Models\Event; +use App\Models\PhotoboothSetting; +use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\Test; +use Tests\Feature\Tenant\TenantTestCase; + +class PhotoboothControllerTest extends TenantTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + config([ + 'photobooth.control_service.base_url' => 'https://control.test', + 'photobooth.control_service.token' => 'secret-token', + ]); + + PhotoboothSetting::current()->update([ + 'control_service_base_url' => 'https://control.test', + ]); + } + + #[Test] + public function it_returns_photobooth_status_for_an_event(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'photobooth-demo', + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/photobooth"); + + $response->assertOk() + ->assertJsonPath('data.enabled', false) + ->assertJsonPath('data.username', null) + ->assertJsonPath('data.ftp.port', 2121); + } + + #[Test] + public function it_can_enable_and_rotate_photobooth_access(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'photobooth-event', + 'date' => now()->addDay(), + ]); + + Http::fake([ + 'https://control.test/*' => Http::response(['ok' => true], 200), + ]); + + $enable = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/enable"); + + $enable->assertOk() + ->assertJsonPath('data.enabled', true) + ->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; + + Http::assertSent(fn ($request) => $request->url() === 'https://control.test/users' && $request['username'] === $username); + + $rotate = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/rotate"); + + $rotate->assertOk() + ->assertJsonPath('data.enabled', true); + + $event->refresh(); + $this->assertNotSame($firstPassword, $event->photobooth_password); + + Http::assertSent(fn ($request) => $request->url() === "https://control.test/users/{$username}/rotate"); + } + + #[Test] + public function it_can_disable_photobooth_access(): void + { + $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(); + + Http::fake([ + 'https://control.test/*' => Http::response(['ok' => true], 200), + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/disable"); + + $response->assertOk() + ->assertJsonPath('data.enabled', false) + ->assertJsonPath('data.username', null); + + $event->refresh(); + $this->assertFalse($event->photobooth_enabled); + $this->assertNull($event->photobooth_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 new file mode 100644 index 0000000..8df7442 --- /dev/null +++ b/tests/Feature/Photobooth/PhotoboothFilterTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Tests\Feature\Photobooth; + +use App\Models\Event; +use App\Models\Photo; +use App\Models\Tenant; +use App\Services\EventJoinTokenService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\Test; +use Tests\TestCase; + +class PhotoboothFilterTest extends TestCase +{ + use RefreshDatabase; + + #[Test] + public function gallery_api_returns_only_photobooth_photos_when_filter_is_active(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->for($tenant) + ->create([ + 'status' => 'published', + 'photobooth_enabled' => true, + ]); + + Photo::factory()->create([ + 'event_id' => $event->id, + 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, + 'guest_name' => Photo::SOURCE_PHOTOBOOTH, + ]); + + Photo::factory()->create([ + 'event_id' => $event->id, + 'ingest_source' => Photo::SOURCE_GUEST_PWA, + ]); + + /** @var EventJoinTokenService $tokens */ + $tokens = app(EventJoinTokenService::class); + $token = $tokens->createToken($event, ['label' => 'Photobooth'])->getAttribute('plain_token'); + + $response = $this->getJson("/api/v1/events/{$token}/photos?filter=photobooth"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertSame(Photo::SOURCE_PHOTOBOOTH, $response->json('data.0.ingest_source')); + } +} diff --git a/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php b/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php new file mode 100644 index 0000000..77f60f2 --- /dev/null +++ b/tests/Feature/Photobooth/PhotoboothIngestCommandTest.php @@ -0,0 +1,93 @@ +<?php + +namespace Tests\Feature\Photobooth; + +use App\Jobs\ProcessPhotoSecurityScan; +use App\Models\Emotion; +use App\Models\Event; +use App\Models\EventPackage; +use App\Models\MediaStorageTarget; +use App\Models\Package; +use App\Models\Photo; +use App\Models\Tenant; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\Test; +use Tests\TestCase; + +class PhotoboothIngestCommandTest extends TestCase +{ + use RefreshDatabase; + + #[Test] + public function it_ingests_pending_files_and_marks_them_as_photobooth(): void + { + Storage::fake('photobooth'); + Storage::fake('public'); + + config([ + 'photobooth.import.disk' => 'photobooth', + 'photobooth.import.max_files' => 10, + 'filesystems.default' => 'public', + ]); + + MediaStorageTarget::create([ + 'key' => 'public', + 'name' => 'Local Public', + 'driver' => 'local', + 'config' => [ + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + ], + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + 'priority' => 1, + ]); + + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->for($tenant) + ->create([ + 'slug' => 'demo-event', + 'status' => 'published', + 'photobooth_enabled' => true, + ]); + + $event->update([ + 'photobooth_path' => $tenant->slug.'/'.$event->id, + ]); + + $package = Package::factory()->create(['max_photos' => 5]); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 0, + 'used_photos' => 0, + ]); + + Storage::disk('photobooth')->put($event->photobooth_path.'/sample.jpg', $this->sampleImage()); + Emotion::factory()->create(); + + Bus::fake(); + + $this->artisan('photobooth:ingest', ['--event' => $event->id]) + ->assertExitCode(0); + + $this->assertDatabaseHas('photos', [ + 'event_id' => $event->id, + 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, + ]); + + Storage::disk('photobooth')->assertMissing($event->photobooth_path.'/sample.jpg'); + + Bus::assertDispatched(ProcessPhotoSecurityScan::class); + } + + private function sampleImage(): string + { + return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=='); + } +}