Skip to content

Commit eedf670

Browse files
committed
feat(creators): added referrers, code redemption, campaign tracking, etc
1 parent 2f492ca commit eedf670

File tree

18 files changed

+11918
-1
lines changed

18 files changed

+11918
-1
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { useSearchParams } from 'next/navigation'
5+
6+
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
7+
const COOKIE_NAME = 'sim_utm'
8+
const COOKIE_MAX_AGE = 3600 // 1 hour
9+
10+
export function UtmCookieSetter() {
11+
const searchParams = useSearchParams()
12+
13+
useEffect(() => {
14+
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
15+
if (!hasUtm) return
16+
17+
const utmData: Record<string, string> = {}
18+
for (const key of UTM_KEYS) {
19+
const value = searchParams.get(key)
20+
if (value) {
21+
utmData[key] = value
22+
}
23+
}
24+
25+
utmData.referrer_url = document.referrer || ''
26+
utmData.landing_page = window.location.pathname
27+
utmData.created_at = Date.now().toString()
28+
29+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(utmData))}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
30+
}, [searchParams])
31+
32+
return null
33+
}

apps/sim/app/(auth)/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { useEffect } from 'react'
3+
import { Suspense, useEffect } from 'react'
44
import AuthBackground from '@/app/(auth)/components/auth-background'
5+
import { UtmCookieSetter } from '@/app/(auth)/components/utm-cookie-setter'
56
import Nav from '@/app/(landing)/components/nav/nav'
67

78
// Helper to detect if a color is dark
@@ -28,6 +29,9 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
2829
}, [])
2930
return (
3031
<AuthBackground>
32+
<Suspense>
33+
<UtmCookieSetter />
34+
</Suspense>
3135
<main className='relative flex min-h-screen flex-col text-foreground'>
3236
{/* Header - Nav handles all conditional logic */}
3337
<Nav hideAuthButtons={true} variant='auth' />
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { db } from '@sim/db'
2+
import { DEFAULT_REFERRAL_BONUS_CREDITS } from '@sim/db/constants'
3+
import { referralAttribution, referralCampaigns, user, userStats } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { eq } from 'drizzle-orm'
6+
import { nanoid } from 'nanoid'
7+
import { cookies } from 'next/headers'
8+
import { NextResponse } from 'next/server'
9+
import { getSession } from '@/lib/auth'
10+
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
11+
12+
const logger = createLogger('AttributionAPI')
13+
14+
const 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+
*/
22+
const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000
23+
24+
/**
25+
* 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).
28+
*/
29+
async function findMatchingCampaign(utmData: Record<string, string>) {
30+
const campaigns = await db
31+
.select()
32+
.from(referralCampaigns)
33+
.where(eq(referralCampaigns.isActive, true))
34+
35+
let bestMatch: (typeof campaigns)[number] | null = null
36+
let bestScore = -1
37+
38+
for (const campaign of campaigns) {
39+
let score = 0
40+
let mismatch = false
41+
42+
const fields = [
43+
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
44+
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
45+
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
46+
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
47+
] as const
48+
49+
for (const { campaignVal, utmVal } of fields) {
50+
if (campaignVal === null) continue
51+
if (campaignVal === utmVal) {
52+
score++
53+
} else {
54+
mismatch = true
55+
break
56+
}
57+
}
58+
59+
if (!mismatch && score > 0) {
60+
if (
61+
score > bestScore ||
62+
(score === bestScore &&
63+
bestMatch &&
64+
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
65+
) {
66+
bestScore = score
67+
bestMatch = campaign
68+
}
69+
}
70+
}
71+
72+
return bestMatch
73+
}
74+
75+
export async function POST() {
76+
try {
77+
const session = await getSession()
78+
if (!session?.user?.id) {
79+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
80+
}
81+
82+
const cookieStore = await cookies()
83+
const utmCookie = cookieStore.get(COOKIE_NAME)
84+
if (!utmCookie?.value) {
85+
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
86+
}
87+
88+
let utmData: Record<string, string>
89+
try {
90+
utmData = JSON.parse(decodeURIComponent(utmCookie.value))
91+
} catch {
92+
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
93+
cookieStore.delete(COOKIE_NAME)
94+
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
95+
}
96+
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.
101+
const cookieCreatedAt = Number(utmData.created_at)
102+
if (!cookieCreatedAt || !Number.isFinite(cookieCreatedAt)) {
103+
logger.warn('UTM cookie missing created_at timestamp', { userId: session.user.id })
104+
cookieStore.delete(COOKIE_NAME)
105+
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
106+
}
107+
108+
const userRows = await db
109+
.select({ createdAt: user.createdAt })
110+
.from(user)
111+
.where(eq(user.id, session.user.id))
112+
.limit(1)
113+
114+
if (userRows.length === 0) {
115+
return NextResponse.json({ error: 'User not found' }, { status: 404 })
116+
}
117+
118+
const userCreatedAt = userRows[0].createdAt.getTime()
119+
if (userCreatedAt < cookieCreatedAt - CLOCK_DRIFT_TOLERANCE_MS) {
120+
logger.info('User account predates UTM cookie, skipping attribution', {
121+
userId: session.user.id,
122+
userCreatedAt: new Date(userCreatedAt).toISOString(),
123+
cookieCreatedAt: new Date(cookieCreatedAt).toISOString(),
124+
})
125+
cookieStore.delete(COOKIE_NAME)
126+
return NextResponse.json({ attributed: false, reason: 'account_predates_cookie' })
127+
}
128+
129+
// Ensure userStats record exists (may not yet for brand-new signups)
130+
const [existingStats] = await db
131+
.select({ id: userStats.id })
132+
.from(userStats)
133+
.where(eq(userStats.userId, session.user.id))
134+
.limit(1)
135+
136+
if (!existingStats) {
137+
await db.insert(userStats).values({
138+
id: nanoid(),
139+
userId: session.user.id,
140+
})
141+
}
142+
143+
// Look up the matching campaign to determine bonus amount
144+
const matchedCampaign = await findMatchingCampaign(utmData)
145+
const bonusAmount = matchedCampaign
146+
? Number(matchedCampaign.bonusCreditAmount)
147+
: DEFAULT_REFERRAL_BONUS_CREDITS
148+
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.
152+
let attributed = false
153+
await db.transaction(async (tx) => {
154+
const result = await tx
155+
.insert(referralAttribution)
156+
.values({
157+
id: nanoid(),
158+
userId: session.user.id,
159+
campaignId: matchedCampaign?.id ?? null,
160+
utmSource: utmData.utm_source || null,
161+
utmMedium: utmData.utm_medium || null,
162+
utmCampaign: utmData.utm_campaign || null,
163+
utmContent: utmData.utm_content || null,
164+
referrerUrl: utmData.referrer_url || null,
165+
landingPage: utmData.landing_page || null,
166+
bonusCreditAmount: bonusAmount.toString(),
167+
})
168+
.onConflictDoNothing({ target: referralAttribution.userId })
169+
.returning({ id: referralAttribution.id })
170+
171+
if (result.length > 0) {
172+
await applyBonusCredits(session.user.id, bonusAmount, tx)
173+
attributed = true
174+
}
175+
})
176+
177+
if (attributed) {
178+
logger.info('Referral attribution created and bonus credits applied', {
179+
userId: session.user.id,
180+
campaignId: matchedCampaign?.id,
181+
campaignName: matchedCampaign?.name,
182+
utmSource: utmData.utm_source,
183+
utmCampaign: utmData.utm_campaign,
184+
utmContent: utmData.utm_content,
185+
bonusAmount,
186+
})
187+
} else {
188+
logger.info('User already attributed, skipping', { userId: session.user.id })
189+
}
190+
191+
cookieStore.delete(COOKIE_NAME)
192+
193+
return NextResponse.json({
194+
attributed,
195+
bonusAmount: attributed ? bonusAmount : undefined,
196+
})
197+
} catch (error) {
198+
logger.error('Attribution error', { error })
199+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
200+
}
201+
}

0 commit comments

Comments
 (0)