Skip to content

fix: send invite emails before inserting DB records to avoid partial-state bugs - #303

Merged
ralyodio merged 1 commit into
profullstack:masterfrom
nikos1005:fix/referral-atomic-invites
May 29, 2026
Merged

fix: send invite emails before inserting DB records to avoid partial-state bugs#303
ralyodio merged 1 commit into
profullstack:masterfrom
nikos1005:fix/referral-atomic-invites

Conversation

@nikos1005

Copy link
Copy Markdown
Contributor

Summary

Reverses the order of operations in POST /api/referrals to prevent partial-state bugs.

Problem

Previously, referral records were inserted into the database first, then emails were sent asynchronously. If email delivery failed, the database would show invites that were never actually sent.

Fix

  • Emails are now sent FIRST
  • DB records are only created for successfully delivered emails
  • Returns 502 if all emails fail instead of a misleading 200
  • Failed email count is reported in the response

Affects the affiliate/invite friends flow.

…state bugs

Reversed the order of operations in POST /api/referrals:
- Emails are now sent FIRST
- DB records are only created for successfully delivered emails
- Previously, DB records were created even if emails failed to send.
- Also returns 502 if all emails fail instead of a misleading 200.
@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR reverses the order of operations in POST /api/referrals: invite emails are now sent before DB records are inserted, so failed email delivery no longer produces ghost referral rows. The response also changes to 502 when all emails fail and reports partial-failure counts accurately.

  • New inverse partial-state risk: if emails succeed but the subsequent DB insert fails, the endpoint returns a 400 with no record written — the dedup check on retry won't detect the already-delivered addresses, so users who retry will receive duplicate invitation emails.
  • Rate-limit drift on partial delivery: the quota check uses the full candidate list (validEmails.length) but only the successfully delivered subset is recorded, allowing the effective per-hour/per-day send count to fall below what the guard assumes and enabling quota re-use on retries.

Confidence Score: 3/5

The reordering fixes one class of inconsistency but opens a symmetric one: a DB write failure after successful email delivery leaves no trace of the send, so a user retry will dispatch duplicate invitation emails to the same recipients.

The core logic change swaps which side of the transaction is durable when something goes wrong. The DB insert path (lines 183-190) is still fallible after emails have already left the door, and nothing in the handler catches or compensates for that case.

src/app/api/referrals/route.ts - specifically the DB insert error branch (lines 183-190) and the rate-limit check interaction with partial delivery.

Important Files Changed

Filename Overview
src/app/api/referrals/route.ts Reverses email/DB order to prevent ghost DB records, but introduces an inverse partial-state: if the DB insert fails after emails succeed, no record is stored and a retry will send duplicate emails to the same recipients.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant API as POST /api/referrals
    participant ES as Email Service
    participant DB as Supabase DB

    C->>API: "POST { emails: [...] }"
    API->>API: Validate input, check rate limits
    API->>DB: Query existing invites (dedup)
    API->>DB: Query profile (referralCode)
    
    Note over API,ES: NEW ORDER (this PR)
    API->>ES: sendEmail() for each newValidEmail
    ES-->>API: emailResults[]
    
    alt All emails fail
        API-->>C: 502 Failed to send invitation emails
    else Some/all emails succeed
        API->>DB: INSERT referralRows (only successful emails)
        alt DB insert fails
            Note over API,DB: Emails sent but no DB record, retry sends duplicates
            API-->>C: 400 DB error
        else DB insert succeeds
            API-->>C: 200 message data email_delivery_failed
        end
    end
Loading

Comments Outside Diff (2)

  1. src/app/api/referrals/route.ts, line 183-190 (link)

    P1 Inverse partial-state: emails sent but DB insert fails

    This PR prevents ghost DB records (records without emails), but introduces the opposite race: emails are delivered successfully, then the DB .insert() fails (line 188–190). The endpoint returns a 400, no record is written, and the alreadyInvited dedup check on the next request won't detect these addresses — so a user retry will trigger duplicate invitation emails to the same recipients.

    For an invite flow, sending the same email twice is arguably more disruptive than the original ghost-record problem. Consider wrapping the two operations in a compensation pattern: if the DB insert fails after successful sends, either attempt a best-effort cleanup record (with a "sent_untracked" status) or at minimum log the successful recipient addresses so the operator can reconcile.

  2. src/app/api/referrals/route.ts, line 103-121 (link)

    P2 Rate-limit quota can drift when emails partially fail

    The hourly/daily checks gate on validEmails.length (all syntactically valid emails, before dedup), but after this PR the DB only records successfulEmails.length entries. If a batch of 8 passes the rate-limit check (consuming 8 of a 10/hr quota on paper), but only 3 actually get delivered and recorded, the quota in the DB only advances by 3. A follow-up request can then send 7 more emails (10 − 3) instead of the intended 2 (10 − 8), allowing users who encounter intermittent delivery failures to exceed the intended per-hour limit over multiple retries.

Reviews (1): Last reviewed commit: "fix: send invite emails before inserting..." | Re-trigger Greptile

@ralyodio
ralyodio merged commit 0c17c80 into profullstack:master May 29, 2026
4 checks passed
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.

2 participants