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~~ ✅