diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index d7937de506..4b7705b177 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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([ diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 60e8ebf2fd..a49edc9280 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -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 { diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 47bbb2fe96..f836f9c5fc 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -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; @@ -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, })); }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index cdb384eec5..c5ed9563de 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1204,6 +1204,7 @@ async function handler( evt, originalRescheduledBooking, creationSource: req.body.creationSource, + tracking: reqBody.tracking, }); if (booking?.userId) { diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index 51cb4b97bc..303812da5a 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -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) { diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 72f0213241..6aa2a4f32c 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -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`, diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index aad1ee9aae..ace8788910 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -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; +}; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 9fc21cd64d..53ddc74b8e 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -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, diff --git a/packages/features/insights/hooks/useInsightsColumns.tsx b/packages/features/insights/hooks/useInsightsColumns.tsx index 9791d50e56..9985f1e570 100644 --- a/packages/features/insights/hooks/useInsightsColumns.tsx +++ b/packages/features/insights/hooks/useInsightsColumns.tsx @@ -199,6 +199,76 @@ export const useInsightsColumns = ({ return
{assignmentReason}
; }, }), + columnHelper.accessor("utm_source", { + id: "utm_source", + header: t("utm_source"), + size: 100, + enableColumnFilter: false, + enableSorting: false, + cell: (info) => { + return ( +
+ {info.getValue()} +
+ ); + }, + }), + columnHelper.accessor("utm_medium", { + id: "utm_medium", + header: "utm_medium", + size: 100, + enableColumnFilter: false, + enableSorting: false, + cell: (info) => { + return ( +
+ {info.getValue()} +
+ ); + }, + }), + columnHelper.accessor("utm_term", { + id: "utm_term", + header: "utm_term", + size: 100, + enableColumnFilter: false, + enableSorting: false, + cell: (info) => { + return ( +
+ {info.getValue()} +
+ ); + }, + }), + columnHelper.accessor("utm_content", { + id: "utm_content", + header: "utm_content", + size: 100, + enableColumnFilter: false, + enableSorting: false, + cell: (info) => { + return ( +
+ {info.getValue()} +
+ ); + }, + }), + columnHelper.accessor("utm_campaign", { + id: "utm_campaign", + header: "utm_campaign", + size: 100, + enableColumnFilter: false, + enableSorting: false, + cell: (info) => { + return ( +
+ {info.getValue()} +
+ ); + }, + }), columnHelper.accessor("createdAt", { id: "createdAt", header: t("routing_form_insights_submitted_at"), diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index fd2da31466..433916194d 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -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 || "", }; }); diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index a24dbfd62d..ad1070201a 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -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({}); diff --git a/packages/platform/atoms/lib/getUtmTrackingParameters.ts b/packages/platform/atoms/lib/getUtmTrackingParameters.ts new file mode 100644 index 0000000000..ad841a6a07 --- /dev/null +++ b/packages/platform/atoms/lib/getUtmTrackingParameters.ts @@ -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; +} diff --git a/packages/prisma/migrations/20250311134135_add_tracking_to_booking/migration.sql b/packages/prisma/migrations/20250311134135_add_tracking_to_booking/migration.sql new file mode 100644 index 0000000000..f3dc152a5d --- /dev/null +++ b/packages/prisma/migrations/20250311134135_add_tracking_to_booking/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20250313110448_update_routing_form_response/migration.sql b/packages/prisma/migrations/20250313110448_update_routing_form_response/migration.sql new file mode 100644 index 0000000000..297bf76a25 --- /dev/null +++ b/packages/prisma/migrations/20250313110448_update_routing_form_response/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2d496b6700..bf7f645bdf 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/prisma/zod/custom/booking.ts b/packages/prisma/zod/custom/booking.ts index 19163913f5..b9114a9498 100644 --- a/packages/prisma/zod/custom/booking.ts +++ b/packages/prisma/zod/custom/booking.ts @@ -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;