From 812711385defe24916952c09578ecb9e60774c8c Mon Sep 17 00:00:00 2001 From: sevencat2004 <187867736+sevencat2004@users.noreply.github.com> Date: Thu, 28 May 2026 18:06:17 +0800 Subject: [PATCH 1/2] fix(referrals): normalize invite email dedupe --- src/app/api/referrals/route.test.ts | 70 +++++++++++++++++++++++++++-- src/app/api/referrals/route.ts | 17 ++++--- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/app/api/referrals/route.test.ts b/src/app/api/referrals/route.test.ts index 177ff375..5a2e85f2 100644 --- a/src/app/api/referrals/route.test.ts +++ b/src/app/api/referrals/route.test.ts @@ -34,7 +34,7 @@ const mockSupabase = { })), }; -function makeServiceClientWithNoExistingReferrals() { +function makeServiceClientWithNoExistingReferrals(existingInvites: { referred_email: string }[] = []) { let referralsQueryCount = 0; return { @@ -46,9 +46,9 @@ function makeServiceClientWithNoExistingReferrals() { if (referralsQueryCount <= 2) { return Promise.resolve({ count: 0, error: null }); } - return Promise.resolve({ data: [], error: null }); + return Promise.resolve({ data: existingInvites, error: null }); }), - in: vi.fn().mockResolvedValue({ data: [], error: null }), + in: vi.fn().mockResolvedValue({ data: existingInvites, error: null }), }), }), })), @@ -114,6 +114,12 @@ describe("POST /api/referrals", () => { beforeEach(() => { vi.clearAllMocks(); mockCreateServiceClient.mockReturnValue(makeServiceClientWithNoExistingReferrals()); + mockReferralInviteEmail.mockReturnValue({ + subject: "Join ugig.net", + html: "
Join
", + text: "Join", + }); + mockSendEmail.mockResolvedValue({ success: true }); }); it("should return 401 when not authenticated", async () => { @@ -191,6 +197,64 @@ describe("POST /api/referrals", () => { }); }); + it("normalizes invite emails before duplicate checks, inserts, and sends", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user1" }, + supabase: mockSupabase, + }); + mockCreateServiceClient.mockReturnValue( + makeServiceClientWithNoExistingReferrals([ + { referred_email: "existing@test.com" }, + ]) + ); + + const mockSelectChain = { + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { referral_code: "testuser", username: "testuser", full_name: "Test User" }, + error: null, + }), + }), + }; + const mockInsert = vi.fn().mockReturnValue({ + select: vi.fn().mockResolvedValue({ + data: [{ id: "ref1", referred_email: "new@test.com", status: "pending" }], + error: null, + }), + }); + + mockSupabase.from.mockImplementation((table: string) => { + if (table === "profiles") return { select: () => mockSelectChain }; + if (table === "referrals") return { insert: mockInsert }; + return {}; + }); + + const res = await POST(makePostRequest({ + emails: [ + " Existing@Test.com ", + "NEW@Test.com", + " new@test.com ", + ], + })); + + expect(res.status).toBe(200); + expect(mockInsert).toHaveBeenCalledWith([ + { + referrer_id: "user1", + referred_email: "new@test.com", + referral_code: "testuser", + status: "pending", + }, + ]); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledWith({ + to: "new@test.com", + subject: "Join ugig.net", + html: "Join
", + text: "Join", + }); + }); + it("should keep created invites when email delivery fails", async () => { mockGetAuthContext.mockResolvedValue({ user: { id: "user1" }, diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index 865997f7..79d8e842 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -79,7 +79,13 @@ export async function POST(request: NextRequest) { // Validate email syntax BEFORE rate-limit checks (#143) // Only valid emails should count toward throttle limits const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const validEmails = emails.filter((e: string) => typeof e === "string" && emailRegex.test(e.trim().toLowerCase())); + const validEmails = Array.from( + new Set( + emails + .map((e: string) => e.trim().toLowerCase()) + .filter((email: string) => emailRegex.test(email)) + ) + ); if (validEmails.length === 0) { return NextResponse.json( @@ -121,14 +127,15 @@ export async function POST(request: NextRequest) { } // Prevent duplicate invites to same email - const normalizedEmails = emails.map((e: string) => e.trim().toLowerCase()); const { data: existingInvites } = await (svc as AnySupabase) .from("referrals") .select("referred_email") .eq("referrer_id", user.id) - .in("referred_email", normalizedEmails); + .in("referred_email", validEmails); - const alreadyInvited = new Set((existingInvites || []).map((r: any) => r.referred_email)); + const alreadyInvited = new Set( + (existingInvites || []).map((r: any) => String(r.referred_email).trim().toLowerCase()) + ); // Get user's referral code const { data: profile } = await (supabase as any) @@ -145,7 +152,7 @@ export async function POST(request: NextRequest) { 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)); + const newValidEmails = validEmails.filter((email: string) => !alreadyInvited.has(email)); if (newValidEmails.length === 0) { return NextResponse.json( From 1a6a082f806d0fd32620e12e6bb5a1be18e562c2 Mon Sep 17 00:00:00 2001 From: sevencat2004 <11336110@qq.com> Date: Thu, 28 May 2026 19:43:08 +0800 Subject: [PATCH 2/2] chore(referrals): remove redundant invite normalization --- src/app/api/referrals/route.ts | 354 ++++++++++++++++----------------- 1 file changed, 177 insertions(+), 177 deletions(-) diff --git a/src/app/api/referrals/route.ts b/src/app/api/referrals/route.ts index 79d8e842..33aa5926 100644 --- a/src/app/api/referrals/route.ts +++ b/src/app/api/referrals/route.ts @@ -1,83 +1,83 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthContext } from "@/lib/auth/get-user"; -import { referralInviteEmail, sendEmail } from "@/lib/email"; -import { createServiceClient } from "@/lib/supabase/service"; - -type AnySupabase = any; - -// GET /api/referrals - List my referrals -export async function GET(request: NextRequest) { - try { - const auth = await getAuthContext(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { user, supabase } = auth; - - const { data: referrals, error } = await (supabase as AnySupabase) - .from("referrals") - .select("*") - .eq("referrer_id", user.id) - .order("created_at", { ascending: false }); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - const total = referrals?.length || 0; - const registered = referrals?.filter((r: any) => r.status !== "pending").length || 0; - - return NextResponse.json({ - data: referrals, - stats: { - total_invited: total, - total_registered: registered, - conversion_rate: total > 0 ? Math.round((registered / total) * 100) : 0, - }, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} - -// POST /api/referrals - Send invites -export async function POST(request: NextRequest) { - try { - const auth = await getAuthContext(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const { user, supabase } = auth; - - const body = await request.json(); - const { emails } = body; - - if (!emails || !Array.isArray(emails) || emails.length === 0) { - return NextResponse.json( - { error: "Please provide an array of emails" }, - { status: 400 } - ); - } - - if (!emails.every((email: unknown) => typeof email === "string")) { - return NextResponse.json( - { error: "All email entries must be strings" }, - { status: 400 } - ); - } - - if (emails.length > 20) { - return NextResponse.json( - { error: "Maximum 20 invites at a time" }, - { status: 400 } - ); - } - - // Validate email syntax BEFORE rate-limit checks (#143) - // Only valid emails should count toward throttle limits +import { NextRequest, NextResponse } from "next/server"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { referralInviteEmail, sendEmail } from "@/lib/email"; +import { createServiceClient } from "@/lib/supabase/service"; + +type AnySupabase = any; + +// GET /api/referrals - List my referrals +export async function GET(request: NextRequest) { + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + const { data: referrals, error } = await (supabase as AnySupabase) + .from("referrals") + .select("*") + .eq("referrer_id", user.id) + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + const total = referrals?.length || 0; + const registered = referrals?.filter((r: any) => r.status !== "pending").length || 0; + + return NextResponse.json({ + data: referrals, + stats: { + total_invited: total, + total_registered: registered, + conversion_rate: total > 0 ? Math.round((registered / total) * 100) : 0, + }, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +} + +// POST /api/referrals - Send invites +export async function POST(request: NextRequest) { + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + const body = await request.json(); + const { emails } = body; + + if (!emails || !Array.isArray(emails) || emails.length === 0) { + return NextResponse.json( + { error: "Please provide an array of emails" }, + { status: 400 } + ); + } + + if (!emails.every((email: unknown) => typeof email === "string")) { + return NextResponse.json( + { error: "All email entries must be strings" }, + { status: 400 } + ); + } + + if (emails.length > 20) { + return NextResponse.json( + { error: "Maximum 20 invites at a time" }, + { status: 400 } + ); + } + + // Validate email syntax BEFORE rate-limit checks (#143) + // Only valid emails should count toward throttle limits const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const validEmails = Array.from( new Set( @@ -86,46 +86,46 @@ export async function POST(request: NextRequest) { .filter((email: string) => emailRegex.test(email)) ) ); - - if (validEmails.length === 0) { - return NextResponse.json( - { error: "No valid email addresses provided" }, - { status: 400 } - ); - } - - // Spam throttling: max 50 invites per day, max 10 per hour - // Only count valid emails toward rate limits (#143) - const svc = createServiceClient(); - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - - const { count: hourlyCount } = await (svc as AnySupabase) - .from("referrals") - .select("id", { count: "exact", head: true }) - .eq("referrer_id", user.id) - .gte("created_at", oneHourAgo); - - if ((hourlyCount ?? 0) + validEmails.length > 10) { - return NextResponse.json( - { error: "Too many invites. Max 10 per hour." }, - { status: 429 } - ); - } - - const { count: dailyCount } = await (svc as AnySupabase) - .from("referrals") - .select("id", { count: "exact", head: true }) - .eq("referrer_id", user.id) - .gte("created_at", oneDayAgo); - - if ((dailyCount ?? 0) + validEmails.length > 50) { - return NextResponse.json( - { error: "Daily invite limit reached. Max 50 per day." }, - { status: 429 } - ); - } - + + if (validEmails.length === 0) { + return NextResponse.json( + { error: "No valid email addresses provided" }, + { status: 400 } + ); + } + + // Spam throttling: max 50 invites per day, max 10 per hour + // Only count valid emails toward rate limits (#143) + const svc = createServiceClient(); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const { count: hourlyCount } = await (svc as AnySupabase) + .from("referrals") + .select("id", { count: "exact", head: true }) + .eq("referrer_id", user.id) + .gte("created_at", oneHourAgo); + + if ((hourlyCount ?? 0) + validEmails.length > 10) { + return NextResponse.json( + { error: "Too many invites. Max 10 per hour." }, + { status: 429 } + ); + } + + const { count: dailyCount } = await (svc as AnySupabase) + .from("referrals") + .select("id", { count: "exact", head: true }) + .eq("referrer_id", user.id) + .gte("created_at", oneDayAgo); + + if ((dailyCount ?? 0) + validEmails.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") @@ -136,66 +136,66 @@ export async function POST(request: NextRequest) { const alreadyInvited = new Set( (existingInvites || []).map((r: any) => String(r.referred_email).trim().toLowerCase()) ); - - // Get user's referral code - const { data: profile } = await (supabase as any) - .from("profiles") - .select("referral_code, username, full_name") - .eq("id", user.id) - .single(); - - if (!profile) { - return NextResponse.json({ error: "Profile not found" }, { status: 404 }); - } - - const referralCode = profile.referral_code || profile.username; - const inviterName = profile.full_name || profile.username || "Someone"; - - // Filter valid emails that aren't already invited (#143) + + // Get user's referral code + const { data: profile } = await (supabase as any) + .from("profiles") + .select("referral_code, username, full_name") + .eq("id", user.id) + .single(); + + if (!profile) { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + + 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((email: string) => !alreadyInvited.has(email)); - - if (newValidEmails.length === 0) { - return NextResponse.json( - { error: "All these emails have already been invited" }, - { status: 400 } - ); - } - + + if (newValidEmails.length === 0) { + return NextResponse.json( + { error: "All these emails have already been invited" }, + { status: 400 } + ); + } + const referralRows = newValidEmails.map((email: string) => ({ referrer_id: user.id, - referred_email: email.trim().toLowerCase(), + referred_email: email, referral_code: referralCode, status: "pending" as const, })); - - const { data: referrals, error } = await (supabase as AnySupabase) - .from("referrals") - .insert(referralRows) - .select(); - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - const emailContent = referralInviteEmail({ inviterName, referralCode }); - const emailResults = await Promise.all( - newValidEmails.map((email: string) => - sendEmail({ to: email, ...emailContent }) - ) - ); - const failedEmailCount = emailResults.filter((result) => !result.success).length; - - return NextResponse.json({ - message: failedEmailCount > 0 - ? `${newValidEmails.length} invite(s) created; ${failedEmailCount} email(s) failed to send` - : `${newValidEmails.length} invite(s) created and sent`, - data: referrals, - email_delivery_failed: failedEmailCount, - }); - } catch { - return NextResponse.json( - { error: "An unexpected error occurred" }, - { status: 500 } - ); - } -} + + const { data: referrals, error } = await (supabase as AnySupabase) + .from("referrals") + .insert(referralRows) + .select(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + const emailContent = referralInviteEmail({ inviterName, referralCode }); + const emailResults = await Promise.all( + newValidEmails.map((email: string) => + sendEmail({ to: email, ...emailContent }) + ) + ); + const failedEmailCount = emailResults.filter((result) => !result.success).length; + + return NextResponse.json({ + message: failedEmailCount > 0 + ? `${newValidEmails.length} invite(s) created; ${failedEmailCount} email(s) failed to send` + : `${newValidEmails.length} invite(s) created and sent`, + data: referrals, + email_delivery_failed: failedEmailCount, + }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +}