1+ /**
2+ * POST /api/attribution
3+ *
4+ * Automatic UTM-based referral attribution for new signups.
5+ *
6+ * Reads the `sim_utm` cookie (set by proxy on auth pages), verifies the user
7+ * account was created after the cookie was set, matches a campaign by UTM
8+ * specificity, and atomically inserts an attribution record + applies bonus credits.
9+ *
10+ * Idempotent — the unique constraint on `userId` prevents double-attribution.
11+ */
12+
113import { db } from '@sim/db'
214import { DEFAULT_REFERRAL_BONUS_CREDITS } from '@sim/db/constants'
315import { referralAttribution , referralCampaigns , user , userStats } from '@sim/db/schema'
@@ -12,19 +24,11 @@ import { applyBonusCredits } from '@/lib/billing/credits/bonus'
1224const logger = createLogger ( 'AttributionAPI' )
1325
1426const COOKIE_NAME = 'sim_utm'
15-
16- /**
17- * Maximum allowed gap between when the UTM cookie was set and when the user
18- * account was created. Accounts for client/server clock skew. If the user's
19- * `createdAt` is more than this amount *before* the cookie timestamp, the
20- * attribution is rejected (the user already existed before visiting the link).
21- */
2227const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000
2328
2429/**
2530 * Finds the most specific active campaign matching the given UTM params.
26- * Specificity = number of non-null UTM fields that match. A null field on
27- * the campaign acts as a wildcard (matches anything).
31+ * Null fields on a campaign act as wildcards. Ties broken by newest campaign.
2832 */
2933async function findMatchingCampaign ( utmData : Record < string , string > ) {
3034 const campaigns = await db
@@ -87,17 +91,20 @@ export async function POST() {
8791
8892 let utmData : Record < string , string >
8993 try {
90- utmData = JSON . parse ( decodeURIComponent ( utmCookie . value ) )
94+ // Decode first, falling back to raw value if UTM params contain bare %
95+ let decoded : string
96+ try {
97+ decoded = decodeURIComponent ( utmCookie . value )
98+ } catch {
99+ decoded = utmCookie . value
100+ }
101+ utmData = JSON . parse ( decoded )
91102 } catch {
92103 logger . warn ( 'Failed to parse UTM cookie' , { userId : session . user . id } )
93104 cookieStore . delete ( COOKIE_NAME )
94105 return NextResponse . json ( { attributed : false , reason : 'invalid_cookie' } )
95106 }
96107
97- // Verify user was created AFTER visiting the UTM link.
98- // The cookie embeds a `created_at` timestamp from when the UTM link was
99- // visited. If `user.createdAt` predates that timestamp (minus a small
100- // clock-drift tolerance), the user already existed and is not eligible.
101108 const cookieCreatedAt = Number ( utmData . created_at )
102109 if ( ! cookieCreatedAt || ! Number . isFinite ( cookieCreatedAt ) ) {
103110 logger . warn ( 'UTM cookie missing created_at timestamp' , { userId : session . user . id } )
@@ -126,7 +133,6 @@ export async function POST() {
126133 return NextResponse . json ( { attributed : false , reason : 'account_predates_cookie' } )
127134 }
128135
129- // Ensure userStats record exists (may not yet for brand-new signups)
130136 const [ existingStats ] = await db
131137 . select ( { id : userStats . id } )
132138 . from ( userStats )
@@ -140,15 +146,11 @@ export async function POST() {
140146 } )
141147 }
142148
143- // Look up the matching campaign to determine bonus amount
144149 const matchedCampaign = await findMatchingCampaign ( utmData )
145150 const bonusAmount = matchedCampaign
146151 ? Number ( matchedCampaign . bonusCreditAmount )
147152 : DEFAULT_REFERRAL_BONUS_CREDITS
148153
149- // Attribution insert + credit application in a single transaction.
150- // If the credit update fails, the attribution record rolls back so
151- // the client can safely retry on next workspace load.
152154 let attributed = false
153155 await db . transaction ( async ( tx ) => {
154156 const result = await tx
0 commit comments