-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(creators): added referrers, code redemption, campaign tracking, etc #3198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
f757eed
feat(creators): added referrers, code redemption, campaign tracking, etc
waleedlatif1 f4f60d8
more
waleedlatif1 75b5e8f
added zod
waleedlatif1 54346a5
remove default
waleedlatif1 81f0ee8
remove duplicate index
waleedlatif1 bf29346
update admin routes
waleedlatif1 a10d418
reran migrations
waleedlatif1 55aa406
lint
waleedlatif1 9641389
move userstats record creation inside tx
waleedlatif1 31113e5
added reason for already attributed case
waleedlatif1 aaa4c1c
cleanup referral attributes
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /** | ||
| * POST /api/attribution | ||
| * | ||
| * Automatic UTM-based referral attribution. | ||
| * | ||
| * Reads the `sim_utm` cookie (set by proxy on auth pages), 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 { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema' | ||
| import { createLogger } from '@sim/logger' | ||
| import { eq } from 'drizzle-orm' | ||
| import { nanoid } from 'nanoid' | ||
| import { cookies } from 'next/headers' | ||
| import { NextResponse } from 'next/server' | ||
| import { z } from 'zod' | ||
| import { getSession } from '@/lib/auth' | ||
| import { applyBonusCredits } from '@/lib/billing/credits/bonus' | ||
|
|
||
| const logger = createLogger('AttributionAPI') | ||
|
|
||
| const COOKIE_NAME = 'sim_utm' | ||
|
|
||
| const UtmCookieSchema = z.object({ | ||
| utm_source: z.string().optional(), | ||
| utm_medium: z.string().optional(), | ||
| utm_campaign: z.string().optional(), | ||
| utm_content: z.string().optional(), | ||
| referrer_url: z.string().optional(), | ||
| landing_page: z.string().optional(), | ||
| created_at: z.string().optional(), | ||
| }) | ||
|
|
||
| /** | ||
| * Finds the most specific active campaign matching the given UTM params. | ||
| * Null fields on a campaign act as wildcards. Ties broken by newest campaign. | ||
| */ | ||
| async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) { | ||
| const campaigns = await db | ||
| .select() | ||
| .from(referralCampaigns) | ||
| .where(eq(referralCampaigns.isActive, true)) | ||
|
|
||
| let bestMatch: (typeof campaigns)[number] | null = null | ||
| let bestScore = -1 | ||
|
|
||
| for (const campaign of campaigns) { | ||
| let score = 0 | ||
| let mismatch = false | ||
|
|
||
| const fields = [ | ||
| { campaignVal: campaign.utmSource, utmVal: utmData.utm_source }, | ||
| { campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium }, | ||
| { campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign }, | ||
| { campaignVal: campaign.utmContent, utmVal: utmData.utm_content }, | ||
| ] as const | ||
|
|
||
| for (const { campaignVal, utmVal } of fields) { | ||
| if (campaignVal === null) continue | ||
| if (campaignVal === utmVal) { | ||
| score++ | ||
| } else { | ||
| mismatch = true | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if (!mismatch && score > 0) { | ||
| if ( | ||
| score > bestScore || | ||
| (score === bestScore && | ||
| bestMatch && | ||
| campaign.createdAt.getTime() > bestMatch.createdAt.getTime()) | ||
| ) { | ||
| bestScore = score | ||
| bestMatch = campaign | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return bestMatch | ||
| } | ||
|
|
||
| export async function POST() { | ||
| try { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| const cookieStore = await cookies() | ||
| const utmCookie = cookieStore.get(COOKIE_NAME) | ||
| if (!utmCookie?.value) { | ||
| return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' }) | ||
| } | ||
|
|
||
| let utmData: z.infer<typeof UtmCookieSchema> | ||
| try { | ||
| let decoded: string | ||
| try { | ||
| decoded = decodeURIComponent(utmCookie.value) | ||
| } catch { | ||
| decoded = utmCookie.value | ||
| } | ||
| utmData = UtmCookieSchema.parse(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' }) | ||
| } | ||
|
|
||
| const matchedCampaign = await findMatchingCampaign(utmData) | ||
| if (!matchedCampaign) { | ||
| cookieStore.delete(COOKIE_NAME) | ||
| return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' }) | ||
| } | ||
|
|
||
| const bonusAmount = Number(matchedCampaign.bonusCreditAmount) | ||
|
|
||
| let attributed = false | ||
| await db.transaction(async (tx) => { | ||
| const [existingStats] = await tx | ||
| .select({ id: userStats.id }) | ||
| .from(userStats) | ||
| .where(eq(userStats.userId, session.user.id)) | ||
| .limit(1) | ||
|
|
||
| if (!existingStats) { | ||
| await tx.insert(userStats).values({ | ||
| id: nanoid(), | ||
| userId: session.user.id, | ||
| }) | ||
| } | ||
|
|
||
| const result = await tx | ||
| .insert(referralAttribution) | ||
| .values({ | ||
| id: nanoid(), | ||
| userId: session.user.id, | ||
| campaignId: matchedCampaign.id, | ||
| utmSource: utmData.utm_source || null, | ||
| utmMedium: utmData.utm_medium || null, | ||
| utmCampaign: utmData.utm_campaign || null, | ||
| utmContent: utmData.utm_content || null, | ||
| referrerUrl: utmData.referrer_url || null, | ||
| landingPage: utmData.landing_page || null, | ||
| bonusCreditAmount: bonusAmount.toString(), | ||
| }) | ||
| .onConflictDoNothing({ target: referralAttribution.userId }) | ||
| .returning({ id: referralAttribution.id }) | ||
|
|
||
| if (result.length > 0) { | ||
| await applyBonusCredits(session.user.id, bonusAmount, tx) | ||
| attributed = true | ||
| } | ||
| }) | ||
|
|
||
| if (attributed) { | ||
| logger.info('Referral attribution created and bonus credits applied', { | ||
| userId: session.user.id, | ||
| campaignId: matchedCampaign.id, | ||
| campaignName: matchedCampaign.name, | ||
| utmSource: utmData.utm_source, | ||
| utmCampaign: utmData.utm_campaign, | ||
| utmContent: utmData.utm_content, | ||
| bonusAmount, | ||
| }) | ||
| } else { | ||
| logger.info('User already attributed, skipping', { userId: session.user.id }) | ||
| } | ||
|
|
||
| cookieStore.delete(COOKIE_NAME) | ||
|
|
||
| return NextResponse.json({ | ||
| attributed, | ||
| bonusAmount: attributed ? bonusAmount : undefined, | ||
| reason: attributed ? undefined : 'already_attributed', | ||
| }) | ||
| } catch (error) { | ||
| logger.error('Attribution error', { error }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| /** | ||
| * 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' | ||
| import { and, eq } from 'drizzle-orm' | ||
| import { nanoid } from 'nanoid' | ||
| import { NextResponse } from 'next/server' | ||
| import { z } from 'zod' | ||
| import { getSession } from '@/lib/auth' | ||
| import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' | ||
| import { applyBonusCredits } from '@/lib/billing/credits/bonus' | ||
|
|
||
| const logger = createLogger('ReferralCodeRedemption') | ||
|
|
||
| const RedeemCodeSchema = z.object({ | ||
| code: z.string().min(1, 'Code is required'), | ||
| }) | ||
|
|
||
| export async function POST(request: Request) { | ||
| try { | ||
| const session = await getSession() | ||
| if (!session?.user?.id) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| const body = await request.json() | ||
| const { code } = RedeemCodeSchema.parse(body) | ||
|
|
||
| const subscription = await getHighestPrioritySubscription(session.user.id) | ||
|
|
||
| if (subscription?.plan === 'enterprise') { | ||
| return NextResponse.json({ | ||
| redeemed: false, | ||
| error: 'Enterprise accounts cannot redeem referral codes', | ||
| }) | ||
| } | ||
|
|
||
| const isTeam = subscription?.plan === 'team' | ||
| const orgId = isTeam ? subscription.referenceId : null | ||
|
|
||
| const normalizedCode = code.trim().toUpperCase() | ||
|
|
||
| const [campaign] = await db | ||
| .select() | ||
| .from(referralCampaigns) | ||
| .where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true))) | ||
| .limit(1) | ||
|
|
||
| if (!campaign) { | ||
| logger.info('Invalid code redemption attempt', { | ||
| userId: session.user.id, | ||
| code: normalizedCode, | ||
| }) | ||
| return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 }) | ||
| } | ||
|
|
||
| const [existingUserAttribution] = await db | ||
| .select({ id: referralAttribution.id }) | ||
| .from(referralAttribution) | ||
| .where(eq(referralAttribution.userId, session.user.id)) | ||
| .limit(1) | ||
waleedlatif1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (existingUserAttribution) { | ||
| return NextResponse.json({ | ||
| redeemed: false, | ||
| error: 'You have already redeemed a code', | ||
| }) | ||
| } | ||
|
|
||
| if (orgId) { | ||
| const [existingOrgAttribution] = await db | ||
| .select({ id: referralAttribution.id }) | ||
| .from(referralAttribution) | ||
| .where(eq(referralAttribution.organizationId, orgId)) | ||
| .limit(1) | ||
|
|
||
| if (existingOrgAttribution) { | ||
| return NextResponse.json({ | ||
| redeemed: false, | ||
| error: 'A code has already been redeemed for your organization', | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| const bonusAmount = Number(campaign.bonusCreditAmount) | ||
|
|
||
| let redeemed = false | ||
| await db.transaction(async (tx) => { | ||
| const [existingStats] = await tx | ||
| .select({ id: userStats.id }) | ||
| .from(userStats) | ||
| .where(eq(userStats.userId, session.user.id)) | ||
| .limit(1) | ||
|
|
||
| if (!existingStats) { | ||
| await tx.insert(userStats).values({ | ||
| id: nanoid(), | ||
| userId: session.user.id, | ||
| }) | ||
| } | ||
|
|
||
| const result = await tx | ||
| .insert(referralAttribution) | ||
| .values({ | ||
| id: nanoid(), | ||
| userId: session.user.id, | ||
| organizationId: orgId, | ||
| campaignId: campaign.id, | ||
| utmSource: null, | ||
| utmMedium: null, | ||
| utmCampaign: null, | ||
| utmContent: null, | ||
| referrerUrl: null, | ||
| landingPage: null, | ||
| bonusCreditAmount: bonusAmount.toString(), | ||
| }) | ||
| .onConflictDoNothing() | ||
waleedlatif1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .returning({ id: referralAttribution.id }) | ||
|
|
||
| if (result.length > 0) { | ||
| await applyBonusCredits(session.user.id, bonusAmount, tx) | ||
| redeemed = true | ||
| } | ||
| }) | ||
|
|
||
| if (redeemed) { | ||
| logger.info('Referral code redeemed', { | ||
| userId: session.user.id, | ||
| organizationId: orgId, | ||
| code: normalizedCode, | ||
| campaignId: campaign.id, | ||
| campaignName: campaign.name, | ||
| bonusAmount, | ||
| }) | ||
| } | ||
|
|
||
| if (!redeemed) { | ||
| return NextResponse.json({ | ||
| redeemed: false, | ||
| error: 'You have already redeemed a code', | ||
| }) | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| redeemed: true, | ||
| bonusAmount, | ||
| }) | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) | ||
| } | ||
| logger.error('Referral code redemption error', { error }) | ||
| return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.