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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:35:01 +02:00
parent d704242279
commit 303280286f
4 changed files with 389 additions and 35 deletions

View File

@@ -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

View File

@@ -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,
],
);
}
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Event;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventScopesTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Organisation $otherOrg;
private Event $flatEvent;
private Event $festival;
private Event $subEvent1;
private Event $subEvent2;
private Event $subEvent3;
private Event $otherOrgEvent;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}

View File

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