Fix scopeWithChildren to accept an event ID and add scopeForFestival scope for resolving any event to its full festival context. Extend DevSeeder with sections, time slots, and persons on the festival. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
317 lines
8.9 KiB
PHP
317 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
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\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class Event extends Model
|
|
{
|
|
use HasFactory;
|
|
use HasUlids;
|
|
use SoftDeletes;
|
|
|
|
/** @var array<string, list<string>> Allowed status transitions */
|
|
public const STATUS_TRANSITIONS = [
|
|
'draft' => ['published'],
|
|
'published' => ['registration_open', 'draft'],
|
|
'registration_open' => ['buildup', 'published'],
|
|
'buildup' => ['showday'],
|
|
'showday' => ['teardown'],
|
|
'teardown' => ['closed'],
|
|
'closed' => [],
|
|
];
|
|
|
|
/**
|
|
* Statuses that cascade from a festival parent to its children.
|
|
* When the parent reaches one of these, children in an earlier status follow.
|
|
*/
|
|
private const CASCADE_STATUSES = ['showday', 'teardown', 'closed'];
|
|
|
|
/** Ordered list used to determine "earlier" status. */
|
|
private const STATUS_ORDER = [
|
|
'draft' => 0,
|
|
'published' => 1,
|
|
'registration_open' => 2,
|
|
'buildup' => 3,
|
|
'showday' => 4,
|
|
'teardown' => 5,
|
|
'closed' => 6,
|
|
];
|
|
|
|
protected $fillable = [
|
|
'organisation_id',
|
|
'parent_event_id',
|
|
'name',
|
|
'slug',
|
|
'start_date',
|
|
'end_date',
|
|
'timezone',
|
|
'status',
|
|
'event_type',
|
|
'event_type_label',
|
|
'sub_event_label',
|
|
'is_recurring',
|
|
'recurrence_rule',
|
|
'recurrence_exceptions',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'start_date' => 'date',
|
|
'end_date' => 'date',
|
|
'is_recurring' => 'boolean',
|
|
'recurrence_exceptions' => 'array',
|
|
'event_type' => 'string',
|
|
];
|
|
}
|
|
|
|
public function organisation(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Organisation::class);
|
|
}
|
|
|
|
public function users(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(User::class, 'event_user_roles')
|
|
->withPivot('role')
|
|
->withTimestamps();
|
|
}
|
|
|
|
public function invitations(): HasMany
|
|
{
|
|
return $this->hasMany(UserInvitation::class);
|
|
}
|
|
|
|
public function locations(): HasMany
|
|
{
|
|
return $this->hasMany(Location::class);
|
|
}
|
|
|
|
public function festivalSections(): HasMany
|
|
{
|
|
return $this->hasMany(FestivalSection::class);
|
|
}
|
|
|
|
public function timeSlots(): HasMany
|
|
{
|
|
return $this->hasMany(TimeSlot::class);
|
|
}
|
|
|
|
public function persons(): HasMany
|
|
{
|
|
return $this->hasMany(Person::class);
|
|
}
|
|
|
|
public function crowdLists(): HasMany
|
|
{
|
|
return $this->hasMany(CrowdList::class);
|
|
}
|
|
|
|
public function parent(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Event::class, 'parent_event_id');
|
|
}
|
|
|
|
public function children(): HasMany
|
|
{
|
|
return $this->hasMany(Event::class, 'parent_event_id')
|
|
->orderBy('start_date')
|
|
->orderBy('name');
|
|
}
|
|
|
|
// ----- Status State Machine -----
|
|
|
|
public function canTransitionTo(string $newStatus): bool
|
|
{
|
|
$allowed = self::STATUS_TRANSITIONS[$this->status] ?? [];
|
|
|
|
return in_array($newStatus, $allowed, true);
|
|
}
|
|
|
|
/** @return list<string> Missing prerequisites (empty = OK) */
|
|
public function getTransitionPrerequisites(string $newStatus): array
|
|
{
|
|
$missing = [];
|
|
|
|
switch ($newStatus) {
|
|
case 'published':
|
|
if (! $this->name || ! $this->start_date || ! $this->end_date) {
|
|
$missing[] = 'Event must have a name, start date, and end date.';
|
|
}
|
|
break;
|
|
case 'registration_open':
|
|
if ($this->timeSlots()->count() === 0) {
|
|
$missing[] = 'At least one time slot must exist before opening registration.';
|
|
}
|
|
if ($this->festivalSections()->count() === 0) {
|
|
$missing[] = 'At least one section must exist before opening registration.';
|
|
}
|
|
break;
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
/** @return array{errors: list<string>} */
|
|
public function canTransitionToWithPrerequisites(string $newStatus): array
|
|
{
|
|
if (! $this->canTransitionTo($newStatus)) {
|
|
return ['errors' => ["Status transition from '{$this->status}' to '{$newStatus}' is not allowed."]];
|
|
}
|
|
|
|
return ['errors' => $this->getTransitionPrerequisites($newStatus)];
|
|
}
|
|
|
|
public function transitionTo(string $newStatus): void
|
|
{
|
|
if (! $this->canTransitionTo($newStatus)) {
|
|
throw new \InvalidArgumentException(
|
|
"Cannot transition from '{$this->status}' to '{$newStatus}'."
|
|
);
|
|
}
|
|
|
|
$this->update(['status' => $newStatus]);
|
|
|
|
if ($this->isFestival() && in_array($newStatus, self::CASCADE_STATUSES, true)) {
|
|
$this->cascadeStatusToChildren($newStatus);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cascade a status to all children that are in an earlier lifecycle stage.
|
|
*/
|
|
private function cascadeStatusToChildren(string $newStatus): void
|
|
{
|
|
$targetOrder = self::STATUS_ORDER[$newStatus];
|
|
|
|
$earlierStatuses = array_keys(
|
|
array_filter(self::STATUS_ORDER, fn (int $order) => $order < $targetOrder)
|
|
);
|
|
|
|
$this->children()
|
|
->whereIn('status', $earlierStatuses)
|
|
->update(['status' => $newStatus]);
|
|
}
|
|
|
|
// ----- Scopes -----
|
|
|
|
public function scopeTopLevel(Builder $query): Builder
|
|
{
|
|
return $query->whereNull('parent_event_id');
|
|
}
|
|
|
|
public function scopeChildren(Builder $query): Builder
|
|
{
|
|
return $query->whereNotNull('parent_event_id');
|
|
}
|
|
|
|
public function scopeFestivals(Builder $query): Builder
|
|
{
|
|
return $query->whereIn('event_type', ['festival', 'series']);
|
|
}
|
|
|
|
public function scopeWithChildren(Builder $query, string $eventId): Builder
|
|
{
|
|
return $query->where(function (Builder $q) use ($eventId) {
|
|
$q->where('id', $eventId)
|
|
->orWhere('parent_event_id', $eventId);
|
|
});
|
|
}
|
|
|
|
public function scopeForFestival(Builder $query, Event $event): Builder
|
|
{
|
|
$rootId = $event->parent_event_id ?? $event->id;
|
|
|
|
return $query->withChildren($rootId);
|
|
}
|
|
|
|
// ----- Helpers -----
|
|
|
|
public function isFestival(): bool
|
|
{
|
|
return $this->event_type !== 'event' && $this->parent_event_id === null;
|
|
}
|
|
|
|
public function isSubEvent(): bool
|
|
{
|
|
return $this->parent_event_id !== null;
|
|
}
|
|
|
|
public function isFlatEvent(): bool
|
|
{
|
|
return $this->parent_event_id === null && $this->children()->count() === 0;
|
|
}
|
|
|
|
public function hasChildren(): bool
|
|
{
|
|
return $this->children()->exists();
|
|
}
|
|
|
|
public function scopeDraft(Builder $query): Builder
|
|
{
|
|
return $query->where('status', 'draft');
|
|
}
|
|
|
|
public function scopePublished(Builder $query): Builder
|
|
{
|
|
return $query->where('status', 'published');
|
|
}
|
|
|
|
public function scopeActive(Builder $query): Builder
|
|
{
|
|
return $query->whereIn('status', ['showday', 'buildup', 'teardown']);
|
|
}
|
|
|
|
/**
|
|
* Eager-load the event's own time slots plus, for sub-events,
|
|
* time slots from parent cross_event sections.
|
|
*/
|
|
public function scopeWithOperationalContext(Builder $query): Builder
|
|
{
|
|
return $query->with(['timeSlots', 'festivalSections', 'parent.timeSlots', 'parent.festivalSections']);
|
|
}
|
|
|
|
/**
|
|
* Get all time slots relevant for shift planning:
|
|
* - Flat event: own time slots
|
|
* - Festival parent: own time slots + all children's time slots
|
|
* - Sub-event: own time slots + parent's time slots
|
|
*/
|
|
public function getAllRelevantTimeSlots(): Collection
|
|
{
|
|
$ownSlots = $this->timeSlots()->orderBy('date')->orderBy('start_time')->get();
|
|
|
|
if ($this->isFestival()) {
|
|
$childIds = $this->children()->pluck('id');
|
|
$childSlots = TimeSlot::whereIn('event_id', $childIds)
|
|
->orderBy('date')
|
|
->orderBy('start_time')
|
|
->get();
|
|
|
|
return $ownSlots->merge($childSlots)->sortBy(['date', 'start_time'])->values();
|
|
}
|
|
|
|
if ($this->isSubEvent() && $this->parent_event_id) {
|
|
$parentSlots = TimeSlot::where('event_id', $this->parent_event_id)
|
|
->orderBy('date')
|
|
->orderBy('start_time')
|
|
->get();
|
|
|
|
return $ownSlots->merge($parentSlots)->sortBy(['date', 'start_time'])->values();
|
|
}
|
|
|
|
return $ownSlots;
|
|
}
|
|
}
|