diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php new file mode 100644 index 00000000..8b95a273 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -0,0 +1,77 @@ +where('status', 'registration_open') + ->first(); + + if ($event === null) { + abort(404, 'Event not found or not accepting registrations.'); + } + + $festivalEvent = $event->isSubEvent() ? $event->parent : $event; + + $sectionQuery = FestivalSection::where('event_id', $festivalEvent->id) + ->where(function ($query) { + $query->where('type', '!=', 'cross_event') + ->orWhereNull('type'); + }) + ->ordered(); + + if ($festivalEvent->isFestival()) { + $childIds = $festivalEvent->children()->pluck('id'); + $sectionQuery->orWhere(function ($query) use ($childIds) { + $query->whereIn('event_id', $childIds) + ->where(function ($q) { + $q->where('type', '!=', 'cross_event') + ->orWhereNull('type'); + }); + }); + } + + $sections = $sectionQuery->get(['id', 'name', 'category', 'icon']); + + $timeSlots = $festivalEvent->getAllRelevantTimeSlots() + ->where('person_type', 'VOLUNTEER') + ->values(); + + return response()->json([ + 'data' => [ + 'event' => [ + 'id' => $festivalEvent->id, + 'name' => $festivalEvent->name, + 'start_date' => $festivalEvent->start_date->toDateString(), + 'end_date' => $festivalEvent->end_date->toDateString(), + 'organisation_id' => $festivalEvent->organisation_id, + ], + 'sections' => $sections->map(fn (FestivalSection $section) => [ + 'id' => $section->id, + 'name' => $section->name, + 'category' => $section->category, + 'icon' => $section->icon, + ]), + 'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [ + 'id' => $slot->id, + 'name' => $slot->name, + 'date' => $slot->date->toDateString(), + 'start_time' => $slot->start_time, + 'end_time' => $slot->end_time, + 'duration_hours' => $slot->duration_hours, + ]), + ], + ]); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index c223bd1d..49bbbbc4 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -21,6 +21,10 @@ use App\Http\Controllers\Api\V1\ShiftAssignmentController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TimeSlotController; use App\Http\Controllers\Api\V1\VolunteerAvailabilityController; +use App\Http\Controllers\Api\V1\VolunteerRegistrationController; +use App\Http\Controllers\Api\V1\PublicRegistrationDataController; +use App\Http\Controllers\Api\V1\PortalTokenController; +use App\Http\Controllers\Api\V1\PortalMeController; use App\Http\Controllers\Api\V1\UserOrganisationTagController; use App\Models\FestivalSection; use App\Models\Organisation; @@ -50,12 +54,20 @@ Route::post('auth/login', LoginController::class); Route::get('invitations/{token}', [InvitationController::class, 'show']); Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']); +// Public portal routes +Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class); +Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class); +Route::post('portal/token-auth', [PortalTokenController::class, 'auth']); + // Protected routes Route::middleware('auth:sanctum')->group(function () { // Auth Route::get('auth/me', MeController::class); Route::post('auth/logout', LogoutController::class); + // Portal (authenticated) + Route::get('portal/me', [PortalMeController::class, 'index']); + // Organisations Route::apiResource('organisations', OrganisationController::class) ->only(['index', 'show', 'store', 'update']); diff --git a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php new file mode 100644 index 00000000..02fff666 --- /dev/null +++ b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php @@ -0,0 +1,85 @@ +organisation = Organisation::factory()->create(); + } + + public function test_returns_registration_data_for_open_event(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'test-event-2026', + ]); + + $section = FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'type' => 'standard', + ]); + + FestivalSection::factory()->create([ + 'event_id' => $event->id, + 'type' => 'cross_event', + ]); + + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $event->id, + 'person_type' => 'VOLUNTEER', + ]); + + TimeSlot::factory()->create([ + 'event_id' => $event->id, + 'person_type' => 'CREW', + ]); + + $response = $this->getJson('/api/v1/public/events/test-event-2026/registration-data'); + + $response->assertOk() + ->assertJsonPath('data.event.id', $event->id) + ->assertJsonPath('data.event.name', $event->name) + ->assertJsonCount(1, 'data.sections') + ->assertJsonPath('data.sections.0.id', $section->id) + ->assertJsonCount(1, 'data.time_slots') + ->assertJsonPath('data.time_slots.0.id', $timeSlot->id); + } + + public function test_returns_404_for_non_registration_open_event(): void + { + Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + 'slug' => 'draft-event', + ]); + + $response = $this->getJson('/api/v1/public/events/draft-event/registration-data'); + + $response->assertNotFound(); + } + + public function test_returns_404_for_nonexistent_slug(): void + { + $response = $this->getJson('/api/v1/public/events/does-not-exist/registration-data'); + + $response->assertNotFound(); + } +} diff --git a/apps/portal/package.json b/apps/portal/package.json index 3c16bc6e..bb0d73f8 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -27,6 +27,7 @@ "@tiptap/pm": "^2.27.1", "@tiptap/starter-kit": "^2.27.1", "@tiptap/vue-3": "^2.27.1", + "@vee-validate/zod": "^4.15.1", "@vueuse/core": "10.11.1", "@vueuse/math": "10.11.1", "apexcharts": "3.54.1", @@ -45,6 +46,7 @@ "swiper": "11.2.10", "ufo": "1.6.1", "unplugin-vue-define-options": "1.5.5", + "vee-validate": "^4.15.1", "vue": "3.5.22", "vue-chartjs": "5.3.2", "vue-flatpickr-component": "11.0.5", diff --git a/apps/portal/pnpm-lock.yaml b/apps/portal/pnpm-lock.yaml index a2859493..0cea5567 100644 --- a/apps/portal/pnpm-lock.yaml +++ b/apps/portal/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@tiptap/vue-3': specifier: ^2.27.1 version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(vue@3.5.22(typescript@5.9.3)) + '@vee-validate/zod': + specifier: ^4.15.1 + version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76) '@vueuse/core': specifier: 10.11.1 version: 10.11.1(vue@3.5.22(typescript@5.9.3)) @@ -105,6 +108,9 @@ importers: unplugin-vue-define-options: specifier: 1.5.5 version: 1.5.5(vue@3.5.22(typescript@5.9.3)) + vee-validate: + specifier: ^4.15.1 + version: 4.15.1(vue@3.5.22(typescript@5.9.3)) vue: specifier: 3.5.22 version: 3.5.22(typescript@5.9.3) @@ -1747,6 +1753,11 @@ packages: cpu: [x64] os: [win32] + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + '@vitejs/plugin-vue-jsx@5.1.1': resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4543,6 +4554,11 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -6349,6 +6365,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vee-validate/zod@4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.22(typescript@5.9.3)) + zod: 3.25.76 + transitivePeerDependencies: + - vue + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 @@ -9714,6 +9738,12 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vee-validate@4.15.1(vue@3.5.22(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.7 + type-fest: 4.41.0 + vue: 3.5.22(typescript@5.9.3) + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/apps/portal/src/composables/api/useVolunteerRegistration.ts b/apps/portal/src/composables/api/useVolunteerRegistration.ts new file mode 100644 index 00000000..6f457b9c --- /dev/null +++ b/apps/portal/src/composables/api/useVolunteerRegistration.ts @@ -0,0 +1,36 @@ +import { useQuery, useMutation } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration' + +interface ApiResponse { + data: T +} + +export function useRegistrationData(eventSlug: Ref) { + return useQuery({ + queryKey: ['registration-data', eventSlug], + queryFn: async () => { + const { data } = await apiClient.get>( + `/public/events/${eventSlug.value}/registration-data`, + ) + + return data.data + }, + enabled: () => !!eventSlug.value, + retry: false, + }) +} + +export function useSubmitRegistration() { + return useMutation({ + mutationFn: async ({ eventId, form }: { eventId: string; form: VolunteerRegistrationForm }) => { + const { data } = await apiClient.post>>( + `/events/${eventId}/volunteer-register`, + form, + ) + + return data.data + }, + }) +} diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index 2c4d1c64..2bb16832 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -1,4 +1,17 @@ + + diff --git a/apps/portal/src/pages/register/success.vue b/apps/portal/src/pages/register/success.vue new file mode 100644 index 00000000..d27480f6 --- /dev/null +++ b/apps/portal/src/pages/register/success.vue @@ -0,0 +1,68 @@ + + + diff --git a/apps/portal/src/schemas/registrationSchema.ts b/apps/portal/src/schemas/registrationSchema.ts new file mode 100644 index 00000000..dee13a3f --- /dev/null +++ b/apps/portal/src/schemas/registrationSchema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const step1Schema = z.object({ + name: z.string().min(1, 'Naam is verplicht').max(255), + email: z.string().min(1, 'E-mailadres is verplicht').email('Ongeldig e-mailadres').max(255), + phone: z.string().max(50).optional().or(z.literal('')), +}) + +export const step2Schema = z.object({ + tshirt_size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']).optional().or(z.literal('')), + first_aid: z.boolean().default(false), + allergies: z.string().max(500).optional().or(z.literal('')), + access_requirements: z.string().max(500).optional().or(z.literal('')), + driving_licence: z.boolean().default(false), +}) + +export const step3Schema = z.object({ + motivation: z.string().max(1000).optional().or(z.literal('')), + motivation_other: z.string().max(500).optional().or(z.literal('')), +}) + +export const fullRegistrationSchema = step1Schema + .merge(step2Schema) + .merge(step3Schema) diff --git a/apps/portal/src/types/registration.ts b/apps/portal/src/types/registration.ts new file mode 100644 index 00000000..30821ee0 --- /dev/null +++ b/apps/portal/src/types/registration.ts @@ -0,0 +1,57 @@ +export interface EventRegistrationData { + event: { + id: string + name: string + start_date: string + end_date: string + organisation_id: string + } + sections: SectionOption[] + time_slots: TimeSlotOption[] +} + +export interface SectionOption { + id: string + name: string + category: string | null + icon: string | null +} + +export interface TimeSlotOption { + id: string + name: string + date: string + start_time: string + end_time: string + duration_hours: number +} + +export interface SectionPreference { + section_id: string + priority: number +} + +export interface VolunteerAvailability { + time_slot_id: string + preference_level: number +} + +export interface VolunteerRegistrationForm { + // Step 1 + name: string + email: string + phone: string + // Step 2 + tshirt_size: string + first_aid: boolean + allergies: string + access_requirements: string + driving_licence: boolean + // Step 3 + motivation: string + motivation_other: string + // Step 4 + section_preferences: SectionPreference[] + // Step 5 + availabilities: VolunteerAvailability[] +} diff --git a/apps/portal/typed-router.d.ts b/apps/portal/typed-router.d.ts index a927476f..d00108ea 100644 --- a/apps/portal/typed-router.d.ts +++ b/apps/portal/typed-router.d.ts @@ -25,6 +25,7 @@ declare module 'vue-router/auto-routes' { 'login': RouteRecordInfo<'login', '/login', Record, Record>, 'portal-profile': RouteRecordInfo<'portal-profile', '/profile', Record, Record>, 'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue }, { eventSlug: ParamValue }>, + 'register-success': RouteRecordInfo<'register-success', '/register/success', Record, Record>, 'portal-shifts': RouteRecordInfo<'portal-shifts', '/shifts', Record, Record>, } } diff --git a/dev-docs/API.md b/dev-docs/API.md index 497ac10a..8cbd0357 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -389,4 +389,33 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al - `GET /events/{event}/persons?tag={person_tag_id}` — filter persons by single tag - `GET /events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all) +## Public Registration Data + +- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Excludes `cross_event` sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival. + +### Response + +```json +{ + "data": { + "event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." }, + "sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass" }], + "time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }] + } +} +``` + +### Error Responses + +- `404` — Event not found or not accepting registrations + +## Volunteer Registration + +- `POST /events/{event}/volunteer-register` — public, auth-aware (optional Sanctum). Registers a volunteer for an event. Resolves sub-events to the parent festival. Accepts name, email, phone, tshirt_size, motivation, section_preferences, availabilities. Authenticated users have their name/email taken from the auth token. Returns `PersonResource` (201 on new, 200 on re-registration of rejected person). + +## Portal + +- `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid. +- `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found. + _(Extend this contract per module as endpoints are implemented.)_