Skip to content

Commit 9228893

Browse files
committed
more
1 parent eedf670 commit 9228893

File tree

10 files changed

+132
-78
lines changed

10 files changed

+132
-78
lines changed

apps/sim/app/(auth)/components/utm-cookie-setter.tsx

Lines changed: 0 additions & 33 deletions
This file was deleted.

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

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

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

87
// Helper to detect if a color is dark
@@ -29,9 +28,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
2928
}, [])
3029
return (
3130
<AuthBackground>
32-
<Suspense>
33-
<UtmCookieSetter />
34-
</Suspense>
3531
<main className='relative flex min-h-screen flex-col text-foreground'>
3632
{/* Header - Nav handles all conditional logic */}
3733
<Nav hideAuthButtons={true} variant='auth' />

apps/sim/app/api/attribution/route.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
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+
113
import { db } from '@sim/db'
214
import { DEFAULT_REFERRAL_BONUS_CREDITS } from '@sim/db/constants'
315
import { referralAttribution, referralCampaigns, user, userStats } from '@sim/db/schema'
@@ -12,19 +24,11 @@ import { applyBonusCredits } from '@/lib/billing/credits/bonus'
1224
const logger = createLogger('AttributionAPI')
1325

1426
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-
*/
2227
const 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
*/
2933
async 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

apps/sim/app/api/referral-code/redeem/route.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/**
2+
* POST /api/referral-code/redeem
3+
*
4+
* Redeem a referral/promo code to receive bonus credits.
5+
*
6+
* Body:
7+
* - code: string — The referral code to redeem
8+
*
9+
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
10+
*
11+
* Constraints:
12+
* - Enterprise users cannot redeem codes
13+
* - One redemption per user, ever (unique constraint on userId)
14+
* - One redemption per organization for team users (partial unique on organizationId)
15+
*/
16+
117
import { db } from '@sim/db'
218
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
319
import { createLogger } from '@sim/logger'
@@ -24,7 +40,6 @@ export async function POST(request: Request) {
2440
return NextResponse.json({ error: 'Code is required' }, { status: 400 })
2541
}
2642

27-
// Determine the user's plan — enterprise users cannot redeem codes
2843
const subscription = await getHighestPrioritySubscription(session.user.id)
2944

3045
if (subscription?.plan === 'enterprise') {
@@ -37,7 +52,6 @@ export async function POST(request: Request) {
3752
const isTeam = subscription?.plan === 'team'
3853
const orgId = isTeam ? subscription.referenceId : null
3954

40-
// Look up the campaign by code directly (codes are stored uppercased)
4155
const normalizedCode = code.trim().toUpperCase()
4256

4357
const [campaign] = await db
@@ -54,7 +68,6 @@ export async function POST(request: Request) {
5468
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
5569
}
5670

57-
// Check 1: Has this user already redeemed? (one per user, ever)
5871
const [existingUserAttribution] = await db
5972
.select({ id: referralAttribution.id })
6073
.from(referralAttribution)
@@ -68,8 +81,6 @@ export async function POST(request: Request) {
6881
})
6982
}
7083

71-
// Check 2: For team users, has any member of this org already redeemed?
72-
// Credits pool to the org, so only one redemption per org is allowed.
7384
if (orgId) {
7485
const [existingOrgAttribution] = await db
7586
.select({ id: referralAttribution.id })
@@ -85,7 +96,6 @@ export async function POST(request: Request) {
8596
}
8697
}
8798

88-
// Ensure userStats record exists
8999
const [existingStats] = await db
90100
.select({ id: userStats.id })
91101
.from(userStats)
@@ -101,7 +111,6 @@ export async function POST(request: Request) {
101111

102112
const bonusAmount = Number(campaign.bonusCreditAmount)
103113

104-
// Attribution insert + credit application in a single transaction
105114
let redeemed = false
106115
await db.transaction(async (tx) => {
107116
const result = await tx

apps/sim/app/api/v1/admin/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@
6666
* Credits:
6767
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
6868
*
69+
* Referral Campaigns:
70+
* GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false)
71+
* POST /api/v1/admin/referral-campaigns - Create campaign
72+
* GET /api/v1/admin/referral-campaigns/:id - Get campaign details
73+
* PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields
74+
*
6975
* Access Control (Permission Groups):
7076
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
7177
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
/**
2-
* GET /api/v1/admin/referral-campaigns/:id — Get a single campaign
3-
* PATCH /api/v1/admin/referral-campaigns/:id — Update campaign fields
2+
* GET /api/v1/admin/referral-campaigns/:id
3+
*
4+
* Get a single referral campaign by ID.
5+
*
6+
* PATCH /api/v1/admin/referral-campaigns/:id
7+
*
8+
* Update campaign fields. All fields are optional.
9+
*
10+
* Body:
11+
* - name?: string — Campaign name (non-empty)
12+
* - bonusCreditAmount?: number — Bonus credits in dollars (> 0)
13+
* - isActive?: boolean — Enable/disable the campaign
14+
* - code?: string | null — Redeemable code (min 6 chars, auto-uppercased, null to remove)
15+
* - utmSource?: string | null — UTM source match (null = wildcard)
16+
* - utmMedium?: string | null — UTM medium match (null = wildcard)
17+
* - utmCampaign?: string | null — UTM campaign match (null = wildcard)
18+
* - utmContent?: string | null — UTM content match (null = wildcard)
419
*/
520

621
import { db } from '@sim/db'
@@ -85,13 +100,17 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
85100
}
86101

87102
if (body.code !== undefined) {
88-
if (body.code !== null && typeof body.code !== 'string') {
89-
return badRequestResponse('code must be a string or null')
103+
if (body.code !== null) {
104+
if (typeof body.code !== 'string') {
105+
return badRequestResponse('code must be a string or null')
106+
}
107+
if (body.code.trim().length < 6) {
108+
return badRequestResponse('code must be at least 6 characters')
109+
}
90110
}
91111
updates.code = body.code ? body.code.trim().toUpperCase() : null
92112
}
93113

94-
// UTM fields can be set to string or null (null = wildcard)
95114
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
96115
if (body[field] !== undefined) {
97116
if (body[field] !== null && typeof body[field] !== 'string') {

apps/sim/app/api/v1/admin/referral-campaigns/route.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
/**
2-
* GET /api/v1/admin/referral-campaigns — List all campaigns (optional ?active=true filter)
3-
* POST /api/v1/admin/referral-campaigns — Create a new campaign
2+
* GET /api/v1/admin/referral-campaigns
3+
*
4+
* List referral campaigns with optional filtering and pagination.
5+
*
6+
* Query params:
7+
* - active?: 'true' | 'false' — Filter by active status
8+
* - limit?: number — Page size (default 50)
9+
* - offset?: number — Offset for pagination
10+
*
11+
* POST /api/v1/admin/referral-campaigns
12+
*
13+
* Create a new referral campaign.
14+
*
15+
* Body:
16+
* - name: string — Campaign name (required)
17+
* - bonusCreditAmount: number — Bonus credits in dollars (required, > 0)
18+
* - code?: string | null — Redeemable code (min 6 chars, auto-uppercased)
19+
* - utmSource?: string | null — UTM source match (null = wildcard)
20+
* - utmMedium?: string | null — UTM medium match (null = wildcard)
21+
* - utmCampaign?: string | null — UTM campaign match (null = wildcard)
22+
* - utmContent?: string | null — UTM content match (null = wildcard)
423
*/
524

625
import { db } from '@sim/db'
@@ -35,7 +54,6 @@ export const GET = withAdminAuth(async (request) => {
3554

3655
const rows = await query.limit(limit).offset(offset)
3756

38-
// Count total for pagination
3957
let countQuery = db.select().from(referralCampaigns).$dynamic()
4058
if (activeFilter === 'true') {
4159
countQuery = countQuery.where(eq(referralCampaigns.isActive, true))
@@ -69,8 +87,13 @@ export const POST = withAdminAuth(async (request) => {
6987
return badRequestResponse('bonusCreditAmount must be a positive number')
7088
}
7189

72-
if (code !== undefined && code !== null && typeof code !== 'string') {
73-
return badRequestResponse('code must be a string or null')
90+
if (code !== undefined && code !== null) {
91+
if (typeof code !== 'string') {
92+
return badRequestResponse('code must be a string or null')
93+
}
94+
if (code.trim().length < 6) {
95+
return badRequestResponse('code must be at least 6 characters')
96+
}
7497
}
7598

7699
const id = nanoid()

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,6 @@ export function Subscription() {
550550
/>
551551
)}
552552

553-
{/* Referral Code — hidden from enterprise users */}
554553
{!subscription.isEnterprise && (
555554
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
556555
)}

apps/sim/hooks/use-referral-attribution.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ const logger = createLogger('ReferralAttribution')
77

88
const COOKIE_NAME = 'sim_utm'
99

10-
/** Terminal reasons that should not be retried. */
1110
const TERMINAL_REASONS = new Set(['account_predates_cookie', 'invalid_cookie'])
1211

12+
/**
13+
* Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present.
14+
* Retries on transient failures; stops on terminal outcomes.
15+
*/
1316
export function useReferralAttribution() {
1417
const calledRef = useRef(false)
1518

@@ -25,10 +28,8 @@ export function useReferralAttribution() {
2528
if (data.attributed) {
2629
logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount })
2730
} else if (data.error || TERMINAL_REASONS.has(data.reason)) {
28-
// Terminal — don't retry
2931
logger.info('Referral attribution skipped', { reason: data.reason || data.error })
3032
} else {
31-
// Non-terminal (e.g. transient failure) — allow retry on next mount
3233
calledRef.current = false
3334
}
3435
})

apps/sim/proxy.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,37 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
137137
return null
138138
}
139139

140+
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
141+
const UTM_COOKIE_NAME = 'sim_utm'
142+
const UTM_COOKIE_MAX_AGE = 3600
143+
144+
/**
145+
* Sets a `sim_utm` cookie when UTM params are present on auth pages.
146+
* Captures UTM values, the HTTP Referer, landing page, and a timestamp
147+
* used by the attribution API to verify the user signed up after visiting the link.
148+
*/
149+
function setUtmCookie(request: NextRequest, response: NextResponse): void {
150+
const { searchParams, pathname } = request.nextUrl
151+
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
152+
if (!hasUtm) return
153+
154+
const utmData: Record<string, string> = {}
155+
for (const key of UTM_KEYS) {
156+
const value = searchParams.get(key)
157+
if (value) utmData[key] = value
158+
}
159+
utmData.referrer_url = request.headers.get('referer') || ''
160+
utmData.landing_page = pathname
161+
utmData.created_at = Date.now().toString()
162+
163+
response.cookies.set(UTM_COOKIE_NAME, JSON.stringify(utmData), {
164+
path: '/',
165+
maxAge: UTM_COOKIE_MAX_AGE,
166+
sameSite: 'lax',
167+
httpOnly: false, // Client-side hook needs to detect cookie presence
168+
})
169+
}
170+
140171
export async function proxy(request: NextRequest) {
141172
const url = request.nextUrl
142173

@@ -152,6 +183,7 @@ export async function proxy(request: NextRequest) {
152183
}
153184
const response = NextResponse.next()
154185
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
186+
setUtmCookie(request, response)
155187
return response
156188
}
157189

0 commit comments

Comments
 (0)