From 6dccf872341770a701ce4c98aa61368571848d10 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 11 Apr 2026 09:06:29 +0200 Subject: [PATCH] feat: add date_of_birth field to persons across all layers Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Requests/Api/V1/StorePersonRequest.php | 1 + .../Requests/Api/V1/UpdatePersonRequest.php | 1 + .../Api/V1/VolunteerRegistrationRequest.php | 1 + .../Http/Resources/Api/V1/PersonResource.php | 1 + api/app/Models/Person.php | 2 + .../Services/VolunteerRegistrationService.php | 1 + api/database/factories/PersonFactory.php | 1 + ...000_add_date_of_birth_to_persons_table.php | 24 +++++++++++ api/database/seeders/DevSeeder.php | 30 ++++++------- .../Api/V1/VolunteerRegistrationTest.php | 42 +++++++++++++++++++ .../components/persons/CreatePersonDialog.vue | 14 +++++++ .../components/persons/EditPersonDialog.vue | 14 +++++++ .../components/persons/PersonDetailPanel.vue | 21 ++++++++++ apps/app/src/types/person.ts | 2 + .../portal/src/pages/register/[eventSlug].vue | 20 ++++++++- apps/portal/src/schemas/registrationSchema.ts | 1 + apps/portal/src/types/registration.ts | 1 + dev-docs/SCHEMA.md | 1 + 18 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 api/database/migrations/2026_04_10_500000_add_date_of_birth_to_persons_table.php diff --git a/api/app/Http/Requests/Api/V1/StorePersonRequest.php b/api/app/Http/Requests/Api/V1/StorePersonRequest.php index 04e5058c..da2a5c72 100644 --- a/api/app/Http/Requests/Api/V1/StorePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/StorePersonRequest.php @@ -20,6 +20,7 @@ final class StorePersonRequest extends FormRequest 'crowd_type_id' => ['required', 'ulid', 'exists:crowd_types,id'], 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], 'email' => ['required', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:30'], 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], diff --git a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php index 98eab264..aa2035a8 100644 --- a/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdatePersonRequest.php @@ -20,6 +20,7 @@ final class UpdatePersonRequest extends FormRequest 'crowd_type_id' => ['sometimes', 'ulid', 'exists:crowd_types,id'], 'first_name' => ['sometimes', 'string', 'max:255'], 'last_name' => ['sometimes', 'string', 'max:255'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], 'email' => ['sometimes', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:30'], 'company_id' => ['nullable', 'ulid', 'exists:companies,id'], diff --git a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php index 7055df61..d31384d1 100644 --- a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php +++ b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php @@ -35,6 +35,7 @@ final class VolunteerRegistrationRequest extends FormRequest 'last_name' => ['required_without:_authenticated', 'string', 'max:255'], 'email' => ['required_without:_authenticated', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:50'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], 'tshirt_size' => ['nullable', 'string', 'in:XS,S,M,L,XL,XXL,XXXL'], 'first_aid' => ['nullable', 'boolean'], diff --git a/api/app/Http/Resources/Api/V1/PersonResource.php b/api/app/Http/Resources/Api/V1/PersonResource.php index ed4de8cf..a1470ea0 100644 --- a/api/app/Http/Resources/Api/V1/PersonResource.php +++ b/api/app/Http/Resources/Api/V1/PersonResource.php @@ -16,6 +16,7 @@ final class PersonResource extends JsonResource 'event_id' => $this->event_id, 'first_name' => $this->first_name, 'last_name' => $this->last_name, + 'date_of_birth' => $this->date_of_birth?->toDateString(), 'full_name' => $this->full_name, 'email' => $this->email, 'phone' => $this->phone, diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index 3371928f..d0a72836 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -30,6 +30,7 @@ final class Person extends Model 'company_id', 'first_name', 'last_name', + 'date_of_birth', 'email', 'phone', 'status', @@ -51,6 +52,7 @@ final class Person extends Model protected function casts(): array { return [ + 'date_of_birth' => 'date', 'is_blacklisted' => 'boolean', 'custom_fields' => 'array', ]; diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php index 60caa09d..7f50a865 100644 --- a/api/app/Services/VolunteerRegistrationService.php +++ b/api/app/Services/VolunteerRegistrationService.php @@ -53,6 +53,7 @@ final class VolunteerRegistrationService 'first_name' => $user?->first_name ?? $validated['first_name'], 'last_name' => $user?->last_name ?? $validated['last_name'], 'phone' => $validated['phone'] ?? null, + 'date_of_birth' => $validated['date_of_birth'] ?? null, 'status' => PersonStatus::PENDING, 'custom_fields' => [ 'tshirt_size' => $validated['tshirt_size'] ?? null, diff --git a/api/database/factories/PersonFactory.php b/api/database/factories/PersonFactory.php index 65dbd918..e552584d 100644 --- a/api/database/factories/PersonFactory.php +++ b/api/database/factories/PersonFactory.php @@ -20,6 +20,7 @@ final class PersonFactory extends Factory 'crowd_type_id' => CrowdType::factory(), 'first_name' => fake('nl_NL')->firstName(), 'last_name' => fake('nl_NL')->lastName(), + 'date_of_birth' => fake()->dateTimeBetween('-40 years', '-18 years')->format('Y-m-d'), 'email' => fake()->unique()->safeEmail(), 'phone' => fake('nl_NL')->phoneNumber(), 'status' => 'pending', diff --git a/api/database/migrations/2026_04_10_500000_add_date_of_birth_to_persons_table.php b/api/database/migrations/2026_04_10_500000_add_date_of_birth_to_persons_table.php new file mode 100644 index 00000000..a419472f --- /dev/null +++ b/api/database/migrations/2026_04_10_500000_add_date_of_birth_to_persons_table.php @@ -0,0 +1,24 @@ +date('date_of_birth')->nullable()->after('last_name'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table) { + $table->dropColumn('date_of_birth'); + }); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 821b61a7..deda64cb 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -471,21 +471,21 @@ class DevSeeder extends Seeder } // 15 named volunteers - $jan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['jan@gmail.com']->id, 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@gmail.com', 'phone' => '+31612345001', 'status' => 'approved']); - $lisaB = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Lisa', 'last_name' => 'Bakker', 'email' => 'lisa.bakker@hotmail.com', 'phone' => '+31612345002', 'status' => 'approved']); - $ahmedP = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['ahmed.h@gmail.com']->id, 'first_name' => 'Ahmed', 'last_name' => 'Hassan', 'email' => 'ahmed.h@gmail.com', 'phone' => '+31612345003', 'status' => 'approved']); - $saraJ = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sara', 'last_name' => 'Jansen', 'email' => 'sara.j@outlook.com', 'phone' => '+31612345004', 'status' => 'approved']); - $tomV = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['tom.visser@gmail.com']->id, 'first_name' => 'Tom', 'last_name' => 'Visser', 'email' => 'tom.visser@gmail.com', 'phone' => '+31612345005', 'status' => 'approved']); - $fatima = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Fatima', 'last_name' => 'El Amrani', 'email' => 'fatima@gmail.com', 'phone' => '+31612345006', 'status' => 'approved']); - $daan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Daan', 'last_name' => 'Smit', 'email' => 'daan.smit@gmail.com', 'phone' => '+31612345007', 'status' => 'pending']); - Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sophie', 'last_name' => 'Mulder', 'email' => 'sophie.m@hotmail.com', 'phone' => '+31612345008', 'status' => 'pending']); - Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Jesse', 'last_name' => 'van Dijk', 'email' => 'jesse@gmail.com', 'phone' => '+31612345009', 'status' => 'applied']); - Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Noa', 'last_name' => 'Hendriks', 'email' => 'noa.h@outlook.com', 'phone' => '+31612345010', 'status' => 'applied']); - Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Kevin', 'last_name' => 'Bos', 'email' => 'kevin.bos@gmail.com', 'phone' => '+31612345011', 'status' => 'rejected', 'admin_notes' => 'Vorig jaar no-show']); - Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Priya', 'last_name' => 'Sharma', 'email' => 'priya@gmail.com', 'phone' => '+31612345012', 'status' => 'invited']); - $lotte = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['lotte@gmail.com']->id, 'first_name' => 'Lotte', 'last_name' => 'de Jong', 'email' => 'lotte@gmail.com', 'phone' => '+31612345013', 'status' => 'approved']); - $robin = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Robin', 'last_name' => 'Peters', 'email' => 'robin.p@hotmail.com', 'phone' => '+31612345014', 'status' => 'approved']); - $emma = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Emma', 'last_name' => 'Willems', 'email' => 'emma.w@gmail.com', 'phone' => '+31612345015', 'status' => 'no_show', 'is_blacklisted' => true]); + $jan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['jan@gmail.com']->id, 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@gmail.com', 'phone' => '+31612345001', 'status' => 'approved', 'date_of_birth' => '1995-03-15']); + $lisaB = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Lisa', 'last_name' => 'Bakker', 'email' => 'lisa.bakker@hotmail.com', 'phone' => '+31612345002', 'status' => 'approved', 'date_of_birth' => '1998-07-22']); + $ahmedP = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['ahmed.h@gmail.com']->id, 'first_name' => 'Ahmed', 'last_name' => 'Hassan', 'email' => 'ahmed.h@gmail.com', 'phone' => '+31612345003', 'status' => 'approved', 'date_of_birth' => '1992-11-08']); + $saraJ = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sara', 'last_name' => 'Jansen', 'email' => 'sara.j@outlook.com', 'phone' => '+31612345004', 'status' => 'approved', 'date_of_birth' => '2000-01-30']); + $tomV = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['tom.visser@gmail.com']->id, 'first_name' => 'Tom', 'last_name' => 'Visser', 'email' => 'tom.visser@gmail.com', 'phone' => '+31612345005', 'status' => 'approved', 'date_of_birth' => '1997-06-14']); + $fatima = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Fatima', 'last_name' => 'El Amrani', 'email' => 'fatima@gmail.com', 'phone' => '+31612345006', 'status' => 'approved', 'date_of_birth' => '1996-05-20']); + $daan = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Daan', 'last_name' => 'Smit', 'email' => 'daan.smit@gmail.com', 'phone' => '+31612345007', 'status' => 'pending', 'date_of_birth' => '1993-08-11']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Sophie', 'last_name' => 'Mulder', 'email' => 'sophie.m@hotmail.com', 'phone' => '+31612345008', 'status' => 'pending', 'date_of_birth' => '2001-02-28']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Jesse', 'last_name' => 'van Dijk', 'email' => 'jesse@gmail.com', 'phone' => '+31612345009', 'status' => 'applied', 'date_of_birth' => '1999-10-05']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Noa', 'last_name' => 'Hendriks', 'email' => 'noa.h@outlook.com', 'phone' => '+31612345010', 'status' => 'applied', 'date_of_birth' => '2000-07-19']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Kevin', 'last_name' => 'Bos', 'email' => 'kevin.bos@gmail.com', 'phone' => '+31612345011', 'status' => 'rejected', 'admin_notes' => 'Vorig jaar no-show', 'date_of_birth' => '1994-12-01']); + Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Priya', 'last_name' => 'Sharma', 'email' => 'priya@gmail.com', 'phone' => '+31612345012', 'status' => 'invited', 'date_of_birth' => '1998-03-27']); + $lotte = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'user_id' => $volunteerUsers['lotte@gmail.com']->id, 'first_name' => 'Lotte', 'last_name' => 'de Jong', 'email' => 'lotte@gmail.com', 'phone' => '+31612345013', 'status' => 'approved', 'date_of_birth' => '1994-09-25']); + $robin = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Robin', 'last_name' => 'Peters', 'email' => 'robin.p@hotmail.com', 'phone' => '+31612345014', 'status' => 'approved', 'date_of_birth' => '1991-12-03']); + $emma = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $vol, 'first_name' => 'Emma', 'last_name' => 'Willems', 'email' => 'emma.w@gmail.com', 'phone' => '+31612345015', 'status' => 'no_show', 'is_blacklisted' => true, 'date_of_birth' => '1999-04-17']); // 6 named crew $klaas = Person::create(['event_id' => $festival->id, 'crowd_type_id' => $crew, 'company_id' => $this->companies['SecureEvent BV']->id, 'first_name' => 'Klaas', 'last_name' => 'Veilig Jr.', 'email' => 'klaas.jr@secureevent.nl', 'phone' => '+31612345016', 'status' => 'approved']); diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php index 06ddcbf6..fa25ce47 100644 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -170,6 +170,48 @@ class VolunteerRegistrationTest extends TestCase $this->assertNotEmpty($customFields['section_preferences']); } + public function test_volunteer_can_register_with_date_of_birth(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Mila', + 'last_name' => 'de Boer', + 'email' => 'mila@voorbeeld.nl', + 'date_of_birth' => '1998-05-12', + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'mila@voorbeeld.nl')->first(); + $this->assertEquals('1998-05-12', $person->date_of_birth->format('Y-m-d')); + } + + public function test_volunteer_can_register_without_date_of_birth(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Sem', + 'last_name' => 'van Beek', + 'email' => 'sem@voorbeeld.nl', + ]); + + $response->assertStatus(201); + + $person = Person::where('email', 'sem@voorbeeld.nl')->first(); + $this->assertNull($person->date_of_birth); + } + + public function test_date_of_birth_must_be_before_today(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Tijn', + 'last_name' => 'Kuiper', + 'email' => 'tijn@voorbeeld.nl', + 'date_of_birth' => now()->addDay()->format('Y-m-d'), + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('date_of_birth'); + } + public function test_duplicate_email_rejected(): void { $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ diff --git a/apps/app/src/components/persons/CreatePersonDialog.vue b/apps/app/src/components/persons/CreatePersonDialog.vue index 6ca7dae2..1b356192 100644 --- a/apps/app/src/components/persons/CreatePersonDialog.vue +++ b/apps/app/src/components/persons/CreatePersonDialog.vue @@ -23,6 +23,7 @@ const form = ref({ crowd_type_id: '', first_name: '', last_name: '', + date_of_birth: '', email: '', phone: '', company_id: '', @@ -72,6 +73,7 @@ function resetForm() { crowd_type_id: '', first_name: '', last_name: '', + date_of_birth: '', email: '', phone: '', company_id: '', @@ -91,6 +93,7 @@ function onSubmit() { crowd_type_id: form.value.crowd_type_id, first_name: form.value.first_name, last_name: form.value.last_name, + ...(form.value.date_of_birth ? { date_of_birth: form.value.date_of_birth } : {}), email: form.value.email, ...(form.value.phone ? { phone: form.value.phone } : {}), ...(form.value.company_id ? { company_id: form.value.company_id } : {}), @@ -164,6 +167,17 @@ function onSubmit() { :error-messages="errors.last_name" /> + + + props.person, (p) => { crowd_type_id: p.crowd_type?.id ?? '', first_name: p.first_name, last_name: p.last_name, + date_of_birth: p.date_of_birth ?? '', email: p.email, phone: p.phone ?? '', company_id: p.company?.id ?? '', @@ -96,6 +98,7 @@ function onSubmit() { crowd_type_id: form.value.crowd_type_id, first_name: form.value.first_name, last_name: form.value.last_name, + date_of_birth: form.value.date_of_birth || undefined, email: form.value.email, phone: form.value.phone || undefined, company_id: form.value.company_id || undefined, @@ -167,6 +170,17 @@ function onSubmit() { :error-messages="errors.last_name" /> + + + + + + Geboortedatum + {{ person.date_of_birth ? formatDateOfBirth(person.date_of_birth) : 'Niet opgegeven' }} + +