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>
This commit is contained in:
authored and GitHub committed 2026-01-22 08:13:47 -03:00
1 parent 087fa03513
commit f9d40e083f
7 files changed
+106 -41

No files matched your search

@@ -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;
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -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),
})
);
@@ -1205,6 +1205,7 @@ export const getOptions = ({
liFatId: tracking.linkedInAds.liFatId,
linkedInCampaignId: tracking.linkedInAds.campaignId,
}),
...(tracking.utmData && tracking.utmData),
},
});
await prisma.user.update({
@@ -25,7 +25,28 @@ export type SignupResult =
| { ok: false; status: number; error: SignupErrorResponse };
export async function fetchSignup(data: SignupData, cfToken?: string): Promise<SignupResult> {
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<S
}
export function isUserAlreadyExistsError(result: SignupResult): boolean {
return !result.ok && result.status === 409 && result.error.message === SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS;
return (
!result.ok && result.status === 409 && result.error.message === SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS
);
}
export function hasCheckoutSession(result: SignupResult): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } {
export function hasCheckoutSession(
result: SignupResult
): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } {
return !result.ok && !!result.error.checkoutSessionId;
}
+22 -23
View File
@@ -33,7 +33,8 @@ type UsernameStatus = {
export type CustomNextApiHandler = (
body: Record<string, string>,
usernameStatus: UsernameStatus
usernameStatus: UsernameStatus,
query?: Record<string, string>
) => Promise<NextResponse<any>>;
export async function isBlacklisted(username: string) {
@@ -85,27 +86,28 @@ const processResult = (
}
};
const usernameHandler = (handler: CustomNextApiHandler) => async (body: Record<string, string>) => {
const username = slugify(body.username);
const check = await usernameCheckForSignup({ username, email: body.email });
const usernameHandler =
(handler: CustomNextApiHandler) => async (body: Record<string, string>, query: Record<string, string>) => {
const username = slugify(body.username);
const check = await usernameCheckForSignup({ username, email: body.email });
let result: Parameters<typeof processResult>[0] = "ok";
if (check.premium) result = "is_premium";
if (!check.available) result = "username_exists";
let result: Parameters<typeof processResult>[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,
+48 -6
View File
@@ -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<typeof utmTrackingDataSchema>;
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;
}