This commit is contained in:
Waleed Latif
2026-02-11 12:25:19 -08:00
parent eedf67013c
commit 9228893c19
10 changed files with 132 additions and 78 deletions

View File

@@ -1,33 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
const COOKIE_NAME = 'sim_utm'
const COOKIE_MAX_AGE = 3600 // 1 hour
export function UtmCookieSetter() {
const searchParams = useSearchParams()
useEffect(() => {
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
if (!hasUtm) return
const utmData: Record<string, string> = {}
for (const key of UTM_KEYS) {
const value = searchParams.get(key)
if (value) {
utmData[key] = value
}
}
utmData.referrer_url = document.referrer || ''
utmData.landing_page = window.location.pathname
utmData.created_at = Date.now().toString()
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(utmData))}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
}, [searchParams])
return null
}

View File

@@ -1,8 +1,7 @@
'use client'
import { Suspense, useEffect } from 'react'
import { useEffect } from 'react'
import AuthBackground from '@/app/(auth)/components/auth-background'
import { UtmCookieSetter } from '@/app/(auth)/components/utm-cookie-setter'
import Nav from '@/app/(landing)/components/nav/nav'
// Helper to detect if a color is dark
@@ -29,9 +28,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
}, [])
return (
<AuthBackground>
<Suspense>
<UtmCookieSetter />
</Suspense>
<main className='relative flex min-h-screen flex-col text-foreground'>
{/* Header - Nav handles all conditional logic */}
<Nav hideAuthButtons={true} variant='auth' />

View File

@@ -1,3 +1,15 @@
/**
* POST /api/attribution
*
* Automatic UTM-based referral attribution for new signups.
*
* Reads the `sim_utm` cookie (set by proxy on auth pages), verifies the user
* account was created after the cookie was set, matches a campaign by UTM
* specificity, and atomically inserts an attribution record + applies bonus credits.
*
* Idempotent — the unique constraint on `userId` prevents double-attribution.
*/
import { db } from '@sim/db'
import { DEFAULT_REFERRAL_BONUS_CREDITS } from '@sim/db/constants'
import { referralAttribution, referralCampaigns, user, userStats } from '@sim/db/schema'
@@ -12,19 +24,11 @@ import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('AttributionAPI')
const COOKIE_NAME = 'sim_utm'
/**
* Maximum allowed gap between when the UTM cookie was set and when the user
* account was created. Accounts for client/server clock skew. If the user's
* `createdAt` is more than this amount *before* the cookie timestamp, the
* attribution is rejected (the user already existed before visiting the link).
*/
const CLOCK_DRIFT_TOLERANCE_MS = 60 * 1000
/**
* Finds the most specific active campaign matching the given UTM params.
* Specificity = number of non-null UTM fields that match. A null field on
* the campaign acts as a wildcard (matches anything).
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
*/
async function findMatchingCampaign(utmData: Record<string, string>) {
const campaigns = await db
@@ -87,17 +91,20 @@ export async function POST() {
let utmData: Record<string, string>
try {
utmData = JSON.parse(decodeURIComponent(utmCookie.value))
// Decode first, falling back to raw value if UTM params contain bare %
let decoded: string
try {
decoded = decodeURIComponent(utmCookie.value)
} catch {
decoded = utmCookie.value
}
utmData = JSON.parse(decoded)
} catch {
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
// Verify user was created AFTER visiting the UTM link.
// The cookie embeds a `created_at` timestamp from when the UTM link was
// visited. If `user.createdAt` predates that timestamp (minus a small
// clock-drift tolerance), the user already existed and is not eligible.
const cookieCreatedAt = Number(utmData.created_at)
if (!cookieCreatedAt || !Number.isFinite(cookieCreatedAt)) {
logger.warn('UTM cookie missing created_at timestamp', { userId: session.user.id })
@@ -126,7 +133,6 @@ export async function POST() {
return NextResponse.json({ attributed: false, reason: 'account_predates_cookie' })
}
// Ensure userStats record exists (may not yet for brand-new signups)
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
@@ -140,15 +146,11 @@ export async function POST() {
})
}
// Look up the matching campaign to determine bonus amount
const matchedCampaign = await findMatchingCampaign(utmData)
const bonusAmount = matchedCampaign
? Number(matchedCampaign.bonusCreditAmount)
: DEFAULT_REFERRAL_BONUS_CREDITS
// Attribution insert + credit application in a single transaction.
// If the credit update fails, the attribution record rolls back so
// the client can safely retry on next workspace load.
let attributed = false
await db.transaction(async (tx) => {
const result = await tx

View File

@@ -1,3 +1,19 @@
/**
* POST /api/referral-code/redeem
*
* Redeem a referral/promo code to receive bonus credits.
*
* Body:
* - code: string — The referral code to redeem
*
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
*
* Constraints:
* - Enterprise users cannot redeem codes
* - One redemption per user, ever (unique constraint on userId)
* - One redemption per organization for team users (partial unique on organizationId)
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -24,7 +40,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Code is required' }, { status: 400 })
}
// Determine the user's plan — enterprise users cannot redeem codes
const subscription = await getHighestPrioritySubscription(session.user.id)
if (subscription?.plan === 'enterprise') {
@@ -37,7 +52,6 @@ export async function POST(request: Request) {
const isTeam = subscription?.plan === 'team'
const orgId = isTeam ? subscription.referenceId : null
// Look up the campaign by code directly (codes are stored uppercased)
const normalizedCode = code.trim().toUpperCase()
const [campaign] = await db
@@ -54,7 +68,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
}
// Check 1: Has this user already redeemed? (one per user, ever)
const [existingUserAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
@@ -68,8 +81,6 @@ export async function POST(request: Request) {
})
}
// Check 2: For team users, has any member of this org already redeemed?
// Credits pool to the org, so only one redemption per org is allowed.
if (orgId) {
const [existingOrgAttribution] = await db
.select({ id: referralAttribution.id })
@@ -85,7 +96,6 @@ export async function POST(request: Request) {
}
}
// Ensure userStats record exists
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
@@ -101,7 +111,6 @@ export async function POST(request: Request) {
const bonusAmount = Number(campaign.bonusCreditAmount)
// Attribution insert + credit application in a single transaction
let redeemed = false
await db.transaction(async (tx) => {
const result = await tx

View File

@@ -66,6 +66,12 @@
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Referral Campaigns:
* GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false)
* POST /api/v1/admin/referral-campaigns - Create campaign
* GET /api/v1/admin/referral-campaigns/:id - Get campaign details
* PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

View File

@@ -1,6 +1,21 @@
/**
* GET /api/v1/admin/referral-campaigns/:id — Get a single campaign
* PATCH /api/v1/admin/referral-campaigns/:id — Update campaign fields
* GET /api/v1/admin/referral-campaigns/:id
*
* Get a single referral campaign by ID.
*
* PATCH /api/v1/admin/referral-campaigns/:id
*
* Update campaign fields. All fields are optional.
*
* Body:
* - name?: string — Campaign name (non-empty)
* - bonusCreditAmount?: number — Bonus credits in dollars (> 0)
* - isActive?: boolean — Enable/disable the campaign
* - code?: string | null — Redeemable code (min 6 chars, auto-uppercased, null to remove)
* - utmSource?: string | null — UTM source match (null = wildcard)
* - utmMedium?: string | null — UTM medium match (null = wildcard)
* - utmCampaign?: string | null — UTM campaign match (null = wildcard)
* - utmContent?: string | null — UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
@@ -85,13 +100,17 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
}
if (body.code !== undefined) {
if (body.code !== null && typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
if (body.code !== null) {
if (typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (body.code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
updates.code = body.code ? body.code.trim().toUpperCase() : null
}
// UTM fields can be set to string or null (null = wildcard)
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
if (body[field] !== undefined) {
if (body[field] !== null && typeof body[field] !== 'string') {

View File

@@ -1,6 +1,25 @@
/**
* GET /api/v1/admin/referral-campaigns — List all campaigns (optional ?active=true filter)
* POST /api/v1/admin/referral-campaigns — Create a new campaign
* GET /api/v1/admin/referral-campaigns
*
* List referral campaigns with optional filtering and pagination.
*
* Query params:
* - active?: 'true' | 'false' — Filter by active status
* - limit?: number — Page size (default 50)
* - offset?: number — Offset for pagination
*
* POST /api/v1/admin/referral-campaigns
*
* Create a new referral campaign.
*
* Body:
* - name: string — Campaign name (required)
* - bonusCreditAmount: number — Bonus credits in dollars (required, > 0)
* - code?: string | null — Redeemable code (min 6 chars, auto-uppercased)
* - utmSource?: string | null — UTM source match (null = wildcard)
* - utmMedium?: string | null — UTM medium match (null = wildcard)
* - utmCampaign?: string | null — UTM campaign match (null = wildcard)
* - utmContent?: string | null — UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
@@ -35,7 +54,6 @@ export const GET = withAdminAuth(async (request) => {
const rows = await query.limit(limit).offset(offset)
// Count total for pagination
let countQuery = db.select().from(referralCampaigns).$dynamic()
if (activeFilter === 'true') {
countQuery = countQuery.where(eq(referralCampaigns.isActive, true))
@@ -69,8 +87,13 @@ export const POST = withAdminAuth(async (request) => {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
if (code !== undefined && code !== null && typeof code !== 'string') {
return badRequestResponse('code must be a string or null')
if (code !== undefined && code !== null) {
if (typeof code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
const id = nanoid()

View File

@@ -550,7 +550,6 @@ export function Subscription() {
/>
)}
{/* Referral Code — hidden from enterprise users */}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}

View File

@@ -7,9 +7,12 @@ const logger = createLogger('ReferralAttribution')
const COOKIE_NAME = 'sim_utm'
/** Terminal reasons that should not be retried. */
const TERMINAL_REASONS = new Set(['account_predates_cookie', 'invalid_cookie'])
/**
* Fires a one-shot `POST /api/attribution` when a `sim_utm` cookie is present.
* Retries on transient failures; stops on terminal outcomes.
*/
export function useReferralAttribution() {
const calledRef = useRef(false)
@@ -25,10 +28,8 @@ export function useReferralAttribution() {
if (data.attributed) {
logger.info('Referral attribution successful', { bonusAmount: data.bonusAmount })
} else if (data.error || TERMINAL_REASONS.has(data.reason)) {
// Terminal — don't retry
logger.info('Referral attribution skipped', { reason: data.reason || data.error })
} else {
// Non-terminal (e.g. transient failure) — allow retry on next mount
calledRef.current = false
}
})

View File

@@ -137,6 +137,37 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
return null
}
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'] as const
const UTM_COOKIE_NAME = 'sim_utm'
const UTM_COOKIE_MAX_AGE = 3600
/**
* Sets a `sim_utm` cookie when UTM params are present on auth pages.
* Captures UTM values, the HTTP Referer, landing page, and a timestamp
* used by the attribution API to verify the user signed up after visiting the link.
*/
function setUtmCookie(request: NextRequest, response: NextResponse): void {
const { searchParams, pathname } = request.nextUrl
const hasUtm = UTM_KEYS.some((key) => searchParams.get(key))
if (!hasUtm) return
const utmData: Record<string, string> = {}
for (const key of UTM_KEYS) {
const value = searchParams.get(key)
if (value) utmData[key] = value
}
utmData.referrer_url = request.headers.get('referer') || ''
utmData.landing_page = pathname
utmData.created_at = Date.now().toString()
response.cookies.set(UTM_COOKIE_NAME, JSON.stringify(utmData), {
path: '/',
maxAge: UTM_COOKIE_MAX_AGE,
sameSite: 'lax',
httpOnly: false, // Client-side hook needs to detect cookie presence
})
}
export async function proxy(request: NextRequest) {
const url = request.nextUrl
@@ -152,6 +183,7 @@ export async function proxy(request: NextRequest) {
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
setUtmCookie(request, response)
return response
}