feat: add utm tracking on booking (#19965)

* add tracking to booking + pass to handleNewBooking

* save tracking to booking

* return undefined if no utm params

* add test for handleNewBooking

* fix type error in test

* refactor code

* code clean up

* code clean up

* add missing import

* fix type import

* add utm params to insights (#20063)

* add utm params to insights

* remove duplicates

* remove duplicate columns

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Carina Wollendorfer
2025-03-14 08:21:55 -07:00
committed by GitHub
co-authored by CarinaWolli Omar López
parent 13044cb5b0
commit 0968fc56eb
16 changed files with 354 additions and 4 deletions
@@ -10,6 +10,7 @@ import { expect, vi } from "vitest";
import "vitest-fetch-mock";
import dayjs from "@calcom/dayjs";
import type { Tracking } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -428,6 +429,18 @@ export async function expectBookingToBeInDatabase(
);
}
export async function expectBookingTrackingToBeInDatabase(tracking: Tracking, uid?: string) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid,
},
select: {
tracking: true,
},
});
expect(actualBooking?.tracking).toEqual(expect.objectContaining(tracking));
}
export function expectSMSToBeTriggered({ sms, toNumber }: { sms: Fixtures["sms"]; toNumber: string }) {
expect(sms.get()).toEqual(
expect.arrayContaining([
@@ -1,5 +1,6 @@
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import type { Tracking } from "@calcom/features/bookings/lib/handleNewBooking/types";
import type { SchedulingType } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
@@ -42,6 +43,7 @@ export function getMockRequestDataForBooking({
eventTypeId: number;
user?: string;
creationSource?: CreationSource;
tracking?: Tracking;
} & CommonPropsMockRequestData;
}) {
return {
@@ -7,6 +7,7 @@ import { parseRecurringDates } from "@calcom/lib/parse-dates";
import type { RoutingFormSearchParams } from "@calcom/platform-types";
import type { BookerEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
import type { Tracking } from "../handleNewBooking/types";
export type BookingOptions = {
values: Record<string, unknown>;
@@ -99,7 +100,8 @@ export const mapBookingToMutationInput = ({
// Other than that it forwards the mapping to mapBookingToMutationInput.
export const mapRecurringBookingToMutationInput = (
booking: BookingOptions,
recurringCount: number
recurringCount: number,
tracking?: Tracking
): RecurringBookingCreateBody[] => {
const recurringEventId = uuidv4();
const [, recurringDates] = parseRecurringDates(
@@ -124,5 +126,6 @@ export const mapRecurringBookingToMutationInput = (
recurringEventId,
schedulingType: booking.event.schedulingType || undefined,
recurringCount: recurringDates.length,
tracking,
}));
};
@@ -1204,6 +1204,7 @@ async function handler(
evt,
originalRescheduledBooking,
creationSource: req.body.creationSource,
tracking: reqBody.tracking,
});
if (booking?.userId) {
@@ -15,7 +15,7 @@ import type { AwaitedBookingData, EventTypeId } from "./getBookingData";
import type { NewBookingEventType } from "./getEventTypesFromDB";
import type { LoadedUsers } from "./loadUsers";
import type { OriginalRescheduledBooking } from "./originalRescheduledBookingUtils";
import type { PaymentAppData } from "./types";
import type { PaymentAppData, Tracking } from "./types";
type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
@@ -50,6 +50,7 @@ type CreateBookingParams = {
evt: CalendarEvent;
originalRescheduledBooking: OriginalRescheduledBooking;
creationSource?: CreationSource;
tracking?: Tracking;
};
function updateEventDetails(
@@ -85,6 +86,7 @@ export async function createBooking({
reroutingFormResponses,
rescheduledBy,
creationSource,
tracking,
}: CreateBookingParams & { rescheduledBy: string | undefined }) {
updateEventDetails(evt, originalRescheduledBooking, input.changedOrganizer);
const associatedBookingForFormResponse = routingFormResponseId
@@ -102,6 +104,7 @@ export async function createBooking({
evt,
originalRescheduledBooking,
creationSource,
tracking,
});
return await saveBooking(
@@ -213,6 +216,7 @@ function buildNewBookingData(params: CreateBookingParams) {
reroutingFormResponses,
rescheduledBy,
creationSource,
tracking,
} = params;
const attendeesData = getAttendeesData(evt);
@@ -261,6 +265,7 @@ function buildNewBookingData(params: CreateBookingParams) {
? { connect: { id: routingFormResponseId } }
: undefined,
creationSource,
tracking: tracking ? { create: tracking } : undefined,
};
if (reqBody.recurringEventId) {
@@ -43,6 +43,7 @@ import {
expectBrokenIntegrationEmails,
expectSuccessfulCalendarEventCreationInCalendar,
expectICalUIDAsString,
expectBookingTrackingToBeInDatabase,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
@@ -1550,6 +1551,92 @@ describe("handleNewBooking", () => {
);
});
describe("UTM tracking tests", () => {
test(
"should create a booking with UTM tracking",
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getZoomAppCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const tracking = {
utm_source: "source test",
utm_medium: "medium test",
utm_campaign: "campaign test",
utm_term: "term test",
utm_content: "content test",
};
const { req } = createMockNextJsRequest({
method: "POST",
body: getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
},
tracking,
},
}),
});
const scenarioData = getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
locations: [
{
type: BookingLocations.ZoomVideo,
},
],
},
],
organizer,
apps: [TestData.apps["zoomvideo"]],
});
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "zoomvideo",
});
await createBookingScenario(scenarioData);
const createdBooking = await handleNewBooking(req);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expectBookingTrackingToBeInDatabase(tracking, createdBooking.uid!);
},
timeout
);
});
describe("Creation source tests", () => {
test(
`should create a booking with creation source as WEBAPP`,
@@ -43,3 +43,11 @@ export type IsFixedAwareUser = User & {
};
export type { PaymentAppData };
export type Tracking = {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_term?: string;
utm_content?: string;
};
@@ -84,6 +84,11 @@ export function RoutingFormResponsesTable() {
columnVisibility: {
formId: false,
bookingUserId: false,
utm_source: false,
utm_medium: false,
utm_campaign: false,
utm_term: false,
utm_content: false,
},
},
getFacetedUniqueValues: getInsightsFacetedUniqueValues,
@@ -199,6 +199,76 @@ export const useInsightsColumns = ({
return <div className="max-w-[250px]">{assignmentReason}</div>;
},
}),
columnHelper.accessor("utm_source", {
id: "utm_source",
header: t("utm_source"),
size: 100,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return (
<div className="truncate">
<span>{info.getValue()}</span>
</div>
);
},
}),
columnHelper.accessor("utm_medium", {
id: "utm_medium",
header: "utm_medium",
size: 100,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return (
<div className="truncate">
<span>{info.getValue()}</span>
</div>
);
},
}),
columnHelper.accessor("utm_term", {
id: "utm_term",
header: "utm_term",
size: 100,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return (
<div className="truncate">
<span>{info.getValue()}</span>
</div>
);
},
}),
columnHelper.accessor("utm_content", {
id: "utm_content",
header: "utm_content",
size: 100,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return (
<div className="truncate">
<span>{info.getValue()}</span>
</div>
);
},
}),
columnHelper.accessor("utm_campaign", {
id: "utm_campaign",
header: "utm_campaign",
size: 100,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return (
<div className="truncate">
<span>{info.getValue()}</span>
</div>
);
},
}),
columnHelper.accessor("createdAt", {
id: "createdAt",
header: t("routing_form_insights_submitted_at"),
@@ -321,6 +321,11 @@ class RoutingEventsInsights {
bookingStartTime: true,
bookingEndTime: true,
createdAt: true,
utm_source: true,
utm_medium: true,
utm_campaign: true,
utm_term: true,
utm_content: true,
},
where: whereClause,
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
@@ -426,6 +431,11 @@ class RoutingEventsInsights {
"Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "",
...mapKeys(fields, (_, key) => startCase(key)),
utm_source: item.utm_source || "",
utm_medium: item.utm_medium || "",
utm_campaign: item.utm_campaign || "",
utm_term: item.utm_term || "",
utm_content: item.utm_content || "",
};
});
@@ -1,3 +1,5 @@
import { useSearchParams } from "next/navigation";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
@@ -10,6 +12,7 @@ import type { RoutingFormSearchParams } from "@calcom/platform-types";
import type { BookingCreateBody } from "@calcom/prisma/zod/custom/booking";
import { showToast } from "@calcom/ui";
import { getUtmTrackingParameters } from "../../lib/getUtmTrackingParameters";
import type { UseCreateBookingInput } from "./useCreateBooking";
type Callbacks = { onSuccess?: () => void; onError?: (err: any) => void };
@@ -62,6 +65,7 @@ export const useHandleBookEvent = ({
const errorMessage = err?.message ? t(err.message) : t("can_you_try_again");
showToast(errorMessage, "error");
};
const searchParams = useSearchParams();
const handleBookEvent = (inputTimeSlot?: string) => {
const values = bookingForm.getValues();
@@ -109,12 +113,17 @@ export const useHandleBookEvent = ({
routingFormSearchParams,
};
const tracking = getUtmTrackingParameters(searchParams);
if (isInstantMeeting) {
handleInstantBooking(mapBookingToMutationInput(bookingInput), callbacks);
} else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
handleRecBooking(mapRecurringBookingToMutationInput(bookingInput, recurringEventCount), callbacks);
handleRecBooking(
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount, tracking),
callbacks
);
} else {
handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl }, callbacks);
handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl, tracking }, callbacks);
}
// Clears form values stored in store, so old values won't stick around.
setFormValues({});
@@ -0,0 +1,13 @@
import type { ReadonlyURLSearchParams } from "next/navigation";
export function getUtmTrackingParameters(searchParams: ReadonlyURLSearchParams | null) {
if (!searchParams) return undefined;
const utmParams = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
const tracking = Object.fromEntries(
utmParams.map((param) => [param, searchParams.get(param) ?? undefined])
);
return Object.values(tracking).every((value) => value === undefined) ? undefined : tracking;
}
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "Tracking" (
"id" SERIAL NOT NULL,
"bookingId" INTEGER NOT NULL,
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"utm_term" TEXT,
"utm_content" TEXT,
CONSTRAINT "Tracking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Tracking_bookingId_key" ON "Tracking"("bookingId");
-- AddForeignKey
ALTER TABLE "Tracking" ADD CONSTRAINT "Tracking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,80 @@
CREATE OR REPLACE VIEW "RoutingFormResponse" AS
SELECT
r.id,
r.response,
(
SELECT jsonb_object_agg(
key,
CASE
WHEN jsonb_typeof(value->'value') = 'string'
THEN jsonb_build_object(
'label', value->'label',
'value', lower((value->>'value')::text)
)
ELSE value
END
)
FROM jsonb_each(r.response::jsonb)
) as "responseLowercase",
f.id as "formId",
f.name as "formName",
f."teamId" as "formTeamId",
f."userId" as "formUserId",
b.uid as "bookingUid",
b.status as "bookingStatus",
CASE b.status
WHEN 'accepted' THEN 1
WHEN 'pending' THEN 2
WHEN 'awaiting_host' THEN 3
WHEN 'cancelled' THEN 4
WHEN 'rejected' THEN 5
END as "bookingStatusOrder",
b."createdAt" as "bookingCreatedAt",
b."startTime" as "bookingStartTime",
b."endTime" as "bookingEndTime",
(SELECT
json_agg(
json_build_object(
'name', a.name, 'timeZone', a."timeZone", 'email', a.email
)
)
FROM "Attendee" a
WHERE "a"."bookingId" = b.id
) as "bookingAttendees",
u.id as "bookingUserId",
u.name as "bookingUserName",
u.email as "bookingUserEmail",
u."avatarUrl" as "bookingUserAvatarUrl",
COALESCE(
(
SELECT
ar."reasonString"
FROM "AssignmentReason" ar
WHERE ar."bookingId" = b.id
LIMIT 1
),
''
) as "bookingAssignmentReason",
COALESCE(
(
SELECT
LOWER(ar."reasonString")
FROM "AssignmentReason" ar
WHERE ar."bookingId" = b.id
LIMIT 1
),
''
) as "bookingAssignmentReasonLowercase",
r."createdAt" as "createdAt",
-- UTM parameters from Tracking table
t.utm_source as "utm_source",
t.utm_medium as "utm_medium",
t.utm_campaign as "utm_campaign",
t.utm_term as "utm_term",
t.utm_content as "utm_content"
FROM "App_RoutingForms_FormResponse" r
LEFT JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
LEFT JOIN "Booking" b ON r."routedToBookingUid" = b.uid
LEFT JOIN "users" u ON b."userId" = u.id
LEFT JOIN "Tracking" t ON t."bookingId" = b.id;
+19
View File
@@ -699,6 +699,7 @@ model Booking {
assignmentReason AssignmentReason[]
internalNote BookingInternalNote[]
creationSource CreationSource?
tracking Tracking?
@@index([eventTypeId])
@@index([userId])
@@ -709,6 +710,19 @@ model Booking {
@@index([startTime, endTime, status])
}
model Tracking {
id Int @id @default(autoincrement())
bookingId Int
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
utm_source String?
utm_medium String?
utm_campaign String?
utm_term String?
utm_content String?
@@unique([bookingId])
}
model Schedule {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1080,6 +1094,11 @@ view RoutingFormResponse {
bookingStartTime DateTime?
bookingEndTime DateTime?
createdAt DateTime
utm_source String?
utm_medium String?
utm_campaign String?
utm_term String?
utm_content String?
}
model Feedback {
+7
View File
@@ -39,6 +39,13 @@ export const bookingCreateBodySchema = z.object({
_isDryRun: z.boolean().optional(),
/** Whether to override the cache */
_shouldServeCache: z.boolean().optional(),
tracking: 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(),
}).optional(),
});
export type BookingCreateBody = z.input<typeof bookingCreateBodySchema>;