diff --git a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts index 5f73c23bd0..09b39906b4 100644 --- a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts +++ b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler"; -import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants"; +import { + ROUTING_FORM_RESPONSE_ID_QUERY_STRING, + ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING, +} from "@calcom/app-store/routing-forms/lib/constants"; import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "@calcom/lib/server/getTeamMemberEmailFromCrm"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -52,24 +55,22 @@ function mockBookingFormHandler({ ); } -async function createRoutingFormWithResponse({ - formData, -}: { - formData: { - routes: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queryValue?: any; - action: { - eventTypeId: number; - type: RouteActionType; - value: string; - }; - attributeRoutingConfig: { - skipContactOwner: boolean; - }; - }[]; - }; -}) { +type RoutingFormData = { + routes: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryValue?: any; + action: { + eventTypeId: number; + type: RouteActionType; + value: string; + }; + attributeRoutingConfig: { + skipContactOwner: boolean; + }; + }[]; +}; + +async function _createRoutingFormBase({ formData }: { formData: RoutingFormData }) { const user = await prismock.user.create({ data: { name: "Routing Form User", @@ -100,6 +101,12 @@ async function createRoutingFormWithResponse({ }, }); + return { form, routeId }; +} + +async function createRoutingFormWithResponse({ formData }: { formData: RoutingFormData }) { + const { form, routeId } = await _createRoutingFormBase({ formData }); + const responseRecord = await prismock.app_RoutingForms_FormResponse.create({ data: { chosenRouteId: routeId, @@ -115,6 +122,26 @@ async function createRoutingFormWithResponse({ return { form, responseRecord }; } +async function createRoutingFormWithQueuedResponse({ formData }: { formData: RoutingFormData }) { + const { form, routeId } = await _createRoutingFormBase({ formData }); + + const queuedResponseId = v4(); + const queuedResponseRecord = await prismock.app_RoutingForms_QueuedFormResponse.create({ + data: { + id: queuedResponseId, + chosenRouteId: routeId, + response: {}, + form: { + connect: { + id: form.id, + }, + }, + }, + }); + + return { form, queuedResponseRecord }; +} + async function createHostForEvent({ eventTypeData, userData, @@ -254,166 +281,250 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { }); describe("Booking form handler", () => { - it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => { - const bookerEmail = "booker@example.com"; - const teamMemberEmail = "owner@example.com"; - mockBookingFormHandler({ bookerEmail, teamMemberEmail }); - await createHostForEvent({ - eventTypeData: { - id: mockEventData.id, - }, - userData: { - email: teamMemberEmail, - }, - }); + describe("when responseId is provided", () => { + it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "owner@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); - const { responseRecord } = await createRoutingFormWithResponse({ - formData: { - routes: [ - { - action: { - eventTypeId: 1, - type: RouteActionType.EventTypeRedirectUrl, - value: "/team/sales", + const { responseRecord } = await createRoutingFormWithResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: false }, }, - attributeRoutingConfig: { skipContactOwner: false }, - }, - ], - }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), + "cal.salesforce.xxx": "true", + }, + eventData: mockEventData, + }); + + expect(result.email).toBe(teamMemberEmail); }); - const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query: { - email: bookerEmail, - [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), - "cal.salesforce.xxx": "true", - }, - eventData: mockEventData, + it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "owner@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); + + const { responseRecord } = await createRoutingFormWithResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: true }, + }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), + "cal.salesforce.xxx": "true", + }, + eventData: mockEventData, + }); + + // Because skipContactOwner is true, the booking form handler should return null + expect(result.email).toBe(null); }); - expect(result.email).toBe(teamMemberEmail); + it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "ownerNotInEventType@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + const SOME_OTHER_EVENT_ID = 200; + await createHostForEvent({ + eventTypeData: { + id: SOME_OTHER_EVENT_ID, + }, + userData: { + email: teamMemberEmail, + }, + }); + + const { responseRecord } = await createRoutingFormWithResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: false }, + }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), + "cal.salesforce.xxx": "true", + }, + eventData: mockEventData, + }); + + expect(result.email).toBe(null); + }); + + it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "owner@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); + + const { responseRecord } = await createRoutingFormWithResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: false }, + }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), + "cal.somethingelse.xxx": "true", + }, + eventData: mockEventData, + }); + + expect(result.email).toBe(null); + }); }); + describe("when queuedFormResponseId is provided", () => { + it("should return teamMember email through booking form handler when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "owner@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); - it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => { - const bookerEmail = "booker@example.com"; - const teamMemberEmail = "owner@example.com"; - mockBookingFormHandler({ bookerEmail, teamMemberEmail }); - await createHostForEvent({ - eventTypeData: { - id: mockEventData.id, - }, - userData: { - email: teamMemberEmail, - }, - }); - - const { responseRecord } = await createRoutingFormWithResponse({ - formData: { - routes: [ - { - action: { - eventTypeId: 1, - type: RouteActionType.EventTypeRedirectUrl, - value: "/team/sales", + const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: false }, }, - attributeRoutingConfig: { skipContactOwner: true }, - }, - ], - }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id, + "cal.salesforce.xxx": "true", + }, + eventData: mockEventData, + }); + + expect(result.email).toBe(teamMemberEmail); }); - const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query: { - email: bookerEmail, - [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), - "cal.salesforce.xxx": "true", - }, - eventData: mockEventData, - }); + it("should return null when skipContactOwner is true even when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "owner@example.com"; + mockBookingFormHandler({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); - // Because skipContactOwner is true, the booking form handler should return null - expect(result.email).toBe(null); - }); - - it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => { - const bookerEmail = "booker@example.com"; - const teamMemberEmail = "ownerNotInEventType@example.com"; - mockBookingFormHandler({ bookerEmail, teamMemberEmail }); - const SOME_OTHER_EVENT_ID = 200; - await createHostForEvent({ - eventTypeData: { - id: SOME_OTHER_EVENT_ID, - }, - userData: { - email: teamMemberEmail, - }, - }); - - const { responseRecord } = await createRoutingFormWithResponse({ - formData: { - routes: [ - { - action: { - eventTypeId: 1, - type: RouteActionType.EventTypeRedirectUrl, - value: "/team/sales", + const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({ + formData: { + routes: [ + { + action: { + eventTypeId: 1, + type: RouteActionType.EventTypeRedirectUrl, + value: "/team/sales", + }, + attributeRoutingConfig: { skipContactOwner: true }, }, - attributeRoutingConfig: { skipContactOwner: false }, - }, - ], - }, + ], + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { + email: bookerEmail, + [ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id, + "cal.salesforce.xxx": "true", + }, + eventData: mockEventData, + }); + + // Because skipContactOwner is true, the booking form handler should return null + expect(result.email).toBe(null); }); - - const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query: { - email: bookerEmail, - [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), - "cal.salesforce.xxx": "true", - }, - eventData: mockEventData, - }); - - expect(result.email).toBe(null); - }); - - it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => { - const bookerEmail = "booker@example.com"; - const teamMemberEmail = "owner@example.com"; - mockBookingFormHandler({ bookerEmail, teamMemberEmail }); - await createHostForEvent({ - eventTypeData: { - id: mockEventData.id, - }, - userData: { - email: teamMemberEmail, - }, - }); - - const { responseRecord } = await createRoutingFormWithResponse({ - formData: { - routes: [ - { - action: { - eventTypeId: 1, - type: RouteActionType.EventTypeRedirectUrl, - value: "/team/sales", - }, - attributeRoutingConfig: { skipContactOwner: false }, - }, - ], - }, - }); - - const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query: { - email: bookerEmail, - [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(), - "cal.somethingelse.xxx": "true", - }, - eventData: mockEventData, - }); - - expect(result.email).toBe(null); }); }); }); diff --git a/packages/app-store/routing-forms/lib/constants.ts b/packages/app-store/routing-forms/lib/constants.ts index ce24478d6f..ab2f232485 100644 --- a/packages/app-store/routing-forms/lib/constants.ts +++ b/packages/app-store/routing-forms/lib/constants.ts @@ -1 +1,2 @@ export const ROUTING_FORM_RESPONSE_ID_QUERY_STRING = "cal.routingFormResponseId"; +export const ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING = "cal.queuedFormResponseId"; diff --git a/packages/embeds/embed-core/routing-playground.html b/packages/embeds/embed-core/routing-playground.html index 5366651509..e1e97a6006 100644 --- a/packages/embeds/embed-core/routing-playground.html +++ b/packages/embeds/embed-core/routing-playground.html @@ -125,6 +125,7 @@

Routing Form - Prerender Headless Router itself queuing the form response + NOTE: Pass query param param.formId=FORM_UID_HERE to test with a particular routing form

1. As page is loading, we prerender the headless router for the email passed as param.email

2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed

3. The prerender of the headless router queues the form response and doesn't really record it. When the actual booking is made, the form response is recorded from the queue

@@ -145,7 +146,8 @@ }); window.routingFormFullPrerender = { buildRouterUrl: ({skills, location, name, email, formId}) => { - return `router?form=${formId}&skills=${skills}&Location=${location}&name=${name}&Email=${email}&Rating=5`; + // Send `email` as well as `Email` because E2e Tests currently use `Email` and `email` is needed by contact owner lookup. It doesn't accept Uppercase Email + return `router?form=${formId}&skills=${skills}&Location=${location}&name=${name}&Email=${email}&email=${email}&Rating=5`; }, onFormSubmit: (e) => { e.preventDefault(); diff --git a/packages/lib/server/getTeamMemberEmailFromCrm.ts b/packages/lib/server/getTeamMemberEmailFromCrm.ts index f10c7beb87..7d18760a2b 100644 --- a/packages/lib/server/getTeamMemberEmailFromCrm.ts +++ b/packages/lib/server/getTeamMemberEmailFromCrm.ts @@ -1,12 +1,19 @@ import type { ParsedUrlQuery } from "querystring"; +/* eslint-disable */ import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; -import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants"; +import { + ROUTING_FORM_RESPONSE_ID_QUERY_STRING, + ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING, +} from "@calcom/app-store/routing-forms/lib/constants"; import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps"; import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types"; import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod"; + +/* eslint-enable */ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -38,16 +45,25 @@ async function findUserByEmailWhoIsAHostOfEventType({ }); } +/** Returns the form response id or the queued response id */ function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) { const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]); const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber; - return routingFormResponseId; + + if (routingFormResponseId) return { routingFormResponseId, queuedFormResponseId: null }; + + const queuedFormResponseId = query[ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]; + if (queuedFormResponseId && typeof queuedFormResponseId === "string") + return { queuedFormResponseId, routingFormResponseId: null }; + + return null; } async function getAttributeRoutingConfig( data: | { - routingFormResponseId: number; + routingFormResponseId?: number | null; + queuedFormResponseId?: string | null; eventTypeId: number; } | { @@ -57,20 +73,14 @@ async function getAttributeRoutingConfig( if ("route" in data) { return data.route.attributeRoutingConfig ?? null; } - const { routingFormResponseId } = data; + const { routingFormResponseId, queuedFormResponseId } = data; + const routingFormResponseRepository = new RoutingFormResponseRepository(prisma); - const routingFormResponseQuery = await prisma.app_RoutingForms_FormResponse.findUnique({ - where: { - id: routingFormResponseId, - }, - include: { - form: { - select: { - routes: true, - }, - }, - }, - }); + const routingFormResponseQuery = routingFormResponseId + ? await routingFormResponseRepository.findFormResponseIncludeForm({ routingFormResponseId }) + : queuedFormResponseId + ? await routingFormResponseRepository.findQueuedFormResponseIncludeForm({ queuedFormResponseId }) + : null; if (!routingFormResponseQuery || !routingFormResponseQuery?.form.routes) return null; const parsedRoutes = routesSchema.safeParse(routingFormResponseQuery?.form.routes); @@ -196,12 +206,14 @@ async function getTeamMemberEmailForResponseOrContact({ bookerEmail, eventData, routingFormResponseId, + queuedFormResponseId, chosenRoute, crmAppSlug, }: { bookerEmail: string; eventData: EventData; routingFormResponseId?: number | null; + queuedFormResponseId?: string | null; /** * If provided, we won't go look for the route from DB. */ @@ -216,11 +228,12 @@ async function getTeamMemberEmailForResponseOrContact({ const eventTypeId = eventData.id; if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return returnNullValue; - const attributeRoutingConfigGetterData = routingFormResponseId - ? { routingFormResponseId, eventTypeId } - : chosenRoute - ? { route: chosenRoute } - : null; + const attributeRoutingConfigGetterData = + routingFormResponseId || queuedFormResponseId + ? { routingFormResponseId, queuedFormResponseId, eventTypeId } + : chosenRoute + ? { route: chosenRoute } + : null; // If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic if (attributeRoutingConfigGetterData && crmAppSlug) { @@ -271,13 +284,15 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query); - const routingFormResponseId = getRoutingFormResponseIdFromQuery(query); + const routingFormResponseIdAndQueuedFormResponseId = getRoutingFormResponseIdFromQuery(query); return await getTeamMemberEmailForResponseOrContact({ bookerEmail: query.email, eventData, - routingFormResponseId, chosenRoute, crmAppSlug, + ...(routingFormResponseIdAndQueuedFormResponseId + ? { ...routingFormResponseIdAndQueuedFormResponseId } + : {}), }); }