From f9d40e083facb2132a7274d8323e38216d6c74d8 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:43:47 +0530 Subject: [PATCH] feat: store utm tags in stripe on signup (#26838) * feat: store utm params in stripe on signup * fix: fallback to cookie when query params don't contain valid UTM data Changed from else-if to separate if statement so that when query params exist but don't contain valid UTM data, the cookie fallback is still tried. Previously, any request with non-UTM query params would skip the stored cookie data entirely. Co-Authored-By: unknown <> * fix: e2e --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../signup/handlers/calcomSignupHandler.ts | 11 ++-- apps/web/app/api/auth/signup/route.ts | 3 +- apps/web/pages/api/auth/[...nextauth].ts | 2 +- .../features/auth/lib/next-auth-options.ts | 1 + .../features/auth/signup/lib/fetchSignup.ts | 31 +++++++++-- packages/lib/server/username.ts | 45 ++++++++-------- packages/lib/tracking/server.ts | 54 ++++++++++++++++--- 7 files changed, 106 insertions(+), 41 deletions(-) diff --git a/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts b/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts index 7aac999fe3..3be4611997 100644 --- a/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts +++ b/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts @@ -35,7 +35,7 @@ import { const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] }); -const handler: CustomNextApiHandler = async (body, usernameStatus) => { +const handler: CustomNextApiHandler = async (body, usernameStatus, query) => { const { email: _email, password, @@ -122,7 +122,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { // Create the customer in Stripe with ad tracking metadata const cookieStore = await cookies(); const cookiesObj = Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value])); - const tracking = getTrackingFromCookies(cookiesObj); + const tracking = getTrackingFromCookies(cookiesObj, query); const customer = await billingService.createCustomer({ email, @@ -179,7 +179,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { }, }); if (team) { - const organizationId = team.isOrganization ? team.id : team.parent?.id ?? null; + const organizationId = team.isOrganization ? team.id : (team.parent?.id ?? null); if (username) { const existingUserByUsername = await prisma.user.findFirst({ @@ -272,10 +272,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { if (isPrismaError(error) && error.code === "P2002") { const target = String(error.meta?.target ?? ""); if (target.includes("email") || target.includes("username")) { - return NextResponse.json( - { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, - { status: 409 } - ); + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); } } throw error; diff --git a/apps/web/app/api/auth/signup/route.ts b/apps/web/app/api/auth/signup/route.ts index 33cbd26b12..1e3f10f9a3 100644 --- a/apps/web/app/api/auth/signup/route.ts +++ b/apps/web/app/api/auth/signup/route.ts @@ -47,6 +47,7 @@ async function handler(req: NextRequest) { }); const body = await parseRequestData(req); + const query = Object.fromEntries(req.nextUrl.searchParams.entries()); await checkCfTurnstileToken({ token: req.headers.get("cf-access-token") as string, remoteIp, @@ -62,7 +63,7 @@ async function handler(req: NextRequest) { * @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI. */ if (IS_PREMIUM_USERNAME_ENABLED) { - return await calcomSignupHandler(body); + return await calcomSignupHandler(body, query); } return await selfHostedSignupHandler(body); diff --git a/apps/web/pages/api/auth/[...nextauth].ts b/apps/web/pages/api/auth/[...nextauth].ts index 02f4ec1067..c0ff6cdb41 100644 --- a/apps/web/pages/api/auth/[...nextauth].ts +++ b/apps/web/pages/api/auth/[...nextauth].ts @@ -11,7 +11,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => res, getOptions({ getDubId: () => req.cookies.dub_id || req.cookies.dclid, - getTrackingData: () => getTrackingFromCookies(req.cookies), + getTrackingData: () => getTrackingFromCookies(req.cookies, req.query), }) ); diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index fd8667082e..d14543982b 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -1205,6 +1205,7 @@ export const getOptions = ({ liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds.campaignId, }), + ...(tracking.utmData && tracking.utmData), }, }); await prisma.user.update({ diff --git a/packages/features/auth/signup/lib/fetchSignup.ts b/packages/features/auth/signup/lib/fetchSignup.ts index c665d8359f..8333c0c5c8 100644 --- a/packages/features/auth/signup/lib/fetchSignup.ts +++ b/packages/features/auth/signup/lib/fetchSignup.ts @@ -25,7 +25,28 @@ export type SignupResult = | { ok: false; status: number; error: SignupErrorResponse }; export async function fetchSignup(data: SignupData, cfToken?: string): Promise { - const response = await fetch("/api/auth/signup", { + const allParams = new URLSearchParams(window.location.search); + + const utmParams = new URLSearchParams(); + const utmKeys = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "utm_id", + "utm_referral", + "landing_page", + ]; + + utmKeys.forEach((key) => { + const value = allParams.get(key); + if (value) utmParams.set(key, value); + }); + + const url = utmParams.toString() ? `/api/auth/signup?${utmParams.toString()}` : "/api/auth/signup"; + + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -60,9 +81,13 @@ export async function fetchSignup(data: SignupData, cfToken?: string): Promise, - usernameStatus: UsernameStatus + usernameStatus: UsernameStatus, + query?: Record ) => Promise>; export async function isBlacklisted(username: string) { @@ -85,27 +86,28 @@ const processResult = ( } }; -const usernameHandler = (handler: CustomNextApiHandler) => async (body: Record) => { - const username = slugify(body.username); - const check = await usernameCheckForSignup({ username, email: body.email }); +const usernameHandler = + (handler: CustomNextApiHandler) => async (body: Record, query: Record) => { + const username = slugify(body.username); + const check = await usernameCheckForSignup({ username, email: body.email }); - let result: Parameters[0] = "ok"; - if (check.premium) result = "is_premium"; - if (!check.available) result = "username_exists"; + let result: Parameters[0] = "ok"; + if (check.premium) result = "is_premium"; + if (!check.available) result = "username_exists"; - const { statusCode, message } = processResult(result); - const usernameStatus = { - statusCode, - requestedUserName: username, - json: { - available: result !== "username_exists", - premium: result === "is_premium", - message, - suggestion: check.suggestedUsername, - }, + const { statusCode, message } = processResult(result); + const usernameStatus = { + statusCode, + requestedUserName: username, + json: { + available: result !== "username_exists", + premium: result === "is_premium", + message, + suggestion: check.suggestedUsername, + }, + }; + return handler(body, usernameStatus, query); }; - return handler(body, usernameStatus); -}; const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | null) => { log.debug("usernameCheck", { usernameRaw, currentOrgDomain }); @@ -124,10 +126,7 @@ const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | nu const organization = await prisma.team.findFirst({ where: { isOrganization: true, - OR: [ - { slug: currentOrgDomain }, - { metadata: { path: ["requestedSlug"], equals: currentOrgDomain } }, - ], + OR: [{ slug: currentOrgDomain }, { metadata: { path: ["requestedSlug"], equals: currentOrgDomain } }], }, select: { id: true, diff --git a/packages/lib/tracking/server.ts b/packages/lib/tracking/server.ts index e0aca6f875..a1a99a1fb5 100644 --- a/packages/lib/tracking/server.ts +++ b/packages/lib/tracking/server.ts @@ -1,4 +1,16 @@ import type { NextApiRequest } from "next"; +import { z } from "zod"; + +const utmTrackingDataSchema = z.object({ + utm_source: z.string().optional(), + utm_medium: z.string().optional(), + utm_campaign: z.string().optional(), + utm_term: z.string().optional(), + utm_content: z.string().optional(), + utm_id: z.string().optional(), + utm_referral: z.string().optional(), + landing_page: z.string().optional(), +}); export type GoogleAdsTrackingData = { gclid: string; @@ -10,30 +22,60 @@ export type LinkedInAdsTrackingData = { campaignId?: string; }; +export type UtmTrackingData = z.infer; + export type TrackingData = { googleAds?: GoogleAdsTrackingData; linkedInAds?: LinkedInAdsTrackingData; + utmData?: UtmTrackingData; }; - -export function getTrackingFromCookies(cookies?: NextApiRequest["cookies"]): TrackingData { +export function getTrackingFromCookies( + cookies?: NextApiRequest["cookies"], + query?: NextApiRequest["query"] +): TrackingData { const tracking: TrackingData = {}; - if (!cookies) return tracking; - - if (process.env.GOOGLE_ADS_ENABLED === "1" && cookies.gclid) { + if (process.env.GOOGLE_ADS_ENABLED === "1" && cookies?.gclid) { tracking.googleAds = { gclid: cookies.gclid, ...(cookies.gad_campaignId && { campaignId: cookies.gad_campaignId }), }; } - if (process.env.LINKEDIN_ADS_ENABLED === "1" && cookies.li_fat_id) { + if (process.env.LINKEDIN_ADS_ENABLED === "1" && cookies?.li_fat_id) { tracking.linkedInAds = { liFatId: cookies.li_fat_id, ...(cookies.li_campaignId && { campaignId: cookies.li_campaignId }), }; } + const parseUtm = (input: unknown): UtmTrackingData | null => { + const parsed = utmTrackingDataSchema.safeParse(input); + if (!parsed.success) return null; + + return Object.fromEntries( + Object.entries(parsed.data).filter(([_, value]) => value !== undefined) + ) as UtmTrackingData; + }; + + let utmData: UtmTrackingData | null = null; + + if (query && Object.keys(query).length > 0) { + utmData = parseUtm(query); + } + + if (!utmData && cookies?.utm_data) { + try { + utmData = parseUtm(JSON.parse(cookies.utm_data)); + } catch { + console.debug("Failed to parse utm_data cookie"); + } + } + + if (utmData) { + tracking.utmData = utmData; + } + return tracking; }