diff --git a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts new file mode 100644 index 0000000000..7124dc4413 --- /dev/null +++ b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts @@ -0,0 +1,410 @@ +import prismock from "../../../../tests/libs/__mocks__/prisma"; + +import { v4 } from "uuid"; +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 { RouteActionType } from "@calcom/app-store/routing-forms/zod"; +import { SchedulingType } from "@calcom/prisma/enums"; + +import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "../getTeamMemberEmailFromCrm"; + +vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({ + default: { + salesforce: vi.fn(), + }, +})); + +vi.mock("@calcom/app-store/_utils/CRMRoundRobinSkip", () => ({ + getCRMContactOwnerForRRLeadSkip: vi.fn(), +})); + +function mockGetCRMContactOwnerForRRLeadSkip({ + bookerEmail, + teamMemberEmail, +}: { + bookerEmail: string; + teamMemberEmail: string; +}) { + vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => { + if (_bookerEmail === bookerEmail) { + return Promise.resolve(teamMemberEmail); + } + return Promise.resolve(undefined); + }); +} + +function mockBookingFormHandler({ + bookerEmail, + teamMemberEmail, +}: { + bookerEmail: string; + teamMemberEmail: string; +}) { + vi.mocked(bookingFormHandlers.salesforce).mockImplementation( + (_bookerEmail, _attributeRoutingConfig, _eventTypeId) => { + if (_bookerEmail === bookerEmail) { + return Promise.resolve({ email: teamMemberEmail }); + } + return Promise.resolve({ email: null }); + } + ); +} + +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; + }; + }[]; + }; +}) { + const user = await prismock.user.create({ + data: { + name: "Routing Form User", + username: "routing-form-user", + email: "routing-form-user@example.com", + }, + }); + + const form = await prismock.app_RoutingForms_Form.create({ + data: { + ...formData, + name: "Test Form", + routes: formData.routes.map((route) => ({ + queryValue: { + id: v4(), + type: "group", + children1: [], + properties: {}, + }, + id: v4(), + ...route, + })), + user: { + connect: { + id: user.id, + }, + }, + }, + }); + + const responseRecord = await prismock.app_RoutingForms_FormResponse.create({ + data: { + response: {}, + form: { + connect: { + id: form.id, + }, + }, + }, + }); + + return { form, responseRecord }; +} + +async function createHostForEvent({ + eventTypeData, + userData, +}: { + eventTypeData: { id: number } & Partial<{ + title: string; + slug: string; + length: number; + }>; + userData: { + email: string; + } & Partial<{ + name: string; + username: string; + }>; +}) { + const user = await prismock.user.create({ + data: { + name: "Test User", + username: "testuser", + ...userData, + }, + }); + await prismock.eventType.create({ + data: { + title: "Title", + slug: "slug", + length: 30, + ...eventTypeData, + hosts: { + create: { + userId: user.id, + isFixed: true, + }, + }, + }, + }); +} + +describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const mockEventData = { + id: 1, + isInstantEvent: false, + schedulingType: SchedulingType.ROUND_ROBIN, + metadata: null, + length: 30, + }; + + it("should return null when email is not provided in query", async () => { + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: {}, + eventData: mockEventData, + }); + + expect(result).toBeNull(); + }); + + it("should return null when scheduling type is not ROUND_ROBIN", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "teamMember@example.com"; + mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: teamMemberEmail, + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { email: bookerEmail }, + eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE }, + }); + + expect(result).toBeNull(); + }); + + it("should return CRM owner email when valid", async () => { + const ownerEmail = "owner@example.com"; + const bookerEmail = "booker@example.com"; + mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail }); + await createHostForEvent({ + eventTypeData: { + id: mockEventData.id, + }, + userData: { + email: ownerEmail, + }, + }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { email: "booker@example.com" }, + eventData: mockEventData, + }); + + expect(result).toBe(ownerEmail); + }); + + it("should return null when CRM owner is not found", async () => { + const bookerEmail = "booker@example.com"; + const teamMemberEmail = "teamMember@example.com"; + mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { email: bookerEmail }, + eventData: mockEventData, + }); + + expect(result).toBeNull(); + }); + + it("should return null when CRM owner is not part of event type", async () => { + const ownerEmail = "owner@example.com"; + const bookerEmail = "booker@example.com"; + mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail }); + + const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: { email: bookerEmail }, + eventData: mockEventData, + }); + + expect(result).toBeNull(); + }); + + 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, + }, + }); + + 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).toBe(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", + }, + 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).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 = "owner@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).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).toBe(null); + }); + }); +}); diff --git a/apps/web/lib/getTeamMemberEmailFromCrm.ts b/apps/web/lib/getTeamMemberEmailFromCrm.ts index 95dbb5ad13..314a98d869 100644 --- a/apps/web/lib/getTeamMemberEmailFromCrm.ts +++ b/apps/web/lib/getTeamMemberEmailFromCrm.ts @@ -21,6 +21,21 @@ interface EventData { length: number; } +async function findUserByEmailWhoIsAHostOfEventType({ + email, + eventTypeId, +}: { + email: string; + eventTypeId: number; +}) { + return prisma.user.findFirst({ + where: { + email, + hosts: { some: { eventTypeId } }, + }, + }); +} + function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) { const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]); const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber; @@ -99,16 +114,11 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) { async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise { const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); if (!crmContactOwnerEmail) return null; + // Determine if the contactOwner is a part of the event type - const contactOwnerQuery = await prisma.user.findFirst({ - where: { - email: crmContactOwnerEmail, - hosts: { - some: { - eventTypeId: eventData.id, - }, - }, - }, + const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({ + email: crmContactOwnerEmail, + eventTypeId: eventData.id, }); if (!contactOwnerQuery) return null; return crmContactOwnerEmail; @@ -138,7 +148,6 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler")) .default; const appHandler = appBookingFormHandler[crmAppSlug]; - if (!appHandler) return nullReturnValue; const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId); @@ -146,16 +155,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ if (!userEmail) return nullReturnValue; // Determine if the user is a part of the event type - const userQuery = await prisma.user.findFirst({ - where: { - email: userEmail, - hosts: { - some: { - eventTypeId: eventTypeId, - }, - }, - }, - }); + const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId }); if (!userQuery) return nullReturnValue; @@ -164,14 +164,12 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ async function getTeamMemberEmailForResponseOrContact({ bookerEmail, - eventTypeId, eventData, routingFormResponseId, chosenRoute, crmAppSlug, }: { bookerEmail: string; - eventTypeId: number; eventData: EventData; routingFormResponseId?: number | null; /** @@ -180,6 +178,7 @@ async function getTeamMemberEmailForResponseOrContact({ chosenRoute?: LocalRoute; crmAppSlug?: string; }) { + const eventTypeId = eventData.id; if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null; const attributeRoutingConfigGetterData = routingFormResponseId @@ -214,12 +213,10 @@ async function getTeamMemberEmailForResponseOrContact({ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ query, - eventTypeId, eventData, chosenRoute, }: { query: ParsedUrlQuery; - eventTypeId: number; eventData: EventData; chosenRoute?: LocalRoute; }) { @@ -236,7 +233,6 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ return await getTeamMemberEmailForResponseOrContact({ bookerEmail: query.email, - eventTypeId, eventData, routingFormResponseId, chosenRoute, diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 640cd7283b..5675cf36cc 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -114,7 +114,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => orgBannerUrl: team.parent?.bannerUrl ?? "", teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ query, - eventTypeId, eventData, }), }, diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts index f89e9026ab..0e32a3bf47 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts @@ -214,7 +214,6 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({ const timeBeforeCrm = performance.now(); const contactOwnerEmail = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()), - eventTypeId: eventType.id, eventData: eventType, chosenRoute: route, });