diff --git a/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php b/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php new file mode 100644 index 00000000..c9578375 --- /dev/null +++ b/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php @@ -0,0 +1,292 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->volunteerCrowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + + // Create org admin for approval + $this->orgAdmin = User::factory()->create(); + $this->orgAdmin->assignRole('super_admin'); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + /** + * The golden path: register → approve → portal/me works. + */ + public function test_full_flow_register_approve_portal_me(): void + { + Mail::fake(); + + // ── Step 1: Volunteer registers via public form ── + $regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Vrijwilliger', + 'last_name' => 'Test', + 'email' => 'vrijwilliger@test.nl', + 'password' => 'Wachtwoord1', + ]); + + $regResponse->assertStatus(201); + + // Verify person was created with user_id + $person = Person::where('email', 'vrijwilliger@test.nl')->first(); + $this->assertNotNull($person, 'Person record should exist'); + $this->assertNotNull($person->user_id, 'Person should have user_id after registration'); + $this->assertEquals('pending', $person->status); + + $user = User::where('email', 'vrijwilliger@test.nl')->first(); + $this->assertNotNull($user, 'User account should exist'); + $this->assertEquals($user->id, $person->user_id, 'Person.user_id should match created user'); + + // ── Step 2: Organizer approves the volunteer ─��� + Sanctum::actingAs($this->orgAdmin); + + $approveResponse = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve" + ); + + $approveResponse->assertOk(); + + // Verify person is now approved + $person->refresh(); + $this->assertEquals('approved', $person->status); + $this->assertNotNull($person->user_id, 'user_id should still be set after approval'); + + // ── Step 3: Volunteer logs into portal ── + Sanctum::actingAs($user); + + // Step 3a: GET /auth/me should return portal_events + $meResponse = $this->getJson('/api/v1/auth/me'); + + $meResponse->assertOk(); + $meResponse->assertJsonCount(1, 'data.portal_events'); + $meResponse->assertJsonPath('data.portal_events.0.event_id', $this->event->id); + $meResponse->assertJsonPath('data.portal_events.0.person_status', 'approved'); + + // Step 3b: GET /portal/me should return person details + $portalMeResponse = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}"); + + $portalMeResponse->assertOk(); + $portalMeResponse->assertJsonPath('data.email', 'vrijwilliger@test.nl'); + $portalMeResponse->assertJsonPath('data.status', 'approved'); + } + + /** + * Festival hierarchy: register via sub-event slug → portal/me with parent event_id. + */ + public function test_full_flow_with_festival_sub_event(): void + { + Mail::fake(); + + // Create festival hierarchy + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + $subEvent = Event::factory()->subEvent($festival)->create([ + 'status' => 'registration_open', + ]); + + // ── Step 1: Register via sub-event route ── + $regResponse = $this->postJson("/api/v1/events/{$subEvent->id}/volunteer-register", [ + 'first_name' => 'Festival', + 'last_name' => 'Ganger', + 'email' => 'festival@test.nl', + 'password' => 'Wachtwoord1', + ]); + + $regResponse->assertStatus(201); + + // Person should be linked to parent (festival), not sub-event + $person = Person::where('email', 'festival@test.nl')->first(); + $this->assertNotNull($person); + $this->assertEquals($festival->id, $person->event_id, 'Person should be linked to parent event'); + $this->assertNotNull($person->user_id); + + $user = User::where('email', 'festival@test.nl')->first(); + + // ── Step 2: Approve ── + Sanctum::actingAs($this->orgAdmin); + $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/persons/{$person->id}/approve" + )->assertOk(); + + // ── Step 3: Portal access ── + Sanctum::actingAs($user); + + // portal/me with parent event ID should work + $this->getJson("/api/v1/portal/me?event_id={$festival->id}") + ->assertOk() + ->assertJsonPath('data.email', 'festival@test.nl'); + + // portal/me with sub-event ID should also work (controller resolves to parent) + $this->getJson("/api/v1/portal/me?event_id={$subEvent->id}") + ->assertOk() + ->assertJsonPath('data.email', 'festival@test.nl'); + } + + /** + * Returning volunteer: existing user registers for new event. + */ + public function test_returning_volunteer_portal_access(): void + { + Mail::fake(); + + $existingUser = User::factory()->create([ + 'first_name' => 'Bestaand', + 'last_name' => 'Lid', + 'email' => 'bestaand@test.nl', + 'password' => \Illuminate\Support\Facades\Hash::make('BestaandWw1'), + ]); + + // Register for event with existing account + $regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Bestaand', + 'last_name' => 'Lid', + 'email' => 'bestaand@test.nl', + 'password' => 'BestaandWw1', + ]); + + $regResponse->assertStatus(201); + + $person = Person::where('email', 'bestaand@test.nl')->first(); + $this->assertEquals($existingUser->id, $person->user_id); + + // Approve + Sanctum::actingAs($this->orgAdmin); + $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve" + )->assertOk(); + + // Portal access + Sanctum::actingAs($existingUser); + + $this->getJson('/api/v1/auth/me') + ->assertOk() + ->assertJsonCount(1, 'data.portal_events'); + + $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") + ->assertOk() + ->assertJsonPath('data.status', 'approved'); + } + + /** + * Organizer-created person (no user account) → identity match confirmed → portal/me. + */ + public function test_organizer_created_person_then_identity_linked(): void + { + // ── Step 1: Organizer creates a person manually ── + $user = User::factory()->create([ + 'first_name' => 'Handmatig', + 'last_name' => 'Toegevoegd', + 'email' => 'handmatig@test.nl', + ]); + $this->organisation->users()->attach($user, ['role' => 'org_member']); + + Sanctum::actingAs($this->orgAdmin); + + $storeResponse = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons", + [ + 'crowd_type_id' => $this->volunteerCrowdType->id, + 'first_name' => 'Handmatig', + 'last_name' => 'Toegevoegd', + 'email' => 'handmatig@test.nl', + 'status' => 'approved', + ] + ); + + $storeResponse->assertStatus(201); + + $person = Person::where('email', 'handmatig@test.nl')->first(); + $this->assertNotNull($person); + $this->assertNull($person->user_id, 'Organizer-created person should not have user_id'); + + // ── Step 2: Portal access should fail (no user_id link) ── + Sanctum::actingAs($user); + + $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") + ->assertStatus(404); + + // ── Step 3: Confirm identity match → links user_id ── + $match = $person->pendingIdentityMatch; + $this->assertNotNull($match, 'Identity match should have been auto-detected'); + + Sanctum::actingAs($this->orgAdmin); + + $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" + )->assertOk(); + + $person->refresh(); + $this->assertEquals($user->id, $person->user_id, 'user_id should be set after identity confirm'); + + // ── Step 4: Portal access should now work ── + Sanctum::actingAs($user); + + $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") + ->assertOk() + ->assertJsonPath('data.email', 'handmatig@test.nl'); + } + + /** + * Pending volunteer can see their status via portal (before approval). + */ + public function test_pending_volunteer_can_access_portal_me(): void + { + Mail::fake(); + + $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [ + 'first_name' => 'Wachtend', + 'last_name' => 'Vrijwilliger', + 'email' => 'wachtend@test.nl', + 'password' => 'Wachtwoord1', + ])->assertStatus(201); + + $user = User::where('email', 'wachtend@test.nl')->first(); + Sanctum::actingAs($user); + + // Even without approval, portal/me should work (just shows pending status) + $this->getJson("/api/v1/portal/me?event_id={$this->event->id}") + ->assertOk() + ->assertJsonPath('data.status', 'pending'); + } +} diff --git a/apps/portal/src/stores/usePortalStore.ts b/apps/portal/src/stores/usePortalStore.ts index baf8c211..daa717f4 100644 --- a/apps/portal/src/stores/usePortalStore.ts +++ b/apps/portal/src/stores/usePortalStore.ts @@ -46,16 +46,42 @@ function writeStoredActiveEventId(id: string | null): void { else localStorage.removeItem(STORAGE_ACTIVE_EVENT) } -function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[]): PortalEvent[] { +/** + * Merge API events with locally stored events. + * + * When the API call succeeded (`apiSucceeded = true`), the API is the source + * of truth: stored events that are NOT confirmed by the API are dropped. + * This prevents stale localStorage entries from showing events the user no + * longer has access to (e.g. after user_id was cleared). + * + * When the API call failed (`apiSucceeded = false`), we fall back to stored + * events as a best-effort cache. + */ +function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[], apiSucceeded: boolean): PortalEvent[] { const map = new Map() - for (const e of stored) map.set(e.event_id, { ...e }) - for (const e of apiEvents) { - const prev = map.get(e.event_id) - map.set(e.event_id, { - ...prev, - ...e, - organisation_name: e.organisation_name || prev?.organisation_name || '', - }) + + if (apiSucceeded) { + // API is source of truth — start with API events only + for (const e of apiEvents) { + const prev = stored.find(s => s.event_id === e.event_id) + map.set(e.event_id, { + ...prev, + ...e, + organisation_name: e.organisation_name || prev?.organisation_name || '', + }) + } + } + else { + // API failed — merge stored + whatever API returned (likely empty) + for (const e of stored) map.set(e.event_id, { ...e }) + for (const e of apiEvents) { + const prev = map.get(e.event_id) + map.set(e.event_id, { + ...prev, + ...e, + organisation_name: e.organisation_name || prev?.organisation_name || '', + }) + } } return Array.from(map.values()).sort((a, b) => b.start_date.localeCompare(a.start_date)) @@ -84,7 +110,7 @@ export const usePortalStore = defineStore('portal', () => { * TODO: replace with `portal_events` from GET /auth/me when the API exposes it. */ function savePendingEventFromRegistration(event: PortalEvent): void { - const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event]) + const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event], false) userEvents.value = merged persistEvents() if (!activeEventId.value || !merged.some(e => e.event_id === activeEventId.value)) { @@ -99,14 +125,16 @@ export const usePortalStore = defineStore('portal', () => { try { const stored = readStoredEvents() let apiEvents: PortalEvent[] = [] + let apiSucceeded = false try { const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me') apiEvents = data.data.portal_events ?? [] + apiSucceeded = true } catch { // /auth/me failed — still show locally stored registrations } - userEvents.value = mergeEvents(apiEvents, stored) + userEvents.value = mergeEvents(apiEvents, stored, apiSucceeded) persistEvents() } catch (e) { @@ -154,7 +182,10 @@ export const usePortalStore = defineStore('portal', () => { ) persistEvents() } - catch { + catch (err) { + if (import.meta.env.DEV) { + console.warn('[portal] fetchCurrentPerson failed for event_id:', eid, err) + } currentPerson.value = null } finally {