From cf27320bb7fe8f4e77ab1a6ff95048a02954e313 Mon Sep 17 00:00:00 2001 From: Michael Chu Date: Mon, 9 Mar 2026 15:12:01 -0400 Subject: [PATCH] Auto-add users to group when registering for a group event The database trigger `auto_add_participant_to_group` was dropped in a prior migration and never replaced. This adds application-level logic to automatically add users to the group when they register for a group event via the invite link, using fire-and-forget so failures don't block registration. Co-Authored-By: Claude Opus 4.6 --- .../participantService.autoGroup.test.ts | 202 ++++++++++++++++++ .../__tests__/participantService.test.ts | 9 + src/services/participantService.ts | 12 +- 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/services/__tests__/participantService.autoGroup.test.ts diff --git a/src/services/__tests__/participantService.autoGroup.test.ts b/src/services/__tests__/participantService.autoGroup.test.ts new file mode 100644 index 0000000..a0e61c4 --- /dev/null +++ b/src/services/__tests__/participantService.autoGroup.test.ts @@ -0,0 +1,202 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { createQueryChain } from '@/test/mocks/supabase'; + +// Must provide factory to avoid real module loading (env vars check) +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: vi.fn(), + rpc: vi.fn(), + }, +})); + +import { supabase } from '@/lib/supabase'; + +vi.mock('@/lib/errorHandler', async () => { + const actual = await vi.importActual('@/lib/errorHandler'); + return { + ...actual, + throwIfSupabaseError: vi.fn((result) => { + if (result.error) throw result.error; + return result.data; + }), + requireData: vi.fn((data, operation) => { + if (!data) throw new Error(`No data for ${operation}`); + return data; + }), + fireAndForget: vi.fn((promise) => { + if (promise && typeof promise.catch === 'function') { + promise.catch(() => {}); + } + }), + }; +}); + +vi.mock('../notificationService', () => ({ + notificationService: { + queueNewSignup: vi.fn().mockResolvedValue(undefined), + queueSignupConfirmed: vi.fn().mockResolvedValue(undefined), + queueCapacityReached: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../participantActivityService', () => ({ + participantActivityService: { + logJoined: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../groupService', () => ({ + groupService: { + addUserToGroup: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { participantService } from '../participantService'; +import { groupService } from '../groupService'; + +const mockSupabase = vi.mocked(supabase); +const mockGroupService = vi.mocked(groupService); + +const baseParticipant = { + event_id: 'event-1', + name: 'Test User', + email: 'test@example.com', + phone: null, + notes: null, + user_id: 'user-1', + claimed_by_user_id: null, + responses: {}, + payment_status: 'pending' as const, + payment_marked_at: null, + payment_notes: null, +}; + +const createdParticipant = { + id: 'p1', + ...baseParticipant, + created_at: '2024-01-01T00:00:00Z', +}; + +const groupEvent = { + id: 'event-1', + name: 'Weekly Pickup Game', + organizer_id: 'org-1', + max_participants: null, + group_id: 'group-1', +}; + +const standaloneEvent = { + id: 'event-2', + name: 'One-off Meetup', + organizer_id: 'org-1', + max_participants: null, + group_id: null, +}; + +describe('createParticipant – auto-add to group integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should auto-add authenticated user to group when registering for a group event', async () => { + // 1st from() → insert participant + const insertChain = createQueryChain({ data: createdParticipant, error: null }); + // 2nd from() → getEventInfo (returns group event) + const eventInfoChain = createQueryChain({ data: groupEvent, error: null }); + + mockSupabase.from + .mockReturnValueOnce(insertChain as any) + .mockReturnValueOnce(eventInfoChain as any); + + await participantService.createParticipant(baseParticipant); + + expect(mockGroupService.addUserToGroup).toHaveBeenCalledWith('group-1', 'user-1'); + }); + + it('should NOT auto-add to group when registering for a standalone event', async () => { + const insertChain = createQueryChain({ + data: { ...createdParticipant, event_id: 'event-2' }, + error: null, + }); + const eventInfoChain = createQueryChain({ data: standaloneEvent, error: null }); + + mockSupabase.from + .mockReturnValueOnce(insertChain as any) + .mockReturnValueOnce(eventInfoChain as any); + + await participantService.createParticipant({ ...baseParticipant, event_id: 'event-2' }); + + expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled(); + }); + + it('should NOT auto-add guest registration to group (no user_id)', async () => { + const guestParticipant = { + ...createdParticipant, + user_id: null, + name: 'Guest Player', + }; + + const insertChain = createQueryChain({ data: guestParticipant, error: null }); + const eventInfoChain = createQueryChain({ data: groupEvent, error: null }); + + mockSupabase.from + .mockReturnValueOnce(insertChain as any) + .mockReturnValueOnce(eventInfoChain as any); + + await participantService.createParticipant({ + ...baseParticipant, + user_id: null, + name: 'Guest Player', + }); + + expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled(); + }); + + it('should NOT auto-add claimed spot to group (user_id is null for claimed spots)', async () => { + const claimedParticipant = { + ...createdParticipant, + user_id: null, + claimed_by_user_id: 'user-1', + name: 'Friend of User', + }; + + const insertChain = createQueryChain({ data: claimedParticipant, error: null }); + const eventInfoChain = createQueryChain({ data: groupEvent, error: null }); + + mockSupabase.from + .mockReturnValueOnce(insertChain as any) + .mockReturnValueOnce(eventInfoChain as any); + + await participantService.createParticipant( + { + ...baseParticipant, + user_id: null, + name: 'Friend of User', + }, + { claimingUserId: 'user-1', claimingUserEmail: 'claimer@example.com' } + ); + + // The claimer's user_id is set as claimed_by_user_id, not user_id + // So auto-add should NOT fire for claimed spots + expect(mockGroupService.addUserToGroup).not.toHaveBeenCalled(); + }); + + it('should auto-add user to group when added via batch registration for a group event', async () => { + const insertChain = createQueryChain({ + data: { ...createdParticipant, user_id: 'user-2', name: 'Batch Member' }, + error: null, + }); + const eventInfoChain = createQueryChain({ data: groupEvent, error: null }); + + mockSupabase.from + .mockReturnValueOnce(insertChain as any) + .mockReturnValueOnce(eventInfoChain as any); + + await participantService.createParticipantsBatch('event-1', [ + { name: 'Batch Member', user_id: 'user-2' }, + ]); + + expect(mockGroupService.addUserToGroup).toHaveBeenCalledWith('group-1', 'user-2'); + }); +}); diff --git a/src/services/__tests__/participantService.test.ts b/src/services/__tests__/participantService.test.ts index a9da244..fd313fd 100644 --- a/src/services/__tests__/participantService.test.ts +++ b/src/services/__tests__/participantService.test.ts @@ -45,6 +45,13 @@ vi.mock('@/lib/errorHandler', () => ({ }), })); +// Mock groupService for auto-add to group +vi.mock('../groupService', () => ({ + groupService: { + addUserToGroup: vi.fn().mockResolvedValue(undefined), + }, +})); + // Mock participantActivityService to prevent unhandled rejections from activity logging vi.mock('../participantActivityService', () => ({ participantActivityService: { @@ -174,6 +181,8 @@ describe('participantService', () => { }); }); + // Auto-add to group tests are in participantService.autoGroup.test.ts + describe('updateParticipant - data transformation', () => { it('should convert empty strings to null for optional fields', async () => { const updates = { diff --git a/src/services/participantService.ts b/src/services/participantService.ts index be77a3b..691d1f7 100644 --- a/src/services/participantService.ts +++ b/src/services/participantService.ts @@ -10,6 +10,7 @@ import type { import { throwIfSupabaseError, requireData, fireAndForget } from '@/lib/errorHandler'; import { notificationService } from './notificationService'; import { participantActivityService } from './participantActivityService'; +import { groupService } from './groupService'; /** Extended Participant type with labels and computed properties */ export interface Participant extends Omit, 'responses' | 'created_at'> { @@ -53,10 +54,11 @@ async function getEventInfo(eventId: string): Promise<{ name: string; organizer_id: string; max_participants: number | null; + group_id: string | null; } | null> { const { data } = await supabase .from('events') - .select('id, name, organizer_id, max_participants') + .select('id, name, organizer_id, max_participants, group_id') .eq('id', eventId) .single(); return data; @@ -253,6 +255,14 @@ export const participantService = { ); } } + + // Auto-add user to group when registering for a group event + if (eventInfo.group_id && created.user_id) { + fireAndForget( + groupService.addUserToGroup(eventInfo.group_id, created.user_id), + 'auto-add participant to group' + ); + } } return created;