> 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 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} */ 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): Builder { return $query->where(function (Builder $q) { $q->whereIn('id', function ($sub) { $sub->select('id')->from('events')->whereNull('parent_event_id'); })->orWhereIn('parent_event_id', function ($sub) { $sub->select('id')->from('events')->whereNull('parent_event_id'); }); }); } // ----- 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; } }