diff --git a/src/app/api/referrals/route.dedupe.test.ts b/src/app/api/referrals/route.dedupe.test.ts index 8d32d4c5..05323f34 100644 --- a/src/app/api/referrals/route.dedupe.test.ts +++ b/src/app/api/referrals/route.dedupe.test.ts @@ -144,4 +144,113 @@ describe("POST /api/referrals duplicate invite handling", () => { }, ]); }); + + it("only counts new recipients toward the hourly invite limit", async () => { + const existingInviteLookup = vi.fn().mockResolvedValue({ + data: [{ referred_email: "existing@test.com" }], + error: null, + }); + mocks.mockCreateServiceClient.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + gte: vi.fn().mockResolvedValue({ count: 9, error: null }), + in: existingInviteLookup, + })), + })), + })), + }); + + const insertReferrals = vi.fn().mockReturnValue({ + select: vi.fn().mockResolvedValue({ + data: [{ id: "ref1", referred_email: "new@test.com", status: "pending" }], + error: null, + }), + }); + const authSupabase = { + from: vi.fn((table: string) => { + if (table === "profiles") { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn().mockResolvedValue({ + data: { + referral_code: "testuser", + username: "testuser", + full_name: "Test User", + }, + error: null, + }), + })), + })), + }; + } + + if (table === "referrals") { + return { insert: insertReferrals }; + } + + return {}; + }), + }; + mocks.mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: authSupabase, + }); + + const res = await POST( + makePostRequest({ emails: ["existing@test.com", "new@test.com"] }) + ); + + expect(res.status).toBe(200); + expect(existingInviteLookup).toHaveBeenCalledWith( + "referred_email", + ["existing@test.com", "new@test.com"] + ); + expect(mocks.mockSendEmail).toHaveBeenCalledTimes(1); + expect(mocks.mockSendEmail).toHaveBeenCalledWith({ + to: "new@test.com", + subject: "Join ugig.net", + html: "

Join

", + text: "Join", + }); + expect(insertReferrals).toHaveBeenCalledWith([ + { + referrer_id: "user1", + referred_email: "new@test.com", + referral_code: "testuser", + status: "pending", + }, + ]); + }); + + it("does not send invites when the existing-recipient lookup fails", async () => { + const existingInviteLookup = vi.fn().mockResolvedValue({ + data: null, + error: { message: "database unavailable" }, + }); + mocks.mockCreateServiceClient.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + in: existingInviteLookup, + })), + })), + })), + }); + mocks.mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: { from: vi.fn() }, + }); + + const res = await POST( + makePostRequest({ emails: ["friend@test.com"] }) + ); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ + error: "Failed to check existing invitations", + }); + expect(mocks.mockSendEmail).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index 6a27788a..d8545141 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -100,9 +100,34 @@ export async function POST(request: NextRequest) { ); } - // Spam throttling: max 50 invites per day, max 10 per hour - // Only count valid emails toward rate limits (#143) const svc = createServiceClient(); + + // Prevent duplicate invites to the same email before calculating quota usage. + const { data: existingInvites, error: existingInvitesError } = await (svc as AnySupabase) + .from("referrals") + .select("referred_email") + .eq("referrer_id", user.id) + .in("referred_email", validEmails); + + if (existingInvitesError) { + return NextResponse.json( + { error: "Failed to check existing invitations" }, + { status: 500 } + ); + } + + const alreadyInvited = new Set((existingInvites || []).map((r: any) => r.referred_email)); + const newValidEmails = validEmails.filter((e: string) => !alreadyInvited.has(e)); + + if (newValidEmails.length === 0) { + return NextResponse.json( + { error: "All these emails have already been invited" }, + { status: 400 } + ); + } + + // Spam throttling: max 50 invites per day, max 10 per hour. + // Existing recipients do not consume another invite slot. const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); @@ -112,7 +137,7 @@ export async function POST(request: NextRequest) { .eq("referrer_id", user.id) .gte("created_at", oneHourAgo); - if ((hourlyCount ?? 0) + validEmails.length > 10) { + if ((hourlyCount ?? 0) + newValidEmails.length > 10) { return NextResponse.json( { error: "Too many invites. Max 10 per hour." }, { status: 429 } @@ -125,22 +150,13 @@ export async function POST(request: NextRequest) { .eq("referrer_id", user.id) .gte("created_at", oneDayAgo); - if ((dailyCount ?? 0) + validEmails.length > 50) { + if ((dailyCount ?? 0) + newValidEmails.length > 50) { return NextResponse.json( { error: "Daily invite limit reached. Max 50 per day." }, { status: 429 } ); } - // Prevent duplicate invites to same email - const { data: existingInvites } = await (svc as AnySupabase) - .from("referrals") - .select("referred_email") - .eq("referrer_id", user.id) - .in("referred_email", validEmails); - - const alreadyInvited = new Set((existingInvites || []).map((r: any) => r.referred_email)); - // Get user's referral code const { data: profile } = await (supabase as any) .from("profiles") @@ -155,16 +171,6 @@ export async function POST(request: NextRequest) { const referralCode = profile.referral_code || profile.username; const inviterName = profile.full_name || profile.username || "Someone"; - // Filter valid emails that aren't already invited (#143) - const newValidEmails = validEmails.filter((e: string) => !alreadyInvited.has(e)); - - if (newValidEmails.length === 0) { - return NextResponse.json( - { error: "All these emails have already been invited" }, - { status: 400 } - ); - } - // Send emails BEFORE inserting into DB to avoid partial-state issues const emailContent = referralInviteEmail({ inviterName, referralCode }); const emailResults = await Promise.all(