Live Show data model + workflow
This commit is contained in:
12
app/Enums/PhotoLiveStatus.php
Normal file
12
app/Enums/PhotoLiveStatus.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PhotoLiveStatus: string
|
||||||
|
{
|
||||||
|
case NONE = 'none';
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case APPROVED = 'approved';
|
||||||
|
case REJECTED = 'rejected';
|
||||||
|
case EXPIRED = 'expired';
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class Event extends Model
|
|||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'name' => 'array',
|
'name' => 'array',
|
||||||
'description' => 'array',
|
'description' => 'array',
|
||||||
|
'live_show_token_rotated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
@@ -152,6 +153,29 @@ class Event extends Model
|
|||||||
return $this->eventPackage->canUploadPhoto();
|
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
|
public function getSettingsAttribute($value): array
|
||||||
{
|
{
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PhotoLiveStatus;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -37,6 +38,10 @@ class Photo extends Model
|
|||||||
'security_meta' => 'array',
|
'security_meta' => 'array',
|
||||||
'security_scanned_at' => 'datetime',
|
'security_scanned_at' => 'datetime',
|
||||||
'moderated_at' => 'datetime',
|
'moderated_at' => 'datetime',
|
||||||
|
'live_status' => PhotoLiveStatus::class,
|
||||||
|
'live_submitted_at' => 'datetime',
|
||||||
|
'live_approved_at' => 'datetime',
|
||||||
|
'live_reviewed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
@@ -79,6 +84,49 @@ class Photo extends Model
|
|||||||
return $this->belongsTo(User::class, 'moderated_by');
|
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
|
public function likes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PhotoLike::class);
|
return $this->hasMany(PhotoLike::class);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('events', 'live_show_token')) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('photos', function (Blueprint $table) {
|
||||||
|
$afterColumn = Schema::hasColumn('photos', 'moderated_by') ? 'moderated_by' : 'status';
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('photos', 'live_status')) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
85
tests/Feature/LiveShowDataModelTest.php
Normal file
85
tests/Feature/LiveShowDataModelTest.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\PhotoLiveStatus;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LiveShowDataModelTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_event_can_ensure_and_rotate_live_show_token(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user