Skip to content

Fix referral rate limits for already-invited recipients#440

Closed
lazyGPT07 wants to merge 2 commits into
profullstack:masterfrom
lazyGPT07:codex/referral-rate-limit-new-invites
Closed

Fix referral rate limits for already-invited recipients#440
lazyGPT07 wants to merge 2 commits into
profullstack:masterfrom
lazyGPT07:codex/referral-rate-limit-new-invites

Conversation

@lazyGPT07

Copy link
Copy Markdown
Contributor

Summary

  • filter previously invited recipients before hourly and daily quota calculations
  • count only recipients eligible for a new invitation
  • add regression coverage for a user at 9/10 hourly invites submitting one existing and one new recipient

Fixes #439.

Validation

  • npm run test:run -- src/app/api/referrals/route.dedupe.test.ts (3/3)
  • npm run test:run (complete suite)
  • npm run type-check
  • npm run lint -- src/app/api/referrals/route.ts src/app/api/referrals/route.dedupe.test.ts (0 errors; existing repository warnings only)
  • npm run build (185 pages/routes generated)
  • git diff --check

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a bug where already-invited recipients were counted against hourly and daily invite quotas, preventing legitimate new invitations. It also adds a 500-error guard when the deduplication DB query fails, preventing duplicate emails from bypassing deduplication on transient errors.

  • route.ts: The existing-invites lookup is moved before the hourly/daily quota checks; newValidEmails.length (post-dedup) replaces validEmails.length in both > 10 and > 50 guards; an explicit 500 is returned when the dedup query errors.
  • route.dedupe.test.ts: Two new tests cover the hourly boundary case (9/10 used + 1 existing + 1 new → passes) and the dedup-query failure path (→ 500, no email sent).

Confidence Score: 5/5

Safe to merge — the reordering and error-guard are logically correct and the new tests exercise the changed paths.

The deduplication query is now correctly positioned before quota checks, error handling was added to prevent silent bypass on DB failure, and both changed guards use the filtered email count. No regressions were introduced on the existing paths.

No files require special attention.

Important Files Changed

Filename Overview
src/app/api/referrals/route.ts Moves deduplication check before rate-limit calculations and adds proper error handling for the dedup query; uses newValidEmails.length (not validEmails.length) against the hourly/daily guards — the core fix is logically correct.
src/app/api/referrals/route.dedupe.test.ts Adds two new tests: boundary case for the hourly limit (9 existing + 1 new = 10, passes the > 10 guard) and DB-error path for the dedup query (returns 500 without sending emails); both tests exercise newly-introduced behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[POST /api/referrals] --> B[Auth check]
    B --> C[Validate & normalize emails]
    C --> D[createServiceClient]
    D --> E["Query referrals WHERE referred_email IN validEmails\n(dedup check)"]
    E -->|error| F[500 Failed to check existing invitations]
    E -->|ok| G["Build alreadyInvited Set\nnewValidEmails = validEmails − alreadyInvited"]
    G -->|newValidEmails empty| H[400 All already invited]
    G -->|newValidEmails not empty| I["Hourly count query\ncount + newValidEmails.length > 10?"]
    I -->|yes| J[429 Too many invites]
    I -->|no| K["Daily count query\ncount + newValidEmails.length > 50?"]
    K -->|yes| L[429 Daily limit reached]
    K -->|no| M[Fetch profile]
    M --> N[Send emails to newValidEmails]
    N --> O[Insert successful rows into referrals]
    O --> P[200 OK]
Loading

Reviews (2): Last reviewed commit: "fix(referrals): fail closed on dedupe lo..." | Re-trigger Greptile

Comment thread src/app/api/referrals/route.ts Outdated
Comment on lines +148 to +225
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",
},
]);
});

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!

@lazyGPT07

Copy link
Copy Markdown
Contributor Author

Addressed in 96b431e: the existing-recipient lookup now fails closed with HTTP 500 before quota checks or email delivery, and a regression verifies that no email is sent when the database lookup fails.

Validation: focused 4/4 tests; full pre-commit passed with 1,634 tests, TypeScript, lint (0 errors), and a 185-route production build.

@ralyodio ralyodio closed this Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Referral rate limit counts already-invited recipients

2 participants