From ebaf539a6a2fc7d8125b803bcdd6e69f5ff997af Mon Sep 17 00:00:00 2001 From: lazyGPT07 Date: Fri, 12 Jun 2026 22:36:10 -0600 Subject: [PATCH 1/2] fix(referrals): count only new invites toward limits --- src/app/api/referrals/route.dedupe.test.ts | 79 ++++++++++++++++++++++ src/app/api/referrals/route.ts | 45 ++++++------ 2 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/app/api/referrals/route.dedupe.test.ts b/src/app/api/referrals/route.dedupe.test.ts index 8d32d4c5..4c097348 100644 --- a/src/app/api/referrals/route.dedupe.test.ts +++ b/src/app/api/referrals/route.dedupe.test.ts @@ -144,4 +144,83 @@ 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", + }, + ]); + }); }); diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index 6a27788a..98ff1dea 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -100,9 +100,27 @@ 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 } = 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)); + 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 +130,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 +143,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 +164,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( From 96b431e840fc9d4d91442204d229e260b799c113 Mon Sep 17 00:00:00 2001 From: lazyGPT07 Date: Sat, 13 Jun 2026 03:07:52 -0600 Subject: [PATCH 2/2] fix(referrals): fail closed on dedupe lookup errors --- src/app/api/referrals/route.dedupe.test.ts | 30 ++++++++++++++++++++++ src/app/api/referrals/route.ts | 9 ++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/api/referrals/route.dedupe.test.ts b/src/app/api/referrals/route.dedupe.test.ts index 4c097348..05323f34 100644 --- a/src/app/api/referrals/route.dedupe.test.ts +++ b/src/app/api/referrals/route.dedupe.test.ts @@ -223,4 +223,34 @@ describe("POST /api/referrals duplicate invite handling", () => { }, ]); }); + + 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 98ff1dea..d8545141 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -103,12 +103,19 @@ export async function POST(request: NextRequest) { const svc = createServiceClient(); // Prevent duplicate invites to the same email before calculating quota usage. - const { data: existingInvites } = await (svc as AnySupabase) + 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));