diff --git a/api/app/Http/Controllers/Api/V1/EventController.php b/api/app/Http/Controllers/Api/V1/EventController.php index b68f1fc..e047a9d 100644 --- a/api/app/Http/Controllers/Api/V1/EventController.php +++ b/api/app/Http/Controllers/Api/V1/EventController.php @@ -11,34 +11,62 @@ use App\Http\Resources\Api\V1\EventResource; use App\Models\Event; use App\Models\Organisation; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; final class EventController extends Controller { - public function index(Organisation $organisation): AnonymousResourceCollection + public function index(Request $request, Organisation $organisation): AnonymousResourceCollection { Gate::authorize('viewAny', [Event::class, $organisation]); - $events = $organisation->events() - ->latest('start_date') - ->paginate(); + $query = $organisation->events() + ->topLevel() + ->latest('start_date'); - return EventResource::collection($events); + if ($request->query('type')) { + $query->where('event_type', $request->query('type')); + } + + if ($request->boolean('include_children')) { + $query->with('children'); + } + + return EventResource::collection($query->paginate()); } public function show(Organisation $organisation, Event $event): JsonResponse { Gate::authorize('view', [$event, $organisation]); - return $this->success(new EventResource($event->load('organisation'))); + $event->load(['organisation', 'children', 'parent']) + ->loadCount('children'); + + return $this->success(new EventResource($event)); } public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse { Gate::authorize('create', [Event::class, $organisation]); - $event = $organisation->events()->create($request->validated()); + $data = $request->validated(); + + if (!empty($data['parent_event_id'])) { + $parentEvent = Event::where('id', $data['parent_event_id']) + ->where('organisation_id', $organisation->id) + ->first(); + + if (!$parentEvent) { + return $this->error('Parent event does not belong to this organisation.', 422); + } + } + + if (!isset($data['event_type'])) { + $data['event_type'] = empty($data['parent_event_id']) ? 'event' : 'event'; + } + + $event = $organisation->events()->create($data); return $this->created(new EventResource($event)); } @@ -51,4 +79,16 @@ final class EventController extends Controller return $this->success(new EventResource($event->fresh())); } + + public function children(Organisation $organisation, Event $event): AnonymousResourceCollection + { + Gate::authorize('view', [$event, $organisation]); + + $children = $event->children() + ->orderBy('start_date') + ->orderBy('name') + ->paginate(); + + return EventResource::collection($children); + } } diff --git a/api/app/Http/Requests/Api/V1/StoreEventRequest.php b/api/app/Http/Requests/Api/V1/StoreEventRequest.php index 52f38d5..770c40d 100644 --- a/api/app/Http/Requests/Api/V1/StoreEventRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreEventRequest.php @@ -23,6 +23,10 @@ final class StoreEventRequest extends FormRequest 'end_date' => ['required', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'max:50'], 'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'], + 'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'], + 'event_type' => ['nullable', 'in:event,festival,series'], + 'event_type_label' => ['nullable', 'string', 'max:50'], + 'sub_event_label' => ['nullable', 'string', 'max:50'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php index 13f74c3..d37fa5b 100644 --- a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php @@ -23,6 +23,10 @@ final class UpdateEventRequest extends FormRequest 'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'max:50'], 'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'], + 'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'], + 'event_type' => ['sometimes', 'in:event,festival,series'], + 'event_type_label' => ['nullable', 'string', 'max:50'], + 'sub_event_label' => ['nullable', 'string', 'max:50'], ]; } } diff --git a/api/app/Http/Resources/Api/V1/EventResource.php b/api/app/Http/Resources/Api/V1/EventResource.php index e32d1d3..3a506cc 100644 --- a/api/app/Http/Resources/Api/V1/EventResource.php +++ b/api/app/Http/Resources/Api/V1/EventResource.php @@ -14,15 +14,27 @@ final class EventResource extends JsonResource return [ 'id' => $this->id, 'organisation_id' => $this->organisation_id, + 'parent_event_id' => $this->parent_event_id, 'name' => $this->name, 'slug' => $this->slug, 'start_date' => $this->start_date->toDateString(), 'end_date' => $this->end_date->toDateString(), 'timezone' => $this->timezone, 'status' => $this->status, + 'event_type' => $this->event_type, + 'event_type_label' => $this->event_type_label, + 'sub_event_label' => $this->sub_event_label, + 'is_recurring' => $this->is_recurring, + 'is_festival' => $this->resource->isFestival(), + 'is_sub_event' => $this->resource->isSubEvent(), + 'is_flat_event' => $this->resource->isFlatEvent(), + 'has_children' => $this->resource->hasChildren(), 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), + 'children_count' => $this->whenCounted('children'), 'organisation' => new OrganisationResource($this->whenLoaded('organisation')), + 'parent' => new EventResource($this->whenLoaded('parent')), + 'children' => EventResource::collection($this->whenLoaded('children')), ]; } } diff --git a/api/database/factories/EventFactory.php b/api/database/factories/EventFactory.php index d8bd7fc..9fa555d 100644 --- a/api/database/factories/EventFactory.php +++ b/api/database/factories/EventFactory.php @@ -27,6 +27,7 @@ final class EventFactory extends Factory 'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')), 'timezone' => 'Europe/Amsterdam', 'status' => 'draft', + 'event_type' => 'event', ]; } @@ -34,4 +35,31 @@ final class EventFactory extends Factory { return $this->state(fn () => ['status' => 'published']); } + + public function festival(): static + { + return $this->state(fn () => [ + 'event_type' => 'festival', + 'event_type_label' => 'Festival', + 'sub_event_label' => 'Dag', + ]); + } + + public function series(): static + { + return $this->state(fn () => [ + 'event_type' => 'series', + 'event_type_label' => 'Serie', + 'sub_event_label' => 'Editie', + ]); + } + + public function subEvent(Event $parent): static + { + return $this->state(fn () => [ + 'parent_event_id' => $parent->id, + 'organisation_id' => $parent->organisation_id, + 'event_type' => 'event', + ]); + } } diff --git a/api/routes/api.php b/api/routes/api.php index 88d4235..869c8fe 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -55,6 +55,7 @@ Route::middleware('auth:sanctum')->group(function () { // Events (nested under organisations) Route::apiResource('organisations.events', EventController::class) ->only(['index', 'show', 'store', 'update']); + Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']); // Organisation-scoped resources Route::prefix('organisations/{organisation}')->group(function () { diff --git a/api/tests/Feature/Event/FestivalEventTest.php b/api/tests/Feature/Event/FestivalEventTest.php new file mode 100644 index 0000000..d7fa5e5 --- /dev/null +++ b/api/tests/Feature/Event/FestivalEventTest.php @@ -0,0 +1,234 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + // --- INDEX: top-level only --- + + public function test_index_shows_only_top_level_events(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + Event::factory()->subEvent($festival)->create(); + Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events"); + + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + + $ids = collect($response->json('data'))->pluck('parent_event_id'); + $this->assertTrue($ids->every(fn ($v) => $v === null)); + } + + // --- INDEX: include_children --- + + public function test_index_with_include_children_shows_nested_children(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + Event::factory()->subEvent($festival)->count(2)->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?include_children=true"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertCount(2, $response->json('data.0.children')); + } + + // --- INDEX: type filter --- + + public function test_index_type_filter_works(): void + { + Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?type=festival"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals('festival', $response->json('data.0.event_type')); + } + + // --- STORE: sub-event --- + + public function test_store_with_parent_event_id_creates_sub_event(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Dag 1', + 'slug' => 'dag-1', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-01', + 'parent_event_id' => $festival->id, + ]); + + $response->assertCreated(); + $this->assertEquals($festival->id, $response->json('data.parent_event_id')); + $this->assertTrue($response->json('data.is_sub_event')); + } + + // --- STORE: sub-event cross-org → 422 --- + + public function test_store_sub_event_of_other_org_returns_422(): void + { + $otherOrg = Organisation::factory()->create(); + $otherEvent = Event::factory()->festival()->create([ + 'organisation_id' => $otherOrg->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Cross-org sub', + 'slug' => 'cross-org-sub', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-01', + 'parent_event_id' => $otherEvent->id, + ]); + + $response->assertStatus(422); + } + + // --- CHILDREN endpoint --- + + public function test_children_endpoint_returns_sub_events(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + Event::factory()->subEvent($festival)->count(3)->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/children"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_children_of_flat_event_returns_empty_list(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/children"); + + $response->assertOk(); + $this->assertCount(0, $response->json('data')); + } + + // --- SHOW: festival with children --- + + public function test_show_festival_contains_children(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + Event::factory()->subEvent($festival)->count(2)->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}"); + + $response->assertOk(); + $this->assertCount(2, $response->json('data.children')); + $this->assertEquals(2, $response->json('data.children_count')); + } + + // --- SHOW: sub-event with parent --- + + public function test_show_sub_event_contains_parent(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $subEvent = Event::factory()->subEvent($festival)->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}"); + + $response->assertOk(); + $this->assertEquals($festival->id, $response->json('data.parent.id')); + } + + // --- Helper methods --- + + public function test_is_festival_helper(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + $this->assertTrue($festival->isFestival()); + $this->assertFalse($festival->isSubEvent()); + } + + public function test_is_sub_event_helper(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $subEvent = Event::factory()->subEvent($festival)->create(); + + $this->assertTrue($subEvent->isSubEvent()); + $this->assertFalse($subEvent->isFestival()); + } + + public function test_is_flat_event_helper(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + $this->assertTrue($event->isFlatEvent()); + $this->assertFalse($event->isFestival()); + $this->assertFalse($event->isSubEvent()); + } +} diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 712d72d..42ec467 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -34,6 +34,7 @@ declare module 'vue' { CreatePersonDialog: typeof import('./src/components/persons/CreatePersonDialog.vue')['default'] CreateSectionDialog: typeof import('./src/components/sections/CreateSectionDialog.vue')['default'] CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default'] + CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default'] CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default'] CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default'] CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default'] diff --git a/apps/app/src/components/events/CreateEventDialog.vue b/apps/app/src/components/events/CreateEventDialog.vue index 7dd4c40..a68a444 100644 --- a/apps/app/src/components/events/CreateEventDialog.vue +++ b/apps/app/src/components/events/CreateEventDialog.vue @@ -2,6 +2,7 @@ import { VForm } from 'vuetify/components/VForm' import { useCreateEvent } from '@/composables/api/useEvents' import { requiredValidator } from '@core/utils/validators' +import type { EventTypeEnum } from '@/types/event' const props = defineProps<{ orgId: string @@ -17,6 +18,9 @@ const form = ref({ start_date: '', end_date: '', timezone: 'Europe/Amsterdam', + event_type: 'event' as EventTypeEnum, + event_type_label: '', + sub_event_label: '', }) const errors = ref>({}) @@ -25,6 +29,24 @@ const showSuccess = ref(false) const { mutate: createEvent, isPending } = useCreateEvent(orgIdRef) +const isFestivalOrSeries = computed(() => + form.value.event_type === 'festival' || form.value.event_type === 'series', +) + +const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [ + { title: 'Evenement', value: 'event' }, + { title: 'Festival', value: 'festival' }, + { title: 'Serie', value: 'series' }, +] + +const subEventLabelOptions = [ + 'Dag', + 'Programmaonderdeel', + 'Editie', + 'Locatie', + 'Ronde', +] + const timezoneOptions = [ { title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' }, { title: 'Europe/London', value: 'Europe/London' }, @@ -50,7 +72,16 @@ const endDateRule = (v: string) => { } function resetForm() { - form.value = { name: '', slug: '', start_date: '', end_date: '', timezone: 'Europe/Amsterdam' } + form.value = { + name: '', + slug: '', + start_date: '', + end_date: '', + timezone: 'Europe/Amsterdam', + event_type: 'event', + event_type_label: '', + sub_event_label: '', + } errors.value = {} refVForm.value?.resetValidation() } @@ -61,7 +92,20 @@ function onSubmit() { errors.value = {} - createEvent(form.value, { + const payload = { + name: form.value.name, + slug: form.value.slug, + start_date: form.value.start_date, + end_date: form.value.end_date, + timezone: form.value.timezone, + event_type: form.value.event_type, + event_type_label: form.value.event_type_label || null, + sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label + ? form.value.sub_event_label + : null, + } + + createEvent(payload, { onSuccess: () => { modelValue.value = false showSuccess.value = true @@ -105,6 +149,7 @@ function onSubmit() { autofocus /> + + + + + + {{ opt.title }} + + + + + + + + + + + + +import { VForm } from 'vuetify/components/VForm' +import { useCreateSubEvent } from '@/composables/api/useEvents' +import { requiredValidator } from '@core/utils/validators' +import type { EventItem, EventStatus } from '@/types/event' + +const props = defineProps<{ + parentEvent: EventItem + orgId: string +}>() + +const modelValue = defineModel({ required: true }) + +const orgIdRef = computed(() => props.orgId) + +const subEventLabel = computed(() => + props.parentEvent.sub_event_label ?? 'Programmaonderdeel', +) + +const form = ref({ + name: '', + date: '', + start_time: '', + end_time: '', + status: 'draft' as EventStatus, +}) + +const errors = ref>({}) +const refVForm = ref() +const showSuccess = ref(false) + +const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef) + +const statusOptions: { title: string; value: EventStatus }[] = [ + { title: 'Draft', value: 'draft' }, + { title: 'Published', value: 'published' }, + { title: 'Registration Open', value: 'registration_open' }, + { title: 'Build-up', value: 'buildup' }, + { title: 'Show Day', value: 'showday' }, + { title: 'Tear-down', value: 'teardown' }, + { title: 'Closed', value: 'closed' }, +] + +function resetForm() { + form.value = { name: '', date: '', start_time: '', end_time: '', status: 'draft' } + errors.value = {} + refVForm.value?.resetValidation() +} + +function onSubmit() { + refVForm.value?.validate().then(({ valid }) => { + if (!valid) return + + errors.value = {} + + const slug = form.value.name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + + createSubEvent({ + name: form.value.name, + slug, + start_date: form.value.date, + end_date: form.value.date, + timezone: props.parentEvent.timezone, + event_type: 'event', + parent_event_id: props.parentEvent.id, + }, { + onSuccess: () => { + modelValue.value = false + showSuccess.value = true + resetForm() + }, + onError: (err: any) => { + const data = err.response?.data + if (data?.errors) { + errors.value = Object.fromEntries( + Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]), + ) + } + else if (data?.message) { + errors.value = { name: data.message } + } + }, + }) + }) +} + + + diff --git a/apps/app/src/components/events/EditEventDialog.vue b/apps/app/src/components/events/EditEventDialog.vue index 65b9ac6..0936461 100644 --- a/apps/app/src/components/events/EditEventDialog.vue +++ b/apps/app/src/components/events/EditEventDialog.vue @@ -2,10 +2,10 @@ import { VForm } from 'vuetify/components/VForm' import { useUpdateEvent } from '@/composables/api/useEvents' import { requiredValidator } from '@core/utils/validators' -import type { EventStatus, EventType } from '@/types/event' +import type { EventStatus, EventItem } from '@/types/event' const props = defineProps<{ - event: EventType + event: EventItem orgId: string }>() diff --git a/apps/app/src/components/events/EventTabsNav.vue b/apps/app/src/components/events/EventTabsNav.vue index fcf7d53..0589877 100644 --- a/apps/app/src/components/events/EventTabsNav.vue +++ b/apps/app/src/components/events/EventTabsNav.vue @@ -5,6 +5,12 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore' import EditEventDialog from '@/components/events/EditEventDialog.vue' import type { EventStatus } from '@/types/event' +const props = withDefaults(defineProps<{ + hideTabs?: boolean +}>(), { + hideTabs: false, +}) + const route = useRoute() const authStore = useAuthStore() const orgStore = useOrganisationStore() @@ -31,6 +37,11 @@ const statusColor: Record = { closed: 'error', } +const eventTypeColor: Record = { + festival: 'purple', + series: 'info', +} + const dateFormatter = new Intl.DateTimeFormat('nl-NL', { day: '2-digit', month: '2-digit', @@ -54,6 +65,13 @@ const activeTab = computed(() => { const name = route.name as string return tabs.find(t => name === t.route || name?.startsWith(`${t.route}-`))?.route ?? 'events-id' }) + +const backRoute = computed(() => { + if (event.value?.is_sub_event && event.value.parent_event_id) { + return { name: 'events-id' as const, params: { id: event.value.parent_event_id } } + } + return { name: 'events' as const } +})