Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/app/api/referrals/route.dedupe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Join</p>",
text: "Join",
});
expect(insertReferrals).toHaveBeenCalledWith([
{
referrer_id: "user1",
referred_email: "new@test.com",
referral_code: "testuser",
status: "pending",
},
]);
});
Comment on lines +148 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing daily-limit analogue of the new hourly test

The new test covers the hourly boundary (9 existing + 1 new = 10, passes the > 10 guard), but there is no equivalent test for the daily limit. The fix applies the same newValidEmails.length substitution to both guards; a user at 49/50 daily invites submitting 1 existing + 1 new should also be admitted. Without a paired test, a future refactor that accidentally reverts only the daily guard would go undetected.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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();
});
});
52 changes: 29 additions & 23 deletions src/app/api/referrals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 }
Expand All @@ -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")
Expand All @@ -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(
Expand Down
Loading