From 303280286f3ba07e4ed859d36fa36aaf08847abd Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 16:35:01 +0200 Subject: [PATCH] feat: festival helper scopes and DevSeeder with full festival structure (TECH-02, TECH-03) 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) --- api/app/Models/Event.php | 18 +- api/database/seeders/DevSeeder.php | 178 +++++++++++++++-- api/tests/Feature/Event/EventScopesTest.php | 210 ++++++++++++++++++++ dev-docs/BACKLOG.md | 18 +- 4 files changed, 389 insertions(+), 35 deletions(-) create mode 100644 api/tests/Feature/Event/EventScopesTest.php diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 649e442d..445e24cc 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -221,17 +221,21 @@ final class Event extends Model return $query->whereIn('event_type', ['festival', 'series']); } - public function scopeWithChildren(Builder $query): Builder + public function scopeWithChildren(Builder $query, string $eventId): 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'); - }); + 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 diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 90dffc1d..05d09f30 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -6,7 +6,10 @@ namespace Database\Seeders; use App\Models\CrowdType; use App\Models\Event; +use App\Models\FestivalSection; use App\Models\Organisation; +use App\Models\Person; +use App\Models\TimeSlot; use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -114,14 +117,18 @@ class DevSeeder extends Seeder ], ); - // 6. Festival with sub-events + // ============================================= + // 6. Festival: Echt Feesten 2026 + // ============================================= + $festival = Event::firstOrCreate( - ['organisation_id' => $org->id, 'slug' => 'echt-zomer-feesten-2026'], + ['organisation_id' => $org->id, 'slug' => 'echt-feesten-2026'], [ - 'name' => 'Echt Zomer Feesten 2026', + 'name' => 'Echt Feesten 2026', 'start_date' => '2026-07-10', - 'end_date' => '2026-07-11', - 'status' => 'draft', + 'end_date' => '2026-07-12', + 'timezone' => 'Europe/Amsterdam', + 'status' => 'registration_open', 'event_type' => 'festival', 'event_type_label' => 'Festival', 'sub_event_label' => 'Programmaonderdeel', @@ -129,30 +136,173 @@ class DevSeeder extends Seeder ], ); - // Sub-event 1: Dance Festival - Event::firstOrCreate( - ['organisation_id' => $org->id, 'slug' => 'dance-festival-2026'], + // --- Sub-events --- + + $vrijdag = Event::firstOrCreate( + ['organisation_id' => $org->id, 'slug' => 'echt-feesten-2026-vrijdag'], [ - 'name' => 'Dance Festival', + 'name' => 'Vrijdag', 'start_date' => '2026-07-10', 'end_date' => '2026-07-10', - 'status' => 'draft', + 'timezone' => 'Europe/Amsterdam', + 'status' => 'registration_open', 'event_type' => 'event', 'parent_event_id' => $festival->id, ], ); - // Sub-event 2: Zomerfestival Event::firstOrCreate( - ['organisation_id' => $org->id, 'slug' => 'zomerfestival-2026'], + ['organisation_id' => $org->id, 'slug' => 'echt-feesten-2026-zaterdag'], [ - 'name' => 'Zomerfestival', + 'name' => 'Zaterdag', 'start_date' => '2026-07-11', 'end_date' => '2026-07-11', - 'status' => 'draft', + 'timezone' => 'Europe/Amsterdam', + 'status' => 'registration_open', 'event_type' => 'event', 'parent_event_id' => $festival->id, ], ); + + Event::firstOrCreate( + ['organisation_id' => $org->id, 'slug' => 'echt-feesten-2026-zondag'], + [ + 'name' => 'Zondag', + 'start_date' => '2026-07-12', + 'end_date' => '2026-07-12', + 'timezone' => 'Europe/Amsterdam', + 'status' => 'registration_open', + 'event_type' => 'event', + 'parent_event_id' => $festival->id, + ], + ); + + // --- Festival-level sections (on the parent) --- + + FestivalSection::firstOrCreate( + ['event_id' => $festival->id, 'name' => 'EHBO'], + [ + 'type' => 'cross_event', + 'category' => 'Veiligheid', + 'icon' => 'tabler-first-aid-kit', + 'sort_order' => 1, + 'responder_self_checkin' => true, + 'crew_auto_accepts' => false, + ], + ); + + FestivalSection::firstOrCreate( + ['event_id' => $festival->id, 'name' => 'Nachtsecurity'], + [ + 'type' => 'standard', + 'category' => 'Veiligheid', + 'icon' => 'tabler-shield', + 'sort_order' => 2, + 'responder_self_checkin' => true, + 'crew_auto_accepts' => false, + ], + ); + + // --- Sub-event sections (on Vrijdag) --- + + FestivalSection::firstOrCreate( + ['event_id' => $vrijdag->id, 'name' => 'Hoofdpodium Bar'], + [ + 'type' => 'standard', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + 'sort_order' => 1, + 'responder_self_checkin' => true, + 'crew_auto_accepts' => false, + ], + ); + + FestivalSection::firstOrCreate( + ['event_id' => $vrijdag->id, 'name' => 'Backstage'], + [ + 'type' => 'standard', + 'category' => 'Hospitality', + 'icon' => 'tabler-armchair', + 'sort_order' => 2, + 'responder_self_checkin' => true, + 'crew_auto_accepts' => false, + ], + ); + + // --- Festival-level time slots (on the parent) --- + + TimeSlot::firstOrCreate( + ['event_id' => $festival->id, 'name' => 'Opbouw'], + [ + 'date' => '2026-07-09', + 'start_time' => '08:00:00', + 'end_time' => '18:00:00', + 'duration_hours' => 10.00, + 'person_type' => 'CREW', + ], + ); + + TimeSlot::firstOrCreate( + ['event_id' => $festival->id, 'name' => 'Afbraak'], + [ + 'date' => '2026-07-13', + 'start_time' => '08:00:00', + 'end_time' => '18:00:00', + 'duration_hours' => 10.00, + 'person_type' => 'CREW', + ], + ); + + // --- Sub-event time slots (on Vrijdag) --- + + TimeSlot::firstOrCreate( + ['event_id' => $vrijdag->id, 'name' => 'Vrijdag Avond'], + [ + 'date' => '2026-07-10', + 'start_time' => '18:00:00', + 'end_time' => '02:00:00', + 'duration_hours' => 8.00, + 'person_type' => 'VOLUNTEER', + ], + ); + + TimeSlot::firstOrCreate( + ['event_id' => $vrijdag->id, 'name' => 'Vrijdag Nacht'], + [ + 'date' => '2026-07-10', + 'start_time' => '22:00:00', + 'end_time' => '04:00:00', + 'duration_hours' => 6.00, + 'person_type' => 'CREW', + ], + ); + + // --- Festival-level persons (5 volunteers on the parent) --- + + $volunteerType = CrowdType::where('organisation_id', $org->id) + ->where('system_type', 'VOLUNTEER') + ->first(); + + $festivalPersons = [ + ['name' => 'Sanne de Vries', 'email' => 'sanne.devries@example.nl', 'phone' => '+31612345001'], + ['name' => 'Pieter Jansen', 'email' => 'pieter.jansen@example.nl', 'phone' => '+31612345002'], + ['name' => 'Marieke van den Berg', 'email' => 'marieke.vandenberg@example.nl', 'phone' => '+31612345003'], + ['name' => 'Thijs Bakker', 'email' => 'thijs.bakker@example.nl', 'phone' => '+31612345004'], + // Uses the seeded orgAdmin email to test identity matching later + ['name' => 'Org Admin Volunteer', 'email' => 'orgadmin@crewli.test', 'phone' => '+31612345005'], + ]; + + foreach ($festivalPersons as $personData) { + Person::firstOrCreate( + ['event_id' => $festival->id, 'email' => $personData['email']], + [ + 'crowd_type_id' => $volunteerType->id, + 'name' => $personData['name'], + 'phone' => $personData['phone'], + 'status' => 'approved', + 'is_blacklisted' => false, + ], + ); + } } } diff --git a/api/tests/Feature/Event/EventScopesTest.php b/api/tests/Feature/Event/EventScopesTest.php new file mode 100644 index 00000000..46948b90 --- /dev/null +++ b/api/tests/Feature/Event/EventScopesTest.php @@ -0,0 +1,210 @@ +org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + // Flat event (no parent, no children) + $this->flatEvent = Event::factory()->create([ + 'organisation_id' => $this->org->id, + 'event_type' => 'event', + 'parent_event_id' => null, + ]); + + // Festival with 3 sub-events + $this->festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->org->id, + ]); + + $this->subEvent1 = Event::factory()->subEvent($this->festival)->create([ + 'name' => 'Vrijdag', + ]); + $this->subEvent2 = Event::factory()->subEvent($this->festival)->create([ + 'name' => 'Zaterdag', + ]); + $this->subEvent3 = Event::factory()->subEvent($this->festival)->create([ + 'name' => 'Zondag', + ]); + + // Event in a different organisation (must never appear) + $this->otherOrgEvent = Event::factory()->create([ + 'organisation_id' => $this->otherOrg->id, + 'event_type' => 'event', + 'parent_event_id' => null, + ]); + } + + // --- scopeTopLevel --- + + public function test_scope_top_level_returns_only_events_without_parent(): void + { + $results = Event::withoutGlobalScopes()->topLevel()->pluck('id'); + + $this->assertTrue($results->contains($this->flatEvent->id)); + $this->assertTrue($results->contains($this->festival->id)); + $this->assertTrue($results->contains($this->otherOrgEvent->id)); + + $this->assertFalse($results->contains($this->subEvent1->id)); + $this->assertFalse($results->contains($this->subEvent2->id)); + $this->assertFalse($results->contains($this->subEvent3->id)); + } + + // --- scopeChildren --- + + public function test_scope_children_returns_only_events_with_parent(): void + { + $results = Event::withoutGlobalScopes()->children()->pluck('id'); + + $this->assertTrue($results->contains($this->subEvent1->id)); + $this->assertTrue($results->contains($this->subEvent2->id)); + $this->assertTrue($results->contains($this->subEvent3->id)); + + $this->assertFalse($results->contains($this->flatEvent->id)); + $this->assertFalse($results->contains($this->festival->id)); + $this->assertFalse($results->contains($this->otherOrgEvent->id)); + } + + // --- scopeWithChildren --- + + public function test_scope_with_children_returns_parent_and_its_children(): void + { + $results = Event::withoutGlobalScopes() + ->withChildren($this->festival->id) + ->pluck('id'); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($this->festival->id)); + $this->assertTrue($results->contains($this->subEvent1->id)); + $this->assertTrue($results->contains($this->subEvent2->id)); + $this->assertTrue($results->contains($this->subEvent3->id)); + + $this->assertFalse($results->contains($this->flatEvent->id)); + $this->assertFalse($results->contains($this->otherOrgEvent->id)); + } + + public function test_scope_with_children_for_flat_event_returns_only_self(): void + { + $results = Event::withoutGlobalScopes() + ->withChildren($this->flatEvent->id) + ->pluck('id'); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($this->flatEvent->id)); + } + + // --- scopeFestivals --- + + public function test_scope_festivals_returns_only_festival_and_series_types(): void + { + $series = Event::factory()->series()->create([ + 'organisation_id' => $this->org->id, + ]); + + $results = Event::withoutGlobalScopes()->festivals()->pluck('id'); + + $this->assertTrue($results->contains($this->festival->id)); + $this->assertTrue($results->contains($series->id)); + + $this->assertFalse($results->contains($this->flatEvent->id)); + $this->assertFalse($results->contains($this->subEvent1->id)); + $this->assertFalse($results->contains($this->otherOrgEvent->id)); + } + + // --- scopeForFestival --- + + public function test_scope_for_festival_given_child_returns_parent_and_all_siblings(): void + { + $results = Event::withoutGlobalScopes() + ->forFestival($this->subEvent1) + ->pluck('id'); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($this->festival->id)); + $this->assertTrue($results->contains($this->subEvent1->id)); + $this->assertTrue($results->contains($this->subEvent2->id)); + $this->assertTrue($results->contains($this->subEvent3->id)); + } + + public function test_scope_for_festival_given_parent_returns_self_and_all_children(): void + { + $results = Event::withoutGlobalScopes() + ->forFestival($this->festival) + ->pluck('id'); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($this->festival->id)); + $this->assertTrue($results->contains($this->subEvent1->id)); + $this->assertTrue($results->contains($this->subEvent2->id)); + $this->assertTrue($results->contains($this->subEvent3->id)); + } + + public function test_scope_for_festival_given_flat_event_returns_only_self(): void + { + $results = Event::withoutGlobalScopes() + ->forFestival($this->flatEvent) + ->pluck('id'); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($this->flatEvent->id)); + } + + // --- OrganisationScope respect --- + + public function test_scopes_respect_organisation_scope(): void + { + $scopedQuery = Event::withoutGlobalScopes() + ->withGlobalScope('organisation', new OrganisationScope($this->org->id)); + + // topLevel within org should not contain other org's event + $topLevel = (clone $scopedQuery)->topLevel()->pluck('id'); + $this->assertTrue($topLevel->contains($this->flatEvent->id)); + $this->assertTrue($topLevel->contains($this->festival->id)); + $this->assertFalse($topLevel->contains($this->otherOrgEvent->id)); + + // children within org + $children = (clone $scopedQuery)->children()->pluck('id'); + $this->assertTrue($children->contains($this->subEvent1->id)); + $this->assertFalse($children->contains($this->otherOrgEvent->id)); + + // withChildren within org + $withChildren = (clone $scopedQuery)->withChildren($this->festival->id)->pluck('id'); + $this->assertCount(4, $withChildren); + $this->assertFalse($withChildren->contains($this->otherOrgEvent->id)); + + // festivals within org + $festivals = (clone $scopedQuery)->festivals()->pluck('id'); + $this->assertTrue($festivals->contains($this->festival->id)); + $this->assertFalse($festivals->contains($this->otherOrgEvent->id)); + + // forFestival within org + $forFestival = (clone $scopedQuery)->forFestival($this->subEvent1)->pluck('id'); + $this->assertCount(4, $forFestival); + $this->assertFalse($forFestival->contains($this->otherOrgEvent->id)); + } +} diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 68252087..302fd226 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -381,23 +381,11 @@ mogelijk fragiel door gewijzigde factory-setup. --- -### TECH-02 — scopeForFestival helper op Event model - -**Aanleiding:** Queries die door parent/child heen moeten werken. -**Wat:** `Event::scopeWithChildren()` en `Event::scopeForFestival()` -helper scopes zodat queries automatisch parent + children bevatten. +### ~~TECH-02 — scopeForFestival helper op Event model~~ ✅ OPGELOST --- -### TECH-03 — DevSeeder uitbreiden met festival-structuur - -**Aanleiding:** Na festival/event refactor heeft de DevSeeder -realistische testdata nodig met parent/child events. -**Wat:** DevSeeder aanpassen met: - -- Test festival (parent) -- 2-3 sub-events (children) -- Personen op festival-niveau +### ~~TECH-03 — DevSeeder uitbreiden met festival-structuur~~ ✅ OPGELOST --- @@ -409,6 +397,8 @@ realistische testdata nodig met parent/child events. De volgende items zijn geïmplementeerd en afgerond: +- ~~TECH-02: scopeForFestival + scopeWithChildren helper scopes op Event model~~ ✅ +- ~~TECH-03: DevSeeder uitgebreid met festival-structuur (secties, tijdsloten, personen)~~ ✅ - ~~TECH-04: EventController.store() redundante ternary~~ ✅ - ~~Auth race condition (CTRL+R fix)~~ ✅ - ~~Section edit dialog bug~~ ✅