From 2abd1d113f4b1491d6458063ba2beb1b6aa33ecf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 13:09:11 +0100 Subject: [PATCH] Add live show realtime endpoints --- .beads/issues.jsonl | 2 +- .../Controllers/Api/LiveShowController.php | 347 ++++++++++++++++++ app/Models/Photo.php | 1 + routes/api.php | 7 + tests/Feature/LiveShowRealtimeTest.php | 91 +++++ 5 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/LiveShowController.php create mode 100644 tests/Feature/LiveShowRealtimeTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2ece200..f533924 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -104,7 +104,7 @@ {"id":"fotospiel-app-q2n","title":"Checkout refactor: wizard foundations + updated steps","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:58.701443698+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:04.313207281+01:00","closed_at":"2026-01-01T16:06:04.313207281+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-q4l","title":"Legal: disclose checkout/coupon fraud IP/device signals","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T23:33:09.347178363+01:00","created_by":"soeren","updated_at":"2026-01-02T23:33:54.68346278+01:00","closed_at":"2026-01-02T23:33:54.68346278+01:00","close_reason":"Closed"} {"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"} -{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T11:11:06.028871737+01:00","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]} +{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"} {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} diff --git a/app/Http/Controllers/Api/LiveShowController.php b/app/Http/Controllers/Api/LiveShowController.php new file mode 100644 index 0000000..0b5c12c --- /dev/null +++ b/app/Http/Controllers/Api/LiveShowController.php @@ -0,0 +1,347 @@ +resolveEvent($token); + + if (! $event) { + return $this->notFound(); + } + + $settings = $this->liveShowSettings($event); + $settingsVersion = $this->settingsVersion($settings); + $limit = $this->resolveLimit($request); + + $photos = $this->baseLiveShowQuery($event, $settings) + ->orderByDesc('live_approved_at') + ->orderByDesc('id') + ->limit($limit) + ->get() + ->reverse() + ->values(); + + $cursor = $this->buildCursor($photos->last()); + + return response()->json([ + 'event' => [ + 'id' => $event->id, + 'slug' => $event->slug, + 'name' => $event->name, + 'default_locale' => $event->default_locale, + ], + 'settings' => $settings, + 'settings_version' => $settingsVersion, + 'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)), + 'cursor' => $cursor, + ]); + } + + public function updates(Request $request, string $token): JsonResponse + { + $event = $this->resolveEvent($token); + + if (! $event) { + return $this->notFound(); + } + + $cursor = $this->parseCursor($request); + + if ($cursor === null) { + return response()->json([ + 'error' => 'invalid_cursor', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $settings = $this->liveShowSettings($event); + $settingsVersion = $this->settingsVersion($settings); + $limit = $this->resolveLimit($request); + + $query = $this->baseLiveShowQuery($event, $settings); + $this->applyCursor($query, $cursor); + + $photos = $query + ->orderBy('live_approved_at') + ->orderBy('id') + ->limit($limit) + ->get(); + + $nextCursor = $this->buildCursor($photos->last()); + $requestedSettingsVersion = (string) $request->query('settings_version', ''); + $includeSettings = $requestedSettingsVersion === '' || $requestedSettingsVersion !== $settingsVersion; + + return response()->json([ + 'settings' => $includeSettings ? $settings : null, + 'settings_version' => $settingsVersion, + 'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)), + 'cursor' => $nextCursor ?? $cursor, + ]); + } + + public function stream(Request $request, string $token): Response + { + $event = $this->resolveEvent($token); + + if (! $event) { + return $this->notFound(); + } + + $cursor = $this->parseCursor($request); + + if ($cursor === null) { + return response()->json([ + 'error' => 'invalid_cursor', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $settings = $this->liveShowSettings($event); + $settingsVersion = $this->settingsVersion($settings); + $requestedSettingsVersion = (string) $request->query('settings_version', ''); + $lastSettingsCheck = CarbonImmutable::now(); + $lastPingAt = CarbonImmutable::now(); + $startedAt = CarbonImmutable::now(); + + return response()->eventStream(function () use ($event, $cursor, $settings, $settingsVersion, $requestedSettingsVersion, $startedAt, $lastPingAt, $lastSettingsCheck) { + $lastApprovedAt = $cursor['approved_at']; + $lastId = $cursor['id']; + $currentSettingsVersion = $requestedSettingsVersion !== '' ? $requestedSettingsVersion : $settingsVersion; + $currentSettings = $settings; + + while (CarbonImmutable::now()->diffInSeconds($startedAt) < self::STREAM_MAX_SECONDS) { + if (connection_aborted()) { + break; + } + + $now = CarbonImmutable::now(); + + if ($now->diffInSeconds($lastSettingsCheck) >= self::STREAM_PING_SECONDS) { + $event->refresh(); + $currentSettings = $this->liveShowSettings($event); + $newSettingsVersion = $this->settingsVersion($currentSettings); + + if ($newSettingsVersion !== $currentSettingsVersion) { + $currentSettingsVersion = $newSettingsVersion; + yield new StreamedEvent( + event: 'settings.updated', + data: [ + 'settings' => $currentSettings, + 'settings_version' => $currentSettingsVersion, + ] + ); + } + + $lastSettingsCheck = $now; + } + + $query = $this->baseLiveShowQuery($event, $currentSettings); + $this->applyCursor($query, [ + 'approved_at' => $lastApprovedAt, + 'id' => $lastId, + ]); + + $updates = $query + ->orderBy('live_approved_at') + ->orderBy('id') + ->limit(self::MAX_LIMIT) + ->get(); + + foreach ($updates as $photo) { + $payload = $this->serializePhoto($photo); + $lastApprovedAt = $photo->live_approved_at; + $lastId = $photo->id; + + yield new StreamedEvent( + event: 'photo.approved', + data: [ + 'photo' => $payload, + 'cursor' => $this->buildCursor($photo), + ] + ); + } + + if ($now->diffInSeconds($lastPingAt) >= self::STREAM_PING_SECONDS) { + $lastPingAt = $now; + yield new StreamedEvent( + event: 'ping', + data: [ + 'time' => $now->toAtomString(), + ] + ); + } + + sleep(self::STREAM_TICK_SECONDS); + } + }, Response::HTTP_OK, [ + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + + private function resolveEvent(string $token): ?Event + { + if ($token === '') { + return null; + } + + return Event::query() + ->where('live_show_token', $token) + ->first(); + } + + private function liveShowSettings(Event $event): array + { + $settings = is_array($event->settings) ? $event->settings : []; + $liveShow = Arr::get($settings, 'live_show', []); + + return array_merge([ + 'retention_window_hours' => self::DEFAULT_RETENTION_HOURS, + 'playback_mode' => 'newest_first', + 'pace_mode' => 'auto', + 'fixed_interval_seconds' => 8, + 'layout_mode' => 'single', + 'effect_preset' => 'film_cut', + 'effect_intensity' => 70, + 'background_mode' => 'blur_last', + ], is_array($liveShow) ? $liveShow : []); + } + + private function settingsVersion(array $settings): string + { + return sha1(json_encode($settings)); + } + + private function resolveLimit(Request $request): int + { + $limit = (int) $request->query('limit', self::DEFAULT_LIMIT); + + if ($limit <= 0) { + return self::DEFAULT_LIMIT; + } + + return min($limit, self::MAX_LIMIT); + } + + private function baseLiveShowQuery(Event $event, array $settings): Builder + { + $query = Photo::query() + ->where('event_id', $event->id) + ->where('live_status', PhotoLiveStatus::APPROVED->value) + ->where('status', 'approved') + ->whereNotNull('live_approved_at'); + + $retention = (int) ($settings['retention_window_hours'] ?? self::DEFAULT_RETENTION_HOURS); + if ($retention > 0) { + $query->where('live_approved_at', '>=', now()->subHours($retention)); + } + + return $query; + } + + private function applyCursor(Builder $query, array $cursor): void + { + $afterAt = $cursor['approved_at']; + $afterId = $cursor['id']; + + if (! $afterAt) { + return; + } + + if ($afterId <= 0) { + $query->where('live_approved_at', '>', $afterAt); + + return; + } + + $query->where(function (Builder $inner) use ($afterAt, $afterId) { + $inner->where('live_approved_at', '>', $afterAt) + ->orWhere(function (Builder $tie) use ($afterAt, $afterId) { + $tie->where('live_approved_at', $afterAt) + ->where('id', '>', $afterId); + }); + }); + } + + private function serializePhoto(Photo $photo): array + { + return [ + 'id' => $photo->id, + 'full_url' => $photo->file_path, + 'thumb_url' => $photo->thumbnail_path, + 'approved_at' => $photo->live_approved_at?->toAtomString(), + 'width' => $photo->width, + 'height' => $photo->height, + 'is_featured' => (bool) $photo->is_featured, + 'live_priority' => (int) ($photo->live_priority ?? 0), + ]; + } + + private function buildCursor(?Photo $photo): ?array + { + if (! $photo || ! $photo->live_approved_at) { + return null; + } + + return [ + 'approved_at' => $photo->live_approved_at, + 'id' => $photo->id, + ]; + } + + private function parseCursor(Request $request): ?array + { + $afterAt = trim((string) $request->query('after_approved_at', '')); + $afterId = (int) $request->query('after_id', 0); + + if ($afterAt === '' && $afterId === 0) { + return [ + 'approved_at' => null, + 'id' => 0, + ]; + } + + try { + $approvedAt = CarbonImmutable::parse($afterAt); + } catch (\Throwable) { + return null; + } + + return [ + 'approved_at' => $approvedAt, + 'id' => max(0, $afterId), + ]; + } + + private function notFound(): JsonResponse + { + return response()->json([ + 'error' => 'live_show_not_found', + ], Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index d97d42f..75c22c0 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -47,6 +47,7 @@ class Photo extends Model protected $attributes = [ 'security_scan_status' => 'pending', 'ingest_source' => self::SOURCE_GUEST_PWA, + 'live_status' => PhotoLiveStatus::NONE->value, ]; public function mediaAsset(): BelongsTo diff --git a/routes/api.php b/routes/api.php index 56bab66..240274b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\HelpController; use App\Http\Controllers\Api\LegalController; +use App\Http\Controllers\Api\LiveShowController; use App\Http\Controllers\Api\Marketing\CouponPreviewController; use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\SparkboothUploadController; @@ -83,6 +84,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show'); Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show'); + Route::prefix('live-show')->name('live-show.')->group(function () { + Route::get('{token}', [LiveShowController::class, 'state'])->name('state'); + Route::get('{token}/updates', [LiveShowController::class, 'updates'])->name('updates'); + Route::get('{token}/stream', [LiveShowController::class, 'stream'])->name('stream'); + }); + 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'); diff --git a/tests/Feature/LiveShowRealtimeTest.php b/tests/Feature/LiveShowRealtimeTest.php new file mode 100644 index 0000000..d7f05f5 --- /dev/null +++ b/tests/Feature/LiveShowRealtimeTest.php @@ -0,0 +1,91 @@ +create([ + 'live_show_token' => str_repeat('a', 64), + ]); + + $first = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now()->subMinutes(10), + ]); + + $second = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now()->subMinutes(3), + ]); + + $response = $this->getJson("/api/v1/live-show/{$event->live_show_token}"); + + $response->assertOk(); + $response->assertJsonPath('photos.0.id', $first->id); + $response->assertJsonPath('photos.1.id', $second->id); + $response->assertJsonPath('cursor.id', $second->id); + $response->assertJsonStructure([ + 'settings', + 'settings_version', + 'photos', + 'cursor' => ['approved_at', 'id'], + ]); + } + + public function test_live_show_updates_respects_cursor(): void + { + $event = Event::factory()->create([ + 'live_show_token' => str_repeat('b', 64), + ]); + + $first = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now()->subMinutes(9), + ]); + + $second = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now()->subMinutes(6), + ]); + + $third = Photo::factory()->for($event)->create([ + 'status' => 'approved', + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => now()->subMinutes(1), + ]); + + $query = http_build_query([ + 'after_approved_at' => $second->live_approved_at->toAtomString(), + 'after_id' => $second->id, + ]); + + $response = $this->getJson("/api/v1/live-show/{$event->live_show_token}/updates?{$query}"); + + $response->assertOk(); + $response->assertJsonCount(1, 'photos'); + $response->assertJsonPath('photos.0.id', $third->id); + $response->assertJsonPath('cursor.id', $third->id); + $response->assertJsonMissing(['photos' => [['id' => $first->id]]]); + } + + public function test_live_show_returns_404_for_invalid_token(): void + { + $response = $this->getJson('/api/v1/live-show/invalid-token'); + + $response->assertNotFound(); + } +}