diff --git a/api/app/Http/Controllers/Api/V1/CheckEmailController.php b/api/app/Http/Controllers/Api/V1/CheckEmailController.php new file mode 100644 index 00000000..349c338f --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/CheckEmailController.php @@ -0,0 +1,20 @@ +validated('email')))->exists(); + + return response()->json(['exists' => $exists]); + } +} diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php index ad54dbca..8fe617b2 100644 --- a/api/app/Http/Controllers/Api/V1/PersonController.php +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -9,17 +9,22 @@ use App\Http\Requests\Api\V1\StorePersonRequest; use App\Http\Requests\Api\V1\UpdatePersonRequest; use App\Http\Resources\Api\V1\PersonCollection; use App\Http\Resources\Api\V1\PersonResource; +use App\Mail\RegistrationApprovedMail; +use App\Mail\RegistrationRejectedMail; use App\Models\Event; use App\Models\Person; use App\Services\PersonIdentityService; +use App\Services\TagSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Mail; final class PersonController extends Controller { public function __construct( private readonly PersonIdentityService $identityService, + private readonly TagSyncService $tagSyncService, ) {} public function index(Request $request, Event $event): PersonCollection @@ -107,6 +112,27 @@ final class PersonController extends Controller $person->update(['status' => 'approved']); + $this->tagSyncService->syncFromRegistration($person); + + if ($person->email) { + Mail::to($person->email)->queue(new RegistrationApprovedMail($person, $event)); + } + + return $this->success(new PersonResource($person->fresh()->load('crowdType'))); + } + + public function reject(Request $request, Event $event, Person $person): JsonResponse + { + Gate::authorize('approve', [$person, $event]); + + $person->update(['status' => 'rejected']); + + $reason = $request->input('reason'); + + if ($person->email) { + Mail::to($person->email)->queue(new RegistrationRejectedMail($person, $event, $reason)); + } + return $this->success(new PersonResource($person->fresh()->load('crowdType'))); } } diff --git a/api/app/Http/Requests/Api/V1/CheckEmailRequest.php b/api/app/Http/Requests/Api/V1/CheckEmailRequest.php new file mode 100644 index 00000000..ef1073fa --- /dev/null +++ b/api/app/Http/Requests/Api/V1/CheckEmailRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php index cb06deac..fde725ba 100644 --- a/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php +++ b/api/app/Http/Requests/Api/V1/VolunteerRegistrationRequest.php @@ -30,7 +30,9 @@ final class VolunteerRegistrationRequest extends FormRequest /** @return array */ public function rules(): array { - return [ + $user = auth('sanctum')->user(); + + $rules = [ 'first_name' => ['required_without:_authenticated', 'string', 'max:255'], 'last_name' => ['required_without:_authenticated', 'string', 'max:255'], 'email' => ['required_without:_authenticated', 'email', 'max:255'], @@ -55,5 +57,13 @@ final class VolunteerRegistrationRequest extends FormRequest 'field_values' => ['nullable', 'array'], ]; + + // Password required for unauthenticated registrations + if ($user === null) { + $rules['password'] = ['required', 'string', 'min:8']; + $rules['password_confirmation'] = ['nullable', 'same:password']; + } + + return $rules; } } diff --git a/api/app/Mail/RegistrationApprovedMail.php b/api/app/Mail/RegistrationApprovedMail.php new file mode 100644 index 00000000..4a2cab90 --- /dev/null +++ b/api/app/Mail/RegistrationApprovedMail.php @@ -0,0 +1,44 @@ +event->name}", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.registration-approved', + with: [ + 'personName' => $this->person->first_name, + 'eventName' => $this->event->name, + 'portalUrl' => config('app.frontend_portal_url'), + ], + ); + } +} diff --git a/api/app/Mail/RegistrationConfirmationMail.php b/api/app/Mail/RegistrationConfirmationMail.php new file mode 100644 index 00000000..db6a3272 --- /dev/null +++ b/api/app/Mail/RegistrationConfirmationMail.php @@ -0,0 +1,46 @@ +event->name}", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.registration-confirmation', + with: [ + 'personName' => $this->person->first_name, + 'eventName' => $this->event->name, + 'startDate' => $this->event->start_date->format('d-m-Y'), + 'endDate' => $this->event->end_date->format('d-m-Y'), + 'portalUrl' => config('app.frontend_portal_url'), + ], + ); + } +} diff --git a/api/app/Mail/RegistrationRejectedMail.php b/api/app/Mail/RegistrationRejectedMail.php new file mode 100644 index 00000000..d1848b01 --- /dev/null +++ b/api/app/Mail/RegistrationRejectedMail.php @@ -0,0 +1,45 @@ +event->name}", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.registration-rejected', + with: [ + 'personName' => $this->person->first_name, + 'eventName' => $this->event->name, + 'reason' => $this->reason, + ], + ); + } +} diff --git a/api/app/Services/VolunteerRegistrationService.php b/api/app/Services/VolunteerRegistrationService.php index 46b4671c..19e139bb 100644 --- a/api/app/Services/VolunteerRegistrationService.php +++ b/api/app/Services/VolunteerRegistrationService.php @@ -5,22 +5,24 @@ declare(strict_types=1); namespace App\Services; use App\Enums\PersonStatus; +use App\Mail\RegistrationConfirmationMail; use App\Models\CrowdType; use App\Models\Event; use App\Models\Person; use App\Models\User; use App\Models\VolunteerAvailability; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Mail; use Illuminate\Validation\ValidationException; final class VolunteerRegistrationService { public function __construct( - private readonly PersonIdentityService $identityService, private readonly RegistrationFormFieldService $registrationFormFieldService, private readonly PersonSectionPreferenceService $personSectionPreferenceService, + private readonly TagSyncService $tagSyncService, ) {} /** @@ -37,23 +39,28 @@ final class VolunteerRegistrationService } $festivalEvent = $this->resolveFestivalEvent($event); - $email = $user?->email ?? $validated['email']; + $email = strtolower($user?->email ?? $validated['email']); $this->checkDuplicateRegistration($festivalEvent, $email); + // Resolve or create user account for unauthenticated registrations + if ($user === null) { + $user = $this->resolveUserAccount($email, $validated); + } + $volunteerCrowdType = $this->resolveVolunteerCrowdType($event); - return DB::transaction(function () use ($festivalEvent, $validated, $user, $email, $volunteerCrowdType): Person { + $person = DB::transaction(function () use ($festivalEvent, $validated, $user, $email, $volunteerCrowdType): Person { $person = Person::updateOrCreate( [ 'event_id' => $festivalEvent->id, 'email' => $email, ], [ - 'user_id' => $user?->id, + 'user_id' => $user->id, 'crowd_type_id' => $volunteerCrowdType->id, - 'first_name' => $user?->first_name ?? $validated['first_name'], - 'last_name' => $user?->last_name ?? $validated['last_name'], + 'first_name' => $validated['first_name'] ?? $user->first_name, + 'last_name' => $validated['last_name'] ?? $user->last_name, 'phone' => $validated['phone'] ?? null, 'date_of_birth' => $validated['date_of_birth'] ?? null, 'status' => PersonStatus::PENDING, @@ -84,11 +91,10 @@ final class VolunteerRegistrationService ); } - if ($user === null) { - $this->detectIdentityMatch($person); - } + // Trigger tag sync — user_id is always known now + $this->tagSyncService->syncFromRegistration($person); - $source = $user !== null ? 'authenticated_form' : 'public_form'; + $source = auth('sanctum')->check() ? 'authenticated_form' : 'public_form'; $activityLogger = activity('volunteer_registration') ->performedOn($person) @@ -97,16 +103,55 @@ final class VolunteerRegistrationService 'event_id' => $festivalEvent->id, 'person_id' => $person->id, 'email' => $email, - ]); - - if ($user !== null) { - $activityLogger->causedBy($user); - } + ]) + ->causedBy($user); $activityLogger->log('person.registered'); return $person; }); + + // Send confirmation email (queued, outside transaction) + Mail::to($person->email)->queue(new RegistrationConfirmationMail($person, $festivalEvent)); + + return $person; + } + + /** + * Resolve or create user account for the registering email. + * + * @param array $validated + * + * @throws ValidationException + */ + private function resolveUserAccount(string $email, array $validated): User + { + $existingUser = User::where('email', $email)->first(); + + if ($existingUser !== null) { + // Returning volunteer: authenticate with provided password + if (!Hash::check($validated['password'], $existingUser->password)) { + throw ValidationException::withMessages([ + 'password' => ['Wachtwoord onjuist.'], + ]); + } + + return $existingUser; + } + + // New volunteer: create user account + try { + return User::create([ + 'first_name' => $validated['first_name'], + 'last_name' => $validated['last_name'], + 'email' => $email, + 'password' => Hash::make($validated['password']), + ]); + } catch (\Illuminate\Database\UniqueConstraintViolationException) { + throw ValidationException::withMessages([ + 'email' => ['Dit emailadres heeft al een account. Gebruik je bestaande wachtwoord.'], + ]); + } } private function resolveFestivalEvent(Event $event): Event @@ -188,18 +233,4 @@ final class VolunteerRegistrationService ]); } } - - private function detectIdentityMatch(Person $person): void - { - if (! Schema::hasTable('person_identity_matches')) { - activity('volunteer_registration') - ->performedOn($person) - ->withProperties(['email' => $person->email]) - ->log('person.identity_match_skipped_table_missing'); - - return; - } - - $this->identityService->detectMatchForPerson($person); - } } diff --git a/api/config/app.php b/api/config/app.php index 83235889..4e44f4bb 100644 --- a/api/config/app.php +++ b/api/config/app.php @@ -124,5 +124,6 @@ return [ ], 'frontend_app_url' => env('FRONTEND_APP_URL', 'http://localhost:5174'), + 'frontend_portal_url' => env('FRONTEND_PORTAL_URL', 'http://localhost:5175'), ]; diff --git a/api/resources/views/emails/registration-approved.blade.php b/api/resources/views/emails/registration-approved.blade.php new file mode 100644 index 00000000..03652a11 --- /dev/null +++ b/api/resources/views/emails/registration-approved.blade.php @@ -0,0 +1,14 @@ + +# Goed nieuws, {{ $personName }}! + +Goed nieuws! Je bent goedgekeurd als vrijwilliger voor **{{ $eventName }}**. + +Log in op het portaal om je shifts te bekijken en te claimen. + + +Naar het portaal + + +Met vriendelijke groet,
+{{ config('app.name') }} +
diff --git a/api/resources/views/emails/registration-confirmation.blade.php b/api/resources/views/emails/registration-confirmation.blade.php new file mode 100644 index 00000000..e239026a --- /dev/null +++ b/api/resources/views/emails/registration-confirmation.blade.php @@ -0,0 +1,19 @@ + +# Bedankt voor je aanmelding, {{ $personName }}! + +Bedankt voor je aanmelding als vrijwilliger voor **{{ $eventName }}**! + +Je registratie wordt beoordeeld door de organisatie. Je ontvangt een e-mail zodra je aanmelding is verwerkt. + +Je kunt inloggen op het vrijwilligersportaal om je status te volgen. + + +Naar het portaal + + +**Evenement:** {{ $eventName }}
+**Datum:** {{ $startDate }} t/m {{ $endDate }} + +Met vriendelijke groet,
+{{ config('app.name') }} +
diff --git a/api/resources/views/emails/registration-rejected.blade.php b/api/resources/views/emails/registration-rejected.blade.php new file mode 100644 index 00000000..663d4f2b --- /dev/null +++ b/api/resources/views/emails/registration-rejected.blade.php @@ -0,0 +1,14 @@ + +# Update over je aanmelding, {{ $personName }} + +Helaas hebben we je aanmelding voor **{{ $eventName }}** niet kunnen goedkeuren. + +@if($reason) +**Reden:** {{ $reason }} +@endif + +Neem contact op met de organisatie als je vragen hebt. + +Met vriendelijke groet,
+{{ config('app.name') }} +
diff --git a/api/routes/api.php b/api/routes/api.php index 8d746872..38222b8f 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Controllers\Api\V1\CheckEmailController; use App\Http\Controllers\Api\V1\CompanyController; use App\Http\Controllers\Api\V1\CrowdListController; use App\Http\Controllers\Api\V1\CrowdTypeController; @@ -60,6 +61,7 @@ Route::post('invitations/{token}/accept', [InvitationController::class, 'accept' // Public portal routes Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class); +Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class); Route::post('portal/token-auth', [PortalTokenController::class, 'auth']); @@ -164,6 +166,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::apiResource('persons', PersonController::class); Route::post('persons/{person}/approve', [PersonController::class, 'approve']); + Route::post('persons/{person}/reject', [PersonController::class, 'reject']); // Volunteer availabilities Route::get('persons/{person}/availabilities', [VolunteerAvailabilityController::class, 'index']); diff --git a/api/tests/Feature/Api/V1/CheckEmailTest.php b/api/tests/Feature/Api/V1/CheckEmailTest.php new file mode 100644 index 00000000..86beb0a2 --- /dev/null +++ b/api/tests/Feature/Api/V1/CheckEmailTest.php @@ -0,0 +1,81 @@ +create(['email' => 'lisa@test.nl']); + + $response = $this->postJson('/api/v1/public/check-email', [ + 'email' => 'lisa@test.nl', + ]); + + $response->assertOk(); + $response->assertJson(['exists' => true]); + } + + public function test_unknown_email_returns_exists_false(): void + { + $response = $this->postJson('/api/v1/public/check-email', [ + 'email' => 'nobody@test.nl', + ]); + + $response->assertOk(); + $response->assertJson(['exists' => false]); + } + + public function test_email_check_is_case_insensitive(): void + { + User::factory()->create(['email' => 'lisa@test.nl']); + + $response = $this->postJson('/api/v1/public/check-email', [ + 'email' => 'LISA@TEST.NL', + ]); + + $response->assertOk(); + $response->assertJson(['exists' => true]); + } + + public function test_rate_limiting_returns_429(): void + { + for ($i = 0; $i < 10; $i++) { + $this->postJson('/api/v1/public/check-email', [ + 'email' => 'test@test.nl', + ])->assertOk(); + } + + $response = $this->postJson('/api/v1/public/check-email', [ + 'email' => 'test@test.nl', + ]); + + $response->assertStatus(429); + } + + public function test_invalid_email_returns_422(): void + { + $response = $this->postJson('/api/v1/public/check-email', [ + 'email' => 'not-an-email', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } + + public function test_missing_email_returns_422(): void + { + $response = $this->postJson('/api/v1/public/check-email', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('email'); + } +} diff --git a/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php b/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php new file mode 100644 index 00000000..b4789b95 --- /dev/null +++ b/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php @@ -0,0 +1,153 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_approving_person_sends_approved_email(): void + { + Mail::fake(); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + 'email' => 'volunteer@test.nl', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons/{$person->id}/approve"); + + $response->assertOk(); + + Mail::assertQueued(RegistrationApprovedMail::class, function ($mail) { + return $mail->hasTo('volunteer@test.nl'); + }); + } + + public function test_rejecting_person_sends_rejected_email_with_reason(): void + { + Mail::fake(); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + 'email' => 'volunteer@test.nl', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons/{$person->id}/reject", [ + 'reason' => 'Geen beschikbaarheid op de juiste momenten.', + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('persons', [ + 'id' => $person->id, + 'status' => 'rejected', + ]); + + Mail::assertQueued(RegistrationRejectedMail::class, function ($mail) { + return $mail->hasTo('volunteer@test.nl') + && $mail->reason === 'Geen beschikbaarheid op de juiste momenten.'; + }); + } + + public function test_rejecting_person_sends_rejected_email_without_reason(): void + { + Mail::fake(); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + 'email' => 'volunteer@test.nl', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons/{$person->id}/reject"); + + $response->assertOk(); + + Mail::assertQueued(RegistrationRejectedMail::class, function ($mail) { + return $mail->hasTo('volunteer@test.nl') + && $mail->reason === null; + }); + } + + public function test_unauthenticated_cannot_approve(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons/{$person->id}/approve"); + + $response->assertStatus(401); + } + + public function test_outsider_cannot_approve(): void + { + $outsider = User::factory()->create(); + $otherOrg = Organisation::factory()->create(); + $otherOrg->users()->attach($outsider, ['role' => 'org_admin']); + + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'status' => 'pending', + ]); + + Sanctum::actingAs($outsider); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/persons/{$person->id}/approve"); + + $response->assertStatus(403); + } +} diff --git a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php index 3f71c220..266b445e 100644 --- a/api/tests/Feature/Api/V1/RegistrationSettingsTest.php +++ b/api/tests/Feature/Api/V1/RegistrationSettingsTest.php @@ -195,8 +195,10 @@ class RegistrationSettingsTest extends TestCase ->assertJsonPath('data.0.section_count', 1); } - public function test_section_preferences_stored_as_section_name(): void + public function test_section_preferences_stored_in_table(): void { + \Illuminate\Support\Facades\Mail::fake(); + // This is a regression check for the VolunteerRegistration flow $event = Event::factory()->create([ 'organisation_id' => $this->organisation->id, @@ -207,7 +209,7 @@ class RegistrationSettingsTest extends TestCase 'organisation_id' => $this->organisation->id, ]); - FestivalSection::factory()->create([ + $section = FestivalSection::factory()->create([ 'event_id' => $event->id, 'name' => 'Backstage', 'show_in_registration' => true, @@ -217,18 +219,20 @@ class RegistrationSettingsTest extends TestCase 'first_name' => 'Test', 'last_name' => 'Vrijwilliger', 'email' => 'test-section-pref@example.nl', + 'password' => 'wachtwoord123', 'section_preferences' => [ - ['section_name' => 'Backstage', 'priority' => 1], + ['festival_section_id' => $section->id, 'priority' => 1], ], ]); $response->assertStatus(201); $person = \App\Models\Person::where('email', 'test-section-pref@example.nl')->first(); - $prefs = $person->custom_fields['section_preferences']; - $this->assertCount(1, $prefs); - $this->assertEquals('Backstage', $prefs[0]['section_name']); - $this->assertEquals(1, $prefs[0]['priority']); + $this->assertDatabaseHas('person_section_preferences', [ + 'person_id' => $person->id, + 'festival_section_id' => $section->id, + 'priority' => 1, + ]); } } diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php index 52807a0a..22578e68 100644 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1; use App\Enums\PersonStatus; +use App\Mail\RegistrationConfirmationMail; use App\Models\CrowdType; use App\Models\Event; use App\Models\FestivalSection; @@ -15,6 +16,8 @@ use App\Models\TimeSlot; use App\Models\User; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Schema; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -54,10 +57,13 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_all_fields(): void { + Mail::fake(); + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@voorbeeld.nl', + 'password' => 'wachtwoord123', 'phone' => '+31612345678', 'tshirt_size' => 'L', 'motivation' => 'Ik wil graag helpen bij dit festival!', @@ -77,10 +83,13 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_minimal_fields(): void { + Mail::fake(); + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Sophie', 'last_name' => 'Bakker', 'email' => 'sophie@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(201); @@ -108,6 +117,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Pieter', 'last_name' => 'Jansen', 'email' => 'pieter@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(201); @@ -120,12 +130,14 @@ class VolunteerRegistrationTest extends TestCase public function test_registration_syncs_availabilities(): void { + Mail::fake(); $timeSlot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Fleur', 'last_name' => 'Vermeer', 'email' => 'fleur@voorbeeld.nl', + 'password' => 'wachtwoord123', 'availabilities' => [ ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 4], ['time_slot_id' => $timeSlot2->id, 'preference_level' => 2], @@ -150,10 +162,13 @@ class VolunteerRegistrationTest extends TestCase public function test_registration_stores_custom_fields(): void { + Mail::fake(); + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Daan', 'last_name' => 'Mulder', 'email' => 'daan@voorbeeld.nl', + 'password' => 'wachtwoord123', 'tshirt_size' => 'XL', 'motivation' => 'Ik vind festivals geweldig.', 'section_preferences' => [ @@ -178,10 +193,13 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_date_of_birth(): void { + Mail::fake(); + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Mila', 'last_name' => 'de Boer', 'email' => 'mila@voorbeeld.nl', + 'password' => 'wachtwoord123', 'date_of_birth' => '1998-05-12', ]); @@ -193,10 +211,13 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_without_date_of_birth(): void { + Mail::fake(); + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Sem', 'last_name' => 'van Beek', 'email' => 'sem@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(201); @@ -211,6 +232,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Tijn', 'last_name' => 'Kuiper', 'email' => 'tijn@voorbeeld.nl', + 'password' => 'wachtwoord123', 'date_of_birth' => now()->addDay()->format('Y-m-d'), ]); @@ -220,16 +242,20 @@ class VolunteerRegistrationTest extends TestCase public function test_duplicate_email_rejected(): void { + Mail::fake(); + $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Anna', 'last_name' => 'Smit', 'email' => 'anna@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ 'first_name' => 'Anna', 'last_name' => 'Smit', 'email' => 'anna@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(422); @@ -238,6 +264,8 @@ class VolunteerRegistrationTest extends TestCase public function test_rejected_person_can_reregister(): void { + Mail::fake(); + Person::factory()->rejected()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->volunteerCrowdType->id, @@ -248,6 +276,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Herkan', 'last_name' => 'Poging', 'email' => 'herkan@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(200); @@ -269,6 +298,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Test', 'last_name' => 'Persoon', 'email' => 'test@voorbeeld.nl', + 'password' => 'wachtwoord123', ]); $response->assertStatus(422); @@ -280,6 +310,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Bas', 'last_name' => 'van Dijk', 'email' => 'bas@voorbeeld.nl', + 'password' => 'wachtwoord123', 'availabilities' => [ ['time_slot_id' => '01JNONEXISTENT00000000000', 'preference_level' => 3], ], @@ -439,6 +470,8 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_field_values(): void { + Mail::fake(); + $selectField = RegistrationFormField::factory()->selectField()->create([ 'event_id' => $this->event->id, 'sort_order' => 0, @@ -453,6 +486,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Noor', 'last_name' => 'Janssen', 'email' => 'noor@voorbeeld.nl', + 'password' => 'wachtwoord123', 'field_values' => [ $selectField->slug => 'L', $textField->slug => 'Ik ben een ervaren vrijwilliger', @@ -478,6 +512,8 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_section_preferences(): void { + Mail::fake(); + $section1 = FestivalSection::factory()->create([ 'event_id' => $this->event->id, 'name' => 'Hoofdpodium Bar', @@ -492,6 +528,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Rick', 'last_name' => 'Peters', 'email' => 'rick@voorbeeld.nl', + 'password' => 'wachtwoord123', 'section_preferences' => [ ['festival_section_id' => $section1->id, 'priority' => 1], ['festival_section_id' => $section2->id, 'priority' => 2], @@ -517,6 +554,8 @@ class VolunteerRegistrationTest extends TestCase public function test_volunteer_can_register_with_multiselect_field_values(): void { + Mail::fake(); + $multiselectField = RegistrationFormField::factory()->multiselectField()->create([ 'event_id' => $this->event->id, ]); @@ -525,6 +564,7 @@ class VolunteerRegistrationTest extends TestCase 'first_name' => 'Femke', 'last_name' => 'de Jong', 'email' => 'femke@voorbeeld.nl', + 'password' => 'wachtwoord123', 'field_values' => [ $multiselectField->slug => ['Vegetarisch', 'Glutenvrij'], ], @@ -546,6 +586,196 @@ class VolunteerRegistrationTest extends TestCase $this->assertEquals(['Vegetarisch', 'Glutenvrij'], $value->selected_options); } + // ─── User Account Creation ────────────────────────────────────────── + + public function test_new_volunteer_registration_creates_user_account(): void + { + Mail::fake(); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Nieuwe', + 'last_name' => 'Vrijwilliger', + 'email' => 'nieuw@voorbeeld.nl', + 'password' => 'wachtwoord123', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('users', [ + 'email' => 'nieuw@voorbeeld.nl', + 'first_name' => 'Nieuwe', + 'last_name' => 'Vrijwilliger', + ]); + + $user = User::where('email', 'nieuw@voorbeeld.nl')->first(); + + $this->assertDatabaseHas('persons', [ + 'email' => 'nieuw@voorbeeld.nl', + 'user_id' => $user->id, + 'event_id' => $this->event->id, + ]); + } + + public function test_returning_volunteer_with_correct_password_creates_person(): void + { + Mail::fake(); + + $existingUser = User::factory()->create([ + 'first_name' => 'Terug', + 'last_name' => 'Keerder', + 'email' => 'terug@voorbeeld.nl', + 'password' => Hash::make('bestaandwachtwoord'), + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Terug', + 'last_name' => 'Keerder', + 'email' => 'terug@voorbeeld.nl', + 'password' => 'bestaandwachtwoord', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'terug@voorbeeld.nl', + 'user_id' => $existingUser->id, + 'event_id' => $this->event->id, + ]); + + // Should not have created a new user + $this->assertEquals(1, User::where('email', 'terug@voorbeeld.nl')->count()); + } + + public function test_returning_volunteer_with_wrong_password_returns_422(): void + { + User::factory()->create([ + 'email' => 'bestaand@voorbeeld.nl', + 'password' => Hash::make('echtgeheim'), + ]); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Fout', + 'last_name' => 'Wachtwoord', + 'email' => 'bestaand@voorbeeld.nl', + 'password' => 'foutwachtwoord', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('password'); + } + + public function test_registration_sends_confirmation_email(): void + { + Mail::fake(); + + $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Mail', + 'last_name' => 'Test', + 'email' => 'mailtest@voorbeeld.nl', + 'password' => 'wachtwoord123', + ]); + + Mail::assertQueued(RegistrationConfirmationMail::class, function ($mail) { + return $mail->hasTo('mailtest@voorbeeld.nl'); + }); + } + + public function test_email_is_always_stored_lowercase(): void + { + Mail::fake(); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Hoofdletter', + 'last_name' => 'Email', + 'email' => 'HOOFDLETTER@VOORBEELD.NL', + 'password' => 'wachtwoord123', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('persons', [ + 'email' => 'hoofdletter@voorbeeld.nl', + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'hoofdletter@voorbeeld.nl', + ]); + } + + public function test_password_required_for_unauthenticated_registration(): void + { + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Zonder', + 'last_name' => 'Wachtwoord', + 'email' => 'geenww@voorbeeld.nl', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('password'); + } + + public function test_password_not_required_for_authenticated_registration(): void + { + Mail::fake(); + + $user = User::factory()->create([ + 'first_name' => 'Auth', + 'last_name' => 'User', + 'email' => 'authuser@voorbeeld.nl', + ]); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + Sanctum::actingAs($user); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", []); + + $response->assertStatus(201); + } + + // ─── Registration Data Endpoint ───────────────────────────────────── + + public function test_registration_data_includes_registration_fields(): void + { + $field = RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $this->event->id, + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + + $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); + + $response->assertOk(); + $response->assertJsonPath('data.registration_fields.0.slug', $field->slug); + } + + public function test_registration_data_excludes_admin_only_fields(): void + { + RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $this->event->id, + 'is_portal_visible' => true, + 'is_admin_only' => true, + ]); + + $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); + + $response->assertOk(); + $response->assertJsonCount(0, 'data.registration_fields'); + } + + public function test_registration_data_includes_form_toggles(): void + { + $response = $this->getJson("/api/v1/public/events/{$this->event->slug}/registration-data"); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => [ + 'event' => [ + 'registration_show_section_preferences', + 'registration_show_availability', + ], + ], + ]); + } + public function test_portal_me_includes_field_values_and_section_preferences(): void { $user = User::factory()->create(['first_name' => 'Lotte', 'last_name' => 'Vos']);