From 4718998e07068ffec05911dc297e1eb9aaccb0f5 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 12:31:54 +0100 Subject: [PATCH] Live Show data model + workflow --- app/Enums/PhotoLiveStatus.php | 12 +++ app/Models/Event.php | 24 +++++ app/Models/Photo.php | 48 +++++++++ ..._add_live_show_columns_to_events_table.php | 46 ++++++++ ..._add_live_show_columns_to_photos_table.php | 102 ++++++++++++++++++ tests/Feature/LiveShowDataModelTest.php | 85 +++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 app/Enums/PhotoLiveStatus.php create mode 100644 database/migrations/2026_01_05_121427_add_live_show_columns_to_events_table.php create mode 100644 database/migrations/2026_01_05_121453_add_live_show_columns_to_photos_table.php create mode 100644 tests/Feature/LiveShowDataModelTest.php diff --git a/app/Enums/PhotoLiveStatus.php b/app/Enums/PhotoLiveStatus.php new file mode 100644 index 0000000..5660d1c --- /dev/null +++ b/app/Enums/PhotoLiveStatus.php @@ -0,0 +1,12 @@ + 'boolean', 'name' => 'array', 'description' => 'array', + 'live_show_token_rotated_at' => 'datetime', ]; protected static function booted(): void @@ -152,6 +153,29 @@ class Event extends Model return $this->eventPackage->canUploadPhoto(); } + public function ensureLiveShowToken(): string + { + if (is_string($this->live_show_token) && $this->live_show_token !== '') { + return $this->live_show_token; + } + + return $this->rotateLiveShowToken(); + } + + public function rotateLiveShowToken(): string + { + do { + $token = bin2hex(random_bytes(32)); + } while (self::query()->where('live_show_token', $token)->exists()); + + $this->forceFill([ + 'live_show_token' => $token, + 'live_show_token_rotated_at' => now(), + ])->save(); + + return $token; + } + public function getSettingsAttribute($value): array { if (is_array($value)) { diff --git a/app/Models/Photo.php b/app/Models/Photo.php index ad9b6e6..d97d42f 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\PhotoLiveStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -37,6 +38,10 @@ class Photo extends Model 'security_meta' => 'array', 'security_scanned_at' => 'datetime', 'moderated_at' => 'datetime', + 'live_status' => PhotoLiveStatus::class, + 'live_submitted_at' => 'datetime', + 'live_approved_at' => 'datetime', + 'live_reviewed_at' => 'datetime', ]; protected $attributes = [ @@ -79,6 +84,49 @@ class Photo extends Model return $this->belongsTo(User::class, 'moderated_by'); } + public function liveReviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'live_reviewed_by'); + } + + public function markLivePending(): void + { + $this->forceFill([ + 'live_status' => PhotoLiveStatus::PENDING, + 'live_submitted_at' => now(), + 'live_approved_at' => null, + 'live_reviewed_at' => null, + 'live_reviewed_by' => null, + 'live_rejection_reason' => null, + ])->save(); + } + + public function approveForLiveShow(?User $reviewer = null): void + { + $now = now(); + + $this->forceFill([ + 'live_status' => PhotoLiveStatus::APPROVED, + 'live_approved_at' => $now, + 'live_reviewed_at' => $now, + 'live_reviewed_by' => $reviewer?->id, + 'live_rejection_reason' => null, + ])->save(); + } + + public function rejectForLiveShow(?User $reviewer = null, ?string $reason = null): void + { + $now = now(); + + $this->forceFill([ + 'live_status' => PhotoLiveStatus::REJECTED, + 'live_approved_at' => null, + 'live_reviewed_at' => $now, + 'live_reviewed_by' => $reviewer?->id, + 'live_rejection_reason' => $reason, + ])->save(); + } + public function likes(): HasMany { return $this->hasMany(PhotoLike::class); diff --git a/database/migrations/2026_01_05_121427_add_live_show_columns_to_events_table.php b/database/migrations/2026_01_05_121427_add_live_show_columns_to_events_table.php new file mode 100644 index 0000000..1cf9988 --- /dev/null +++ b/database/migrations/2026_01_05_121427_add_live_show_columns_to_events_table.php @@ -0,0 +1,46 @@ +string('live_show_token', 96) + ->nullable() + ->after('settings'); + $table->unique('live_show_token', 'events_live_show_token_unique'); + } + + if (! Schema::hasColumn('events', 'live_show_token_rotated_at')) { + $table->timestamp('live_show_token_rotated_at') + ->nullable() + ->after('live_show_token'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + if (Schema::hasColumn('events', 'live_show_token')) { + $table->dropUnique('events_live_show_token_unique'); + $table->dropColumn('live_show_token'); + } + + if (Schema::hasColumn('events', 'live_show_token_rotated_at')) { + $table->dropColumn('live_show_token_rotated_at'); + } + }); + } +}; diff --git a/database/migrations/2026_01_05_121453_add_live_show_columns_to_photos_table.php b/database/migrations/2026_01_05_121453_add_live_show_columns_to_photos_table.php new file mode 100644 index 0000000..6bc5c72 --- /dev/null +++ b/database/migrations/2026_01_05_121453_add_live_show_columns_to_photos_table.php @@ -0,0 +1,102 @@ +string('live_status', 32) + ->default('none') + ->after($afterColumn); + $table->index(['event_id', 'live_status'], 'photos_event_live_status_index'); + } + + if (! Schema::hasColumn('photos', 'live_submitted_at')) { + $table->timestamp('live_submitted_at') + ->nullable() + ->after('live_status'); + } + + if (! Schema::hasColumn('photos', 'live_approved_at')) { + $table->timestamp('live_approved_at') + ->nullable() + ->after('live_submitted_at'); + $table->index(['event_id', 'live_status', 'live_approved_at'], 'photos_event_live_approved_index'); + } + + if (! Schema::hasColumn('photos', 'live_reviewed_at')) { + $table->timestamp('live_reviewed_at') + ->nullable() + ->after('live_approved_at'); + } + + if (! Schema::hasColumn('photos', 'live_reviewed_by')) { + $table->foreignId('live_reviewed_by') + ->nullable() + ->after('live_reviewed_at') + ->constrained('users') + ->nullOnDelete(); + } + + if (! Schema::hasColumn('photos', 'live_rejection_reason')) { + $table->string('live_rejection_reason', 64) + ->nullable() + ->after('live_reviewed_by'); + } + + if (! Schema::hasColumn('photos', 'live_priority')) { + $table->unsignedSmallInteger('live_priority') + ->default(0) + ->after('live_rejection_reason'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + if (Schema::hasColumn('photos', 'live_priority')) { + $table->dropColumn('live_priority'); + } + + if (Schema::hasColumn('photos', 'live_rejection_reason')) { + $table->dropColumn('live_rejection_reason'); + } + + if (Schema::hasColumn('photos', 'live_reviewed_by')) { + $table->dropConstrainedForeignId('live_reviewed_by'); + } + + if (Schema::hasColumn('photos', 'live_reviewed_at')) { + $table->dropColumn('live_reviewed_at'); + } + + if (Schema::hasColumn('photos', 'live_approved_at')) { + $table->dropIndex('photos_event_live_approved_index'); + $table->dropColumn('live_approved_at'); + } + + if (Schema::hasColumn('photos', 'live_submitted_at')) { + $table->dropColumn('live_submitted_at'); + } + + if (Schema::hasColumn('photos', 'live_status')) { + $table->dropIndex('photos_event_live_status_index'); + $table->dropColumn('live_status'); + } + }); + } +}; diff --git a/tests/Feature/LiveShowDataModelTest.php b/tests/Feature/LiveShowDataModelTest.php new file mode 100644 index 0000000..953eba2 --- /dev/null +++ b/tests/Feature/LiveShowDataModelTest.php @@ -0,0 +1,85 @@ +create(); + + $token = $event->ensureLiveShowToken(); + + $this->assertIsString($token); + $this->assertSame(64, strlen($token)); + $this->assertSame($token, $event->refresh()->live_show_token); + $this->assertNotNull($event->live_show_token_rotated_at); + + $rotated = $event->rotateLiveShowToken(); + + $this->assertIsString($rotated); + $this->assertSame(64, strlen($rotated)); + $this->assertNotSame($token, $rotated); + $this->assertSame($rotated, $event->refresh()->live_show_token); + } + + public function test_photo_live_status_is_cast_and_defaults_to_none(): void + { + $photo = Photo::factory()->create(); + $photo->refresh(); + + $this->assertInstanceOf(PhotoLiveStatus::class, $photo->live_status); + $this->assertSame(PhotoLiveStatus::NONE, $photo->live_status); + + $photo->forceFill([ + 'live_status' => PhotoLiveStatus::PENDING, + 'live_submitted_at' => now(), + ])->save(); + + $photo->refresh(); + + $this->assertSame(PhotoLiveStatus::PENDING, $photo->live_status); + $this->assertNotNull($photo->live_submitted_at); + } + + public function test_photo_live_workflow_sets_expected_timestamps_and_reviewer(): void + { + $reviewer = User::factory()->create(); + $photo = Photo::factory()->create(); + + $photo->markLivePending(); + $photo->refresh(); + + $this->assertSame(PhotoLiveStatus::PENDING, $photo->live_status); + $this->assertNotNull($photo->live_submitted_at); + $this->assertNull($photo->live_reviewed_at); + $this->assertNull($photo->live_approved_at); + + $photo->approveForLiveShow($reviewer); + $photo->refresh(); + + $this->assertSame(PhotoLiveStatus::APPROVED, $photo->live_status); + $this->assertNotNull($photo->live_reviewed_at); + $this->assertNotNull($photo->live_approved_at); + $this->assertSame($reviewer->id, $photo->live_reviewed_by); + $this->assertNull($photo->live_rejection_reason); + + $photo->rejectForLiveShow($reviewer, 'policy_violation'); + $photo->refresh(); + + $this->assertSame(PhotoLiveStatus::REJECTED, $photo->live_status); + $this->assertNotNull($photo->live_reviewed_at); + $this->assertNull($photo->live_approved_at); + $this->assertSame($reviewer->id, $photo->live_reviewed_by); + $this->assertSame('policy_violation', $photo->live_rejection_reason); + } +}