diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0e3effe..7e00a7f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -105,13 +105,15 @@ export function getUserDisplayName(user: User | null, fallback = 'User'): string export function canUserClaimSpot({ hasUser, isOrganizer, + isRegistered, isFirstEmptySlot, showGuestRegistration, }: { hasUser: boolean; isOrganizer: boolean; + isRegistered: boolean; isFirstEmptySlot: boolean; showGuestRegistration: boolean; }): boolean { - return hasUser && isOrganizer && isFirstEmptySlot && showGuestRegistration; + return hasUser && (isOrganizer || isRegistered) && isFirstEmptySlot && showGuestRegistration; } diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 0cc0a32..571e5fc 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -971,6 +971,7 @@ export function EventDetailPage() { canUserClaimSpot({ hasUser: !!user, isOrganizer, + isRegistered: !!userRegistration, isFirstEmptySlot, showGuestRegistration, }); diff --git a/src/pages/__tests__/EventDetailPage.test.tsx b/src/pages/__tests__/EventDetailPage.test.tsx index cdd8aba..f5dd3ef 100644 --- a/src/pages/__tests__/EventDetailPage.test.tsx +++ b/src/pages/__tests__/EventDetailPage.test.tsx @@ -173,7 +173,43 @@ describe('EventDetailPage - Claim button visibility', () => { expect(screen.getAllByRole('button', { name: /claim/i }).length).toBeGreaterThan(0); }); - it('hides Claim button when user is not the event organizer', async () => { + it('shows Claim button when non-organizer user is registered for the event', async () => { + mockUseAuth.mockReturnValue({ + user: { id: 'user-456' } as User, + session: null, + loading: false, + signIn: vi.fn(), + signUp: vi.fn(), + signInWithGoogle: vi.fn(), + signInWithGoogleIdToken: vi.fn(), + signOut: vi.fn(), + isAdmin: false, + isImpersonating: false, + impersonate: vi.fn(), + stopImpersonating: vi.fn(), + resetPasswordForEmail: vi.fn(), + updatePassword: vi.fn(), + }); + mockParticipantService.getParticipantsByEventId.mockResolvedValue([ + { + id: 'p-1', + name: 'User', + user_id: 'user-456', + event_id: 'test-event-id', + payment_status: 'pending', + }, + ] as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + render(); + + await waitFor(() => { + expect(screen.getByText('Test Event')).toBeInTheDocument(); + }); + + expect(screen.getAllByRole('button', { name: /claim/i }).length).toBeGreaterThan(0); + }); + + it('hides Claim button when user is neither organizer nor registered', async () => { mockUseAuth.mockReturnValue({ user: { id: 'user-456' } as User, session: null, diff --git a/src/pages/__tests__/canUserClaimSpot.test.ts b/src/pages/__tests__/canUserClaimSpot.test.ts index b0bd2e4..8956ef0 100644 --- a/src/pages/__tests__/canUserClaimSpot.test.ts +++ b/src/pages/__tests__/canUserClaimSpot.test.ts @@ -4,21 +4,26 @@ import { canUserClaimSpot } from '@/lib/utils'; describe('canUserClaimSpot', () => { const baseArgs = { hasUser: true, - isOrganizer: true, + isOrganizer: false, + isRegistered: true, isFirstEmptySlot: true, showGuestRegistration: true, }; - it('returns true when organizer views first empty slot with guest registration enabled', () => { + it('returns true when registered user views first empty slot with guest registration enabled', () => { expect(canUserClaimSpot(baseArgs)).toBe(true); }); + it('returns true when organizer is not registered but views first empty slot', () => { + expect(canUserClaimSpot({ ...baseArgs, isOrganizer: true, isRegistered: false })).toBe(true); + }); + it('returns false when user is not logged in', () => { expect(canUserClaimSpot({ ...baseArgs, hasUser: false })).toBe(false); }); - it('returns false when user is not the organizer', () => { - expect(canUserClaimSpot({ ...baseArgs, isOrganizer: false })).toBe(false); + it('returns false when user is neither organizer nor registered', () => { + expect(canUserClaimSpot({ ...baseArgs, isOrganizer: false, isRegistered: false })).toBe(false); }); it('returns false for non-first empty slots', () => { diff --git a/supabase/migrations/20260310000000_allow_participants_to_claim_spots.sql b/supabase/migrations/20260310000000_allow_participants_to_claim_spots.sql new file mode 100644 index 0000000..e58b22b --- /dev/null +++ b/supabase/migrations/20260310000000_allow_participants_to_claim_spots.sql @@ -0,0 +1,24 @@ +-- Allow any registered participant (not just organizers) to claim spots for guests +DROP POLICY "Event organizers can claim spots for others" ON "public"."participants"; + +CREATE POLICY "Registered participants can claim spots for others" +ON "public"."participants" +FOR INSERT +WITH CHECK ( + claimed_by_user_id = auth.uid() + AND ( + -- Event organizer can always claim + EXISTS ( + SELECT 1 FROM events + WHERE events.id = participants.event_id + AND events.organizer_id = auth.uid() + ) + OR + -- Any registered participant can claim + EXISTS ( + SELECT 1 FROM participants AS p + WHERE p.event_id = participants.event_id + AND p.user_id = auth.uid() + ) + ) +); diff --git a/tests/e2e/specs/participants.spec.ts b/tests/e2e/specs/participants.spec.ts index 7cfa3ad..a95bec4 100644 --- a/tests/e2e/specs/participants.spec.ts +++ b/tests/e2e/specs/participants.spec.ts @@ -5,6 +5,7 @@ import { generateTestName, createTestEvent, getAdminDb, + enableFeatureFlagForUser, } from '../fixtures/database'; import { registerForEvent, @@ -173,34 +174,34 @@ test.describe('Participant Registration Flow', () => { }); }); - // Skip Claiming Additional Spots tests - guest_registration feature flag is disabled - test.describe.skip('Claiming Additional Spots', () => { - test('user can claim additional spot for guest', async ({ page }) => { - // Setup + test.describe('Claiming Additional Spots', () => { + test('organizer can claim additional spot for guest', async ({ page }) => { await register(page, { email: generateTestEmail('claimer'), password: 'TestPassword123!', }); - const organizerId = await getUserId(page); + await enableFeatureFlagForUser(organizerId!, 'guest_registration'); + const event = await createTestEvent(organizerId!, { name: generateTestName('Multi-Spot Event'), - max_participants: 10, // Need max_participants for claim button to appear + max_participants: 10, }); - // Register self first - await registerForEvent(page, event.id, { - name: 'Main Registrant', + // Register organizer as participant directly in DB + await getAdminDb().from('participants').insert({ + event_id: event.id, + name: 'Organizer', + user_id: organizerId!, }); - // Claim additional spot + // Claim additional spot via UI await claimAdditionalSpot(page, event.id, { name: 'Guest 1', }); // Verify both registrations exist - const userId = await getUserId(page); const { data: participants } = await getAdminDb() .from('participants') .select('*') @@ -208,91 +209,63 @@ test.describe('Participant Registration Flow', () => { expect(participants?.length).toBeGreaterThanOrEqual(2); - // One should have user_id, one should have claimed_by_user_id - const selfRegistration = participants?.find((p) => p.user_id === userId); - const claimedSpot = participants?.find((p) => p.claimed_by_user_id === userId && p.user_id === null); + const selfRegistration = participants?.find((p) => p.user_id === organizerId); + const claimedSpot = participants?.find( + (p) => p.claimed_by_user_id === organizerId && p.user_id === null + ); expect(selfRegistration).toBeDefined(); expect(claimedSpot).toBeDefined(); }); - test('claimed spots get auto-generated names', async ({ page }) => { - // Setup + test('registered non-organizer can claim spot for guest', async ({ page }) => { + // Create organizer and event await register(page, { - email: generateTestEmail('namegen'), + email: generateTestEmail('claimorg'), password: 'TestPassword123!', }); - const organizerId = await getUserId(page); const event = await createTestEvent(organizerId!, { - name: generateTestName('Name Generation Event'), - max_participants: 10, // Need max_participants for claim button to appear + name: generateTestName('Non-Org Claim Event'), + max_participants: 10, }); - // Register self - await registerForEvent(page, event.id, { - name: 'John Doe', - }); - - // Claim spot without providing name - await claimAdditionalSpot(page, event.id, {}); - - // Check if auto-generated name follows pattern - const { data: participants } = await getAdminDb() - .from('participants') - .select('*') - .eq('event_id', event.id); - - const claimedSpot = participants?.find((p) => p.claimed_by_user_id !== null && p.user_id === null); - - if (claimedSpot) { - // Auto-generated names should follow pattern like "John Doe - 1" - expect(claimedSpot.name).toMatch(/.*\s*-\s*\d+/); - } - }); + await clearAuth(page); - test('participants are ordered by registration time', async ({ page }) => { - test.setTimeout(60000); // Increase timeout for multiple claims - // Setup + // Create participant user and enable the flag for them await register(page, { - email: generateTestEmail('slots'), + email: generateTestEmail('claimpart'), password: 'TestPassword123!', }); + const participantId = await getUserId(page); + await enableFeatureFlagForUser(participantId!, 'guest_registration'); - const organizerId = await getUserId(page); - const event = await createTestEvent(organizerId!, { - name: generateTestName('Registration Order Event'), - max_participants: 10, // Need max_participants for claim button to appear + // Register participant directly in DB + await getAdminDb().from('participants').insert({ + event_id: event.id, + name: 'Participant', + user_id: participantId!, }); - // Register self - await registerForEvent(page, event.id); - - // Claim two additional spots - await claimAdditionalSpot(page, event.id); - - // Wait a moment between claims to ensure first claim is fully processed - await page.waitForTimeout(2000); - - await claimAdditionalSpot(page, event.id); + // Claim a spot for a guest via UI + await claimAdditionalSpot(page, event.id, { + name: 'My Guest', + }); - // Verify participants are created and ordered by created_at + // Verify the claimed spot exists in the database const { data: participants } = await getAdminDb() .from('participants') .select('*') - .eq('event_id', event.id) - .order('created_at', { ascending: true }); + .eq('event_id', event.id); - expect(participants?.length).toBe(3); + const claimedSpot = participants?.find( + (p) => p.claimed_by_user_id === participantId && p.user_id === null + ); - // Participants should be ordered by creation time - if (participants && participants.length === 3) { - const times = participants.map((p) => new Date(p.created_at).getTime()); - expect(times[0]).toBeLessThanOrEqual(times[1]); - expect(times[1]).toBeLessThanOrEqual(times[2]); - } + expect(claimedSpot).toBeDefined(); + expect(claimedSpot?.name).toBe('My Guest'); }); });