From 73c8e6c466f23f9adffd842acf45444383f6dcca Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 12 Apr 2026 23:44:26 +0200 Subject: [PATCH] feat(api): extend registration endpoints with dynamic fields and section preferences - PublicRegistrationData now returns registration_fields (portal-visible only), form toggles, and available_tags for tag_picker fields - Volunteer registration accepts field_values and section_preferences with festival_section_id, processed via existing services - PortalMe eager-loads fieldValues and sectionPreferences - Section preferences now use the proper relational table instead of custom_fields JSON Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/Api/V1/PortalMeController.php | 42 +++++ .../V1/PublicRegistrationDataController.php | 45 ++++- .../Api/V1/VolunteerRegistrationRequest.php | 4 +- .../Services/VolunteerRegistrationService.php | 21 ++- .../Api/V1/PublicRegistrationDataTest.php | 130 ++++++++++++++ .../Api/V1/VolunteerRegistrationTest.php | 162 +++++++++++++++++- 6 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 api/app/Http/Controllers/Api/V1/PortalMeController.php diff --git a/api/app/Http/Controllers/Api/V1/PortalMeController.php b/api/app/Http/Controllers/Api/V1/PortalMeController.php new file mode 100644 index 00000000..305e871a --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PortalMeController.php @@ -0,0 +1,42 @@ +validated('event_id')); + + if ($event->isSubEvent()) { + $event = $event->parent; + } + + $person = Person::where('user_id', $request->user()->id) + ->where('event_id', $event->id) + ->with([ + 'crowdType', + 'shiftAssignments.shift.festivalSection', + 'shiftAssignments.shift.timeSlot', + 'volunteerAvailabilities.timeSlot', + 'fieldValues.registrationFormField', + 'sectionPreferences.festivalSection', + ]) + ->first(); + + if (! $person) { + return $this->notFound('No registration found for this event'); + } + + return $this->success(new PersonResource($person)); + } +} diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php index 2b4c6483..c8900ff2 100644 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -7,6 +7,8 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\Event; use App\Models\FestivalSection; +use App\Models\PersonTag; +use App\Models\RegistrationFormField; use App\Models\TimeSlot; use Illuminate\Http\JsonResponse; @@ -52,6 +54,13 @@ final class PublicRegistrationDataController extends Controller ->where('person_type', 'VOLUNTEER') ->values(); + $registrationFields = RegistrationFormField::where('event_id', $festivalEvent->id) + ->portalVisible() + ->ordered() + ->get(); + + $organisationId = $festivalEvent->organisation_id; + return response()->json([ 'data' => [ 'event' => [ @@ -59,10 +68,12 @@ final class PublicRegistrationDataController extends Controller 'name' => $festivalEvent->name, 'start_date' => $festivalEvent->start_date->toDateString(), 'end_date' => $festivalEvent->end_date->toDateString(), - 'organisation_id' => $festivalEvent->organisation_id, + 'organisation_id' => $organisationId, 'registration_banner_url' => $festivalEvent->registration_banner_url, 'registration_welcome_text' => $festivalEvent->registration_welcome_text, 'registration_logo_url' => $festivalEvent->registration_logo_url, + 'registration_show_section_preferences' => (bool) $festivalEvent->registration_show_section_preferences, + 'registration_show_availability' => (bool) $festivalEvent->registration_show_availability, ], 'sections' => $sections->map(fn (FestivalSection $section) => [ 'id' => $section->id, @@ -79,6 +90,38 @@ final class PublicRegistrationDataController extends Controller 'end_time' => $slot->end_time, 'duration_hours' => $slot->duration_hours, ]), + 'registration_fields' => $registrationFields->map(function (RegistrationFormField $field) use ($organisationId) { + $data = [ + 'id' => $field->id, + 'label' => $field->label, + 'slug' => $field->slug, + 'field_type' => $field->field_type->value, + 'options' => $field->options, + 'tag_category' => $field->tag_category, + 'is_required' => $field->is_required, + 'section' => $field->section, + 'help_text' => $field->help_text, + ]; + + if ($field->field_type === \App\Enums\RegistrationFieldType::TAG_PICKER) { + $query = PersonTag::where('organisation_id', $organisationId) + ->where('is_active', true); + + if ($field->tag_category) { + $query->where('category', $field->tag_category); + } + + $data['available_tags'] = $query->orderBy('sort_order') + ->get() + ->map(fn (PersonTag $tag) => [ + 'id' => $tag->id, + 'name' => $tag->name, + 'category' => $tag->category, + ]); + } + + return $data; + }), ], ]); } diff --git a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php index 73c20d2f..cb06deac 100644 --- a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php +++ b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php @@ -46,12 +46,14 @@ final class VolunteerRegistrationRequest extends FormRequest 'motivation_other' => ['nullable', 'string', 'max:500'], 'section_preferences' => ['nullable', 'array', 'max:5'], - 'section_preferences.*.section_name' => ['required', 'string', 'max:255'], + 'section_preferences.*.festival_section_id' => ['required', 'ulid'], 'section_preferences.*.priority' => ['required', 'integer', 'min:1', 'max:5'], 'availabilities' => ['nullable', 'array'], 'availabilities.*.time_slot_id' => ['required', 'ulid', 'exists:time_slots,id'], 'availabilities.*.preference_level' => ['nullable', 'integer', 'min:1', 'max:5'], + + 'field_values' => ['nullable', 'array'], ]; } } diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php index 5262426d..46b4671c 100644 --- a/api/app/Services/VolunteerRegistrationService.php +++ b/api/app/Services/VolunteerRegistrationService.php @@ -19,6 +19,8 @@ final class VolunteerRegistrationService { public function __construct( private readonly PersonIdentityService $identityService, + private readonly RegistrationFormFieldService $registrationFormFieldService, + private readonly PersonSectionPreferenceService $personSectionPreferenceService, ) {} /** @@ -62,17 +64,26 @@ final class VolunteerRegistrationService 'driving_licence' => $validated['driving_licence'] ?? false, 'motivation' => $validated['motivation'] ?? null, 'motivation_other' => $validated['motivation_other'] ?? null, - 'section_preferences' => collect($validated['section_preferences'] ?? []) - ->map(fn ($pref) => [ - 'section_name' => $pref['section_name'], - 'priority' => $pref['priority'], - ])->toArray(), ], ] ); $this->syncAvailabilities($person, $festivalEvent, $validated['availabilities'] ?? []); + if (!empty($validated['field_values'])) { + $this->registrationFormFieldService->upsertPersonValues( + $person, + $validated['field_values'] + ); + } + + if (!empty($validated['section_preferences'])) { + $this->personSectionPreferenceService->replacePreferences( + $person, + $validated['section_preferences'] + ); + } + if ($user === null) { $this->detectIdentityMatch($person); } diff --git a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php index 0fcc5981..a9466b8c 100644 --- a/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php +++ b/api/tests/Feature/Api/V1/PublicRegistrationDataTest.php @@ -8,6 +8,8 @@ use App\Models\CrowdType; use App\Models\Event; use App\Models\FestivalSection; use App\Models\Organisation; +use App\Models\PersonTag; +use App\Models\RegistrationFormField; use App\Models\TimeSlot; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -199,4 +201,132 @@ class PublicRegistrationDataTest extends TestCase ->assertJsonCount(1, 'data.sections') ->assertJsonPath('data.sections.0.name', 'Bar'); } + + // ─── Registration Fields ─────────────────────────────────────────── + + public function test_registration_data_includes_registration_fields(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'fields-event', + ]); + + $field = RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $event->id, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'sort_order' => 0, + ]); + + RegistrationFormField::factory()->textareaField()->create([ + 'event_id' => $event->id, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'sort_order' => 1, + ]); + + $response = $this->getJson('/api/v1/public/events/fields-event/registration-data'); + + $response->assertOk() + ->assertJsonCount(2, 'data.registration_fields') + ->assertJsonPath('data.registration_fields.0.slug', $field->slug) + ->assertJsonPath('data.registration_fields.0.field_type', 'select') + ->assertJsonPath('data.registration_fields.0.is_required', false); + } + + public function test_registration_data_excludes_admin_only_fields(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'admin-fields-event', + ]); + + RegistrationFormField::factory()->create([ + 'event_id' => $event->id, + 'label' => 'Public field', + 'slug' => 'public-field', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + + // Admin-only field (should be excluded) + RegistrationFormField::factory()->create([ + 'event_id' => $event->id, + 'label' => 'Admin only field', + 'slug' => 'admin-only-field', + 'is_portal_visible' => true, + 'is_admin_only' => true, + ]); + + // Not portal visible (should be excluded) + RegistrationFormField::factory()->create([ + 'event_id' => $event->id, + 'label' => 'Hidden field', + 'slug' => 'hidden-field', + 'is_portal_visible' => false, + 'is_admin_only' => false, + ]); + + $response = $this->getJson('/api/v1/public/events/admin-fields-event/registration-data'); + + $response->assertOk() + ->assertJsonCount(1, 'data.registration_fields') + ->assertJsonPath('data.registration_fields.0.slug', 'public-field'); + } + + public function test_registration_data_includes_form_toggles(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'toggles-event', + 'registration_show_section_preferences' => true, + 'registration_show_availability' => false, + ]); + + $response = $this->getJson('/api/v1/public/events/toggles-event/registration-data'); + + $response->assertOk() + ->assertJsonPath('data.event.registration_show_section_preferences', true) + ->assertJsonPath('data.event.registration_show_availability', false); + } + + public function test_registration_data_includes_available_tags_for_tag_picker(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'tags-event', + ]); + + $tag = PersonTag::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'EHBO', + 'category' => 'Certificaat', + 'is_active' => true, + ]); + + PersonTag::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'name' => 'Barervaring', + 'category' => 'Ervaring', + 'is_active' => true, + ]); + + RegistrationFormField::factory()->tagPickerField()->create([ + 'event_id' => $event->id, + 'tag_category' => 'Certificaat', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + + $response = $this->getJson('/api/v1/public/events/tags-event/registration-data'); + + $response->assertOk() + ->assertJsonCount(1, 'data.registration_fields') + ->assertJsonCount(1, 'data.registration_fields.0.available_tags') + ->assertJsonPath('data.registration_fields.0.available_tags.0.name', 'EHBO'); + } } diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php index fa25ce47..52807a0a 100644 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -10,6 +10,7 @@ use App\Models\Event; use App\Models\FestivalSection; use App\Models\Organisation; use App\Models\Person; +use App\Models\RegistrationFormField; use App\Models\TimeSlot; use App\Models\User; use Database\Seeders\RoleSeeder; @@ -156,7 +157,7 @@ class VolunteerRegistrationTest extends TestCase 'tshirt_size' => 'XL', 'motivation' => 'Ik vind festivals geweldig.', 'section_preferences' => [ - ['section_name' => $this->section->name, 'priority' => 1], + ['festival_section_id' => $this->section->id, 'priority' => 1], ], ]); @@ -167,7 +168,12 @@ class VolunteerRegistrationTest extends TestCase $this->assertEquals('XL', $customFields['tshirt_size']); $this->assertEquals('Ik vind festivals geweldig.', $customFields['motivation']); - $this->assertNotEmpty($customFields['section_preferences']); + + $this->assertDatabaseHas('person_section_preferences', [ + 'person_id' => $person->id, + 'festival_section_id' => $this->section->id, + 'priority' => 1, + ]); } public function test_volunteer_can_register_with_date_of_birth(): void @@ -428,4 +434,156 @@ class VolunteerRegistrationTest extends TestCase $response->assertStatus(401); } + + // ─── Dynamic Field Values ────────────────────────────────────────── + + public function test_volunteer_can_register_with_field_values(): void + { + $selectField = RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $this->event->id, + 'sort_order' => 0, + ]); + + $textField = RegistrationFormField::factory()->textareaField()->create([ + 'event_id' => $this->event->id, + 'sort_order' => 1, + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Noor', + 'last_name' => 'Janssen', + 'email' => 'noor@voorbeeld.nl', + 'field_values' => [ + $selectField->slug => 'L', + $textField->slug => 'Ik ben een ervaren vrijwilliger', + ], + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'noor@voorbeeld.nl')->first(); + + $this->assertDatabaseHas('person_field_values', [ + 'person_id' => $person->id, + 'registration_form_field_id' => $selectField->id, + 'value' => 'L', + ]); + + $this->assertDatabaseHas('person_field_values', [ + 'person_id' => $person->id, + 'registration_form_field_id' => $textField->id, + 'value' => 'Ik ben een ervaren vrijwilliger', + ]); + } + + public function test_volunteer_can_register_with_section_preferences(): void + { + $section1 = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Hoofdpodium Bar', + ]); + + $section2 = FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'EHBO Post', + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Rick', + 'last_name' => 'Peters', + 'email' => 'rick@voorbeeld.nl', + 'section_preferences' => [ + ['festival_section_id' => $section1->id, 'priority' => 1], + ['festival_section_id' => $section2->id, 'priority' => 2], + ], + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'rick@voorbeeld.nl')->first(); + + $this->assertDatabaseHas('person_section_preferences', [ + 'person_id' => $person->id, + 'festival_section_id' => $section1->id, + 'priority' => 1, + ]); + + $this->assertDatabaseHas('person_section_preferences', [ + 'person_id' => $person->id, + 'festival_section_id' => $section2->id, + 'priority' => 2, + ]); + } + + public function test_volunteer_can_register_with_multiselect_field_values(): void + { + $multiselectField = RegistrationFormField::factory()->multiselectField()->create([ + 'event_id' => $this->event->id, + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Femke', + 'last_name' => 'de Jong', + 'email' => 'femke@voorbeeld.nl', + 'field_values' => [ + $multiselectField->slug => ['Vegetarisch', 'Glutenvrij'], + ], + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'femke@voorbeeld.nl')->first(); + + $this->assertDatabaseHas('person_field_values', [ + 'person_id' => $person->id, + 'registration_form_field_id' => $multiselectField->id, + ]); + + $value = $person->fieldValues() + ->where('registration_form_field_id', $multiselectField->id) + ->first(); + + $this->assertEquals(['Vegetarisch', 'Glutenvrij'], $value->selected_options); + } + + public function test_portal_me_includes_field_values_and_section_preferences(): void + { + $user = User::factory()->create(['first_name' => 'Lotte', 'last_name' => 'Vos']); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'user_id' => $user->id, + 'email' => $user->email, + ]); + + $field = RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $this->event->id, + ]); + + \App\Models\PersonFieldValue::create([ + 'person_id' => $person->id, + 'registration_form_field_id' => $field->id, + 'value' => 'M', + ]); + + \App\Models\PersonSectionPreference::create([ + 'person_id' => $person->id, + 'festival_section_id' => $this->section->id, + 'priority' => 1, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data.field_values') + ->assertJsonPath('data.field_values.0.field_slug', $field->slug) + ->assertJsonPath('data.field_values.0.value', 'M') + ->assertJsonCount(1, 'data.section_preferences') + ->assertJsonPath('data.section_preferences.0.festival_section_id', $this->section->id) + ->assertJsonPath('data.section_preferences.0.priority', 1); + } }