mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 07:24:55 -05:00
more
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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' />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -550,7 +550,6 @@ export function Subscription() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Referral Code — hidden from enterprise users */}
|
||||
{!subscription.isEnterprise && (
|
||||
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user