Files
crewli/api/app/Models/Event.php
bert.hausmans a92ddc48ec refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1
Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:38:08 +02:00

340 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Scopes\OrganisationScope;
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\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
final class Event extends Model
{
use HasFactory;
use HasUlids;
use SoftDeletes;
/** @var string Used by OrganisationScope to determine filtering strategy */
public string $organisationScopeColumn = 'organisation_id';
protected static function booted(): void
{
static::addGlobalScope(new OrganisationScope());
}
/** @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',
'registration_banner_url',
'registration_welcome_text',
'registration_logo_url',
'registration_show_section_preferences',
'registration_show_availability',
];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
'is_recurring' => 'boolean',
'recurrence_exceptions' => 'array',
'event_type' => 'string',
'registration_show_section_preferences' => 'boolean',
'registration_show_availability' => 'boolean',
];
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'event_user_roles')
->using(\App\Models\Pivots\EventUserRole::class)
->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 formSchemas(): MorphMany
{
return $this->morphMany(\App\Models\FormBuilder\FormSchema::class, 'owner');
}
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;
}
}