Add live show realtime endpoints
This commit is contained in:
@@ -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)"}
|
||||
|
||||
347
app/Http/Controllers/Api/LiveShowController.php
Normal file
347
app/Http/Controllers/Api/LiveShowController.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\StreamedEvent;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LiveShowController extends BaseController
|
||||
{
|
||||
private const DEFAULT_RETENTION_HOURS = 12;
|
||||
|
||||
private const DEFAULT_LIMIT = 50;
|
||||
|
||||
private const MAX_LIMIT = 200;
|
||||
|
||||
private const STREAM_TICK_SECONDS = 2;
|
||||
|
||||
private const STREAM_MAX_SECONDS = 60;
|
||||
|
||||
private const STREAM_PING_SECONDS = 10;
|
||||
|
||||
public function state(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$event = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
91
tests/Feature/LiveShowRealtimeTest.php
Normal file
91
tests/Feature/LiveShowRealtimeTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\PhotoLiveStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LiveShowRealtimeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_live_show_state_returns_ordered_photos_and_cursor(): void
|
||||
{
|
||||
$event = Event::factory()->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user