From 149064226017c9bade7ebc480acbdfcce02da25a Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 21 Nov 2024 16:22:39 -0500 Subject: [PATCH] feat: Record Salesforce ownership assignment (#17775) --- .../getTeamMemberEmailFromCrm.test.ts | 28 ++++---- apps/web/lib/getTeamMemberEmailFromCrm.ts | 35 +++++---- .../team/[slug]/[type]/getServerSideProps.tsx | 16 +++-- apps/web/modules/team/type-view.tsx | 4 ++ .../app-store/_utils/CRMRoundRobinSkip.ts | 19 +++-- .../routing-forms/appBookingFormHandler.ts | 2 +- .../app-store/salesforce/lib/CrmService.ts | 43 ++++++++--- .../salesforce/lib/assignmentReasonHandler.ts | 72 +++++++++++++++++++ packages/app-store/salesforce/lib/enums.ts | 7 ++ .../lib/routingFormBookingFormHandler.ts | 8 +-- packages/features/bookings/Booker/store.ts | 18 ++++- packages/features/bookings/Booker/types.ts | 2 + .../booking-to-mutation-input-mapper.tsx | 6 ++ .../features/bookings/lib/handleNewBooking.ts | 25 +++++-- .../AssignmentReasonRecorder.ts | 30 +++++++- .../appAssignmentReasonHandler.ts | 18 +++++ .../hooks/bookings/useHandleBookEvent.ts | 4 ++ packages/prisma/zod-utils.ts | 2 + ...rsMatchingAttributeLogicOfRoute.handler.ts | 2 +- packages/types/CrmService.d.ts | 1 + 20 files changed, 279 insertions(+), 63 deletions(-) create mode 100644 packages/app-store/salesforce/lib/assignmentReasonHandler.ts create mode 100644 packages/features/ee/round-robin/assignmentReason/appAssignmentReasonHandler.ts diff --git a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts index 2c06159ca7..aded368c30 100644 --- a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts +++ b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts @@ -30,9 +30,9 @@ function mockGetCRMContactOwnerForRRLeadSkip({ }) { vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => { if (_bookerEmail === bookerEmail) { - return Promise.resolve(teamMemberEmail); + return Promise.resolve({ email: teamMemberEmail, recordType: null, crmAppSlug: null }); } - return Promise.resolve(undefined); + return Promise.resolve({ email: null, recordType: null, crmAppSlug: null }); }); } @@ -46,9 +46,9 @@ function mockBookingFormHandler({ vi.mocked(bookingFormHandlers.salesforce).mockImplementation( (_bookerEmail, _attributeRoutingConfig, _eventTypeId) => { if (_bookerEmail === bookerEmail) { - return Promise.resolve({ email: teamMemberEmail }); + return Promise.resolve({ email: teamMemberEmail, recordType: null }); } - return Promise.resolve({ email: null }); + return Promise.resolve({ email: null, recordType: null }); } ); } @@ -171,7 +171,9 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBeNull(); + const ownerEmail = result.email; + + expect(ownerEmail).toBeNull(); }); it("should return null when scheduling type is not ROUND_ROBIN", async () => { @@ -192,7 +194,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE }, }); - expect(result).toBeNull(); + expect(result.email).toBeNull(); }); it("should return CRM owner email when valid", async () => { @@ -213,7 +215,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBe(ownerEmail); + expect(result.email).toBe(ownerEmail); }); it("should return null when CRM owner is not found", async () => { @@ -226,7 +228,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBeNull(); + expect(result.email).toBeNull(); }); it("should return null when CRM owner is not part of event type", async () => { @@ -239,7 +241,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBeNull(); + expect(result.email).toBeNull(); }); describe("Booking form handler", () => { @@ -280,7 +282,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBe(teamMemberEmail); + expect(result.email).toBe(teamMemberEmail); }); it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => { @@ -321,7 +323,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { }); // Because skipContactOwner is true, the booking form handler should return null - expect(result).toBe(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 () => { @@ -362,7 +364,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBe(null); + expect(result.email).toBe(null); }); it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => { @@ -402,7 +404,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => { eventData: mockEventData, }); - expect(result).toBe(null); + expect(result.email).toBe(null); }); }); }); diff --git a/apps/web/lib/getTeamMemberEmailFromCrm.ts b/apps/web/lib/getTeamMemberEmailFromCrm.ts index 314a98d869..3237cbcc0a 100644 --- a/apps/web/lib/getTeamMemberEmailFromCrm.ts +++ b/apps/web/lib/getTeamMemberEmailFromCrm.ts @@ -21,6 +21,8 @@ interface EventData { length: number; } +const returnNullValue = { email: null, recordType: null, crmAppSlug: null }; + async function findUserByEmailWhoIsAHostOfEventType({ email, eventTypeId, @@ -111,17 +113,20 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) { /** * Uses the owner of the contact directly from CRM */ -async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise { - const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); - if (!crmContactOwnerEmail) return null; +async function getOwnerEmailFromCrm( + eventData: EventData, + email: string +): Promise<{ email: string | null; recordType: string | null; crmAppSlug: string | null }> { + const crmContactOwner = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); + if (!crmContactOwner?.email) return returnNullValue; // Determine if the contactOwner is a part of the event type const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({ - email: crmContactOwnerEmail, + email: crmContactOwner.email, eventTypeId: eventData.id, }); - if (!contactOwnerQuery) return null; - return crmContactOwnerEmail; + if (!contactOwnerQuery) return returnNullValue; + return crmContactOwner; } /** @@ -138,7 +143,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ attributeRoutingConfig: AttributeRoutingConfig | null; crmAppSlug: string; }) { - const nullReturnValue = { email: null, skipContactOwner: false }; + const nullReturnValue = { email: null, skipContactOwner: false, recordType: "" }; if (!attributeRoutingConfig) return nullReturnValue; @@ -150,7 +155,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ const appHandler = appBookingFormHandler[crmAppSlug]; if (!appHandler) return nullReturnValue; - const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId); + const { email: userEmail, recordType } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId); if (!userEmail) return nullReturnValue; @@ -159,7 +164,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({ if (!userQuery) return nullReturnValue; - return { ...nullReturnValue, email: userEmail }; + return { ...nullReturnValue, email: userEmail, recordType }; } async function getTeamMemberEmailForResponseOrContact({ @@ -179,7 +184,7 @@ async function getTeamMemberEmailForResponseOrContact({ crmAppSlug?: string; }) { const eventTypeId = eventData.id; - if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null; + if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return returnNullValue; const attributeRoutingConfigGetterData = routingFormResponseId ? { routingFormResponseId, eventTypeId } @@ -194,21 +199,21 @@ async function getTeamMemberEmailForResponseOrContact({ safeStringify({ attributeRoutingConfigGetterData, crmAppSlug }) ); const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData); - const { email, skipContactOwner } = await getTeamMemberEmailUsingRoutingFormHandler({ + const { email, skipContactOwner, recordType } = await getTeamMemberEmailUsingRoutingFormHandler({ bookerEmail, eventTypeId, attributeRoutingConfig, crmAppSlug, }); - if (skipContactOwner) return null; - if (email) return email; + if (skipContactOwner) return returnNullValue; + if (email) return { email, recordType, crmAppSlug }; } else { log.debug("Getting the contact owner email from CRM"); return await getOwnerEmailFromCrm(eventData, bookerEmail); } - return null; + return returnNullValue; } export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ @@ -222,7 +227,7 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ }) { // Without email no lookup is possible if (!query.email || typeof query.email !== "string") { - return null; + return returnNullValue; } log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query })); diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 5675cf36cc..2b0873492a 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -89,6 +89,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( "@calcom/web/lib/getTeamMemberEmailFromCrm" ); + const { + email: teamMemberEmail, + recordType: crmOwnerRecordType, + crmAppSlug, + } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query, + eventData, + }); + return { props: { eventData: { @@ -112,10 +121,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => isInstantMeeting: eventData && queryIsInstantMeeting ? true : false, themeBasis: null, orgBannerUrl: team.parent?.bannerUrl ?? "", - teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query, - eventData, - }), + teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }, }; }; diff --git a/apps/web/modules/team/type-view.tsx b/apps/web/modules/team/type-view.tsx index f063f87577..3926f67658 100644 --- a/apps/web/modules/team/type-view.tsx +++ b/apps/web/modules/team/type-view.tsx @@ -32,6 +32,8 @@ function Type({ isInstantMeeting, orgBannerUrl, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }: PageProps) { const searchParams = useSearchParams(); @@ -65,6 +67,8 @@ function Type({ )} orgBannerUrl={orgBannerUrl} teamMemberEmail={teamMemberEmail} + crmOwnerRecordType={crmOwnerRecordType} + crmAppSlug={crmAppSlug} /> ); diff --git a/packages/app-store/_utils/CRMRoundRobinSkip.ts b/packages/app-store/_utils/CRMRoundRobinSkip.ts index be062bf7b2..b0a58e6383 100644 --- a/packages/app-store/_utils/CRMRoundRobinSkip.ts +++ b/packages/app-store/_utils/CRMRoundRobinSkip.ts @@ -10,19 +10,21 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; export async function getCRMContactOwnerForRRLeadSkip( bookerEmail: string, eventTypeMetadata: Prisma.JsonValue -): Promise { +): Promise<{ email: string | null; recordType: string | null; crmAppSlug: string | null }> { + const nullReturnValue = { email: null, recordType: null, crmAppSlug: "" }; const parsedEventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventTypeMetadata); - if (!parsedEventTypeMetadata.success || !parsedEventTypeMetadata.data?.apps) return; + if (!parsedEventTypeMetadata.success || !parsedEventTypeMetadata.data?.apps) return nullReturnValue; const crm = await getCRMManagerWithRRLeadSkip(parsedEventTypeMetadata.data.apps); - if (!crm) return; + if (!crm) return nullReturnValue; + const { crmManager, crmAppSlug } = crm; const startTime = performance.now(); - const contact = await crm.getContacts({ emails: bookerEmail, forRoundRobinSkip: true }); + const contact = await crmManager.getContacts({ emails: bookerEmail, forRoundRobinSkip: true }); const endTime = performance.now(); logger.info(`Fetching from CRM took ${endTime - startTime}ms`); - if (!contact?.length) return; - return contact[0].ownerEmail; + if (!contact?.length || !contact[0].email) return nullReturnValue; + return { email: contact[0].ownerEmail ?? null, recordType: contact[0].recordType ?? null, crmAppSlug }; } async function getCRMManagerWithRRLeadSkip(apps: z.infer) { @@ -53,5 +55,8 @@ async function getCRMManagerWithRRLeadSkip(apps: z.infer Promise<{ email: string | null }>; +) => Promise<{ email: string | null; recordType: string | null }>; const appBookingFormHandler: Record = { salesforce: routingFormBookingFormHandler, diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index 3e453c7f99..1f54439495 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -16,7 +16,13 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; import { default as appMeta } from "../config.json"; -import { SalesforceRecordEnum, SalesforceFieldType, WhenToWriteToRecord, DateFieldTypeData } from "./enums"; +import { + SalesforceRecordEnum, + SalesforceFieldType, + WhenToWriteToRecord, + DateFieldTypeData, + RoutingReasons, +} from "./enums"; type ExtendedTokenResponse = TokenResponse & { instance_url: string; @@ -328,6 +334,7 @@ export default class SalesforceCRMService implements CRM { (forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ?? SalesforceRecordEnum.CONTACT; let soql: string; + let accountOwnerId = ""; if (recordToSearch === SalesforceRecordEnum.ACCOUNT) { // For an account let's assume that the first email is the one we should be querying against const attendeeEmail = emailArray[0]; @@ -355,6 +362,12 @@ export default class SalesforceCRMService implements CRM { } const results = await conn.query(soql); + // If we're checking against the contact, the ownerId should take precedence + if (recordToSearch === SalesforceRecordEnum.ACCOUNT && results.records.length) { + const account = results.records[0] as ContactRecord; + accountOwnerId = account.OwnerId; + } + let records: ContactRecord[] = []; // If falling back to contacts, check for the contact before returning the leads or empty array @@ -381,19 +394,30 @@ export default class SalesforceCRMService implements CRM { if (includeOwner || forRoundRobinSkip) { const ownerIds: Set = new Set(); - records.forEach((record) => { - ownerIds.add(record.OwnerId); - }); + if (accountOwnerId) { + ownerIds.add(accountOwnerId); + } else { + records.forEach((record) => { + ownerIds.add(record.OwnerId); + }); + } const ownersQuery = (await Promise.all( Array.from(ownerIds).map(async (ownerId) => { - return this.getSalesforceUserFromUserId(ownerId); + return await this.getSalesforceUserFromUserId(ownerId); }) )) as { records: ContactRecord[] }[]; const contactsWithOwners = records.map((record) => { - const ownerEmail = ownersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0] - .Email; - return { id: record.Id, email: record.Email, ownerId: record.OwnerId, ownerEmail }; + const ownerEmail = accountOwnerId + ? ownersQuery[0].records[0].Email + : ownersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0].Email; + return { + id: record.Id, + email: record.Email, + ownerId: record.OwnerId, + ownerEmail, + recordType: accountOwnerId ? SalesforceRecordEnum.ACCOUNT : record.attributes.type, + }; }); return contactsWithOwners; } @@ -402,6 +426,7 @@ export default class SalesforceCRMService implements CRM { ? records.map((record) => ({ id: record.Id, email: record.Email, + recordType: record.attributes.type, })) : []; } @@ -1010,7 +1035,7 @@ export default class SalesforceCRMService implements CRM { const user = userQuery.records[0] as { Email: string }; - return user.Email; + return { email: user.Email, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD }; } } } diff --git a/packages/app-store/salesforce/lib/assignmentReasonHandler.ts b/packages/app-store/salesforce/lib/assignmentReasonHandler.ts new file mode 100644 index 0000000000..60cf849b24 --- /dev/null +++ b/packages/app-store/salesforce/lib/assignmentReasonHandler.ts @@ -0,0 +1,72 @@ +import { zodRoutes } from "@calcom/app-store/routing-forms/zod"; +import prisma from "@calcom/prisma"; +import { AssignmentReasonEnum } from "@calcom/prisma/enums"; + +import { RoutingReasons, SalesforceRecordEnum } from "./enums"; + +export async function assignmentReasonHandler({ + recordType, + teamMemberEmail, + routingFormResponseId, +}: { + recordType: string; + teamMemberEmail: string; + routingFormResponseId: number; +}) { + const returnObject = { reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT }; + + switch (recordType) { + case SalesforceRecordEnum.CONTACT: + return { ...returnObject, assignmentReason: `Salesforce contact owner: ${teamMemberEmail}` }; + case SalesforceRecordEnum.LEAD: + return { ...returnObject, assignmentReason: `Salesforce lead owner: ${teamMemberEmail}` }; + case SalesforceRecordEnum.ACCOUNT: + return { ...returnObject, assignmentReason: `Salesforce account owner: ${teamMemberEmail}` }; + case RoutingReasons.ACCOUNT_LOOKUP_FIELD: + const assignmentReason = await handleAccountLookupFieldReason(routingFormResponseId, teamMemberEmail); + return { ...returnObject, assignmentReason }; + } +} + +async function handleAccountLookupFieldReason(routingFormResponseId: number, teamMemberEmail: string) { + const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({ + where: { + id: routingFormResponseId, + }, + select: { + chosenRouteId: true, + form: { + select: { + routes: true, + }, + }, + }, + }); + + if (!routingFormResponse) return; + + const { form } = routingFormResponse; + if (!form.routes) return; + + const parsedRoutes = zodRoutes.safeParse(form.routes); + + if (!parsedRoutes.success || !parsedRoutes.data) return; + + const chosenRouteId = routingFormResponse.chosenRouteId; + + const takenRoute = parsedRoutes.data.find((route) => route.id === chosenRouteId); + + if (!takenRoute || !("attributeRoutingConfig" in takenRoute)) return; + + const attributeRoutingConfig = takenRoute?.attributeRoutingConfig; + + if (!attributeRoutingConfig) return; + + const salesforceConfig = attributeRoutingConfig["salesforce"]; + + const accountLookupFieldName = salesforceConfig?.rrSKipToAccountLookupFieldName; + + return accountLookupFieldName + ? `Salesforce account lookup field: ${accountLookupFieldName} - ${teamMemberEmail}` + : undefined; +} diff --git a/packages/app-store/salesforce/lib/enums.ts b/packages/app-store/salesforce/lib/enums.ts index e1c35e6dd9..d345bf2a42 100644 --- a/packages/app-store/salesforce/lib/enums.ts +++ b/packages/app-store/salesforce/lib/enums.ts @@ -19,3 +19,10 @@ export enum DateFieldTypeData { BOOKING_START_DATE = "booking_start_date", BOOKING_CREATED_DATE = "booking_created_date", } + +export enum RoutingReasons { + ACCOUNT_LOOKUP_FIELD = "account_lookup_field", + LEAD_OWNER = "lead_owner", + CONTACT_OWNER = "contact_owner", + ACCOUNT_OWNER = "account_owner", +} diff --git a/packages/app-store/salesforce/lib/routingFormBookingFormHandler.ts b/packages/app-store/salesforce/lib/routingFormBookingFormHandler.ts index cacfe21a3c..805cdeffb2 100644 --- a/packages/app-store/salesforce/lib/routingFormBookingFormHandler.ts +++ b/packages/app-store/salesforce/lib/routingFormBookingFormHandler.ts @@ -2,7 +2,7 @@ import { CredentialRepository } from "../../../lib/server/repository/credential" import { EventTypeService } from "../../../lib/server/service/eventType"; import type { AttributeRoutingConfig } from "../../routing-forms/types/types"; import SalesforceCRMService from "./CrmService"; -import { SalesforceRecordEnum } from "./enums"; +import { SalesforceRecordEnum, RoutingReasons } from "./enums"; const routingFormBookingFormHandler = async ( attendeeEmail: string, @@ -16,7 +16,7 @@ const routingFormBookingFormHandler = async ( !salesforceSettings.rrSkipToAccountLookupField || !salesforceSettings.rrSKipToAccountLookupFieldName ) - return { email: null }; + return { email: null, recordType: null }; const appData = await EventTypeService.getEventTypeAppDataFromId(eventTypeId, "salesforce"); @@ -24,7 +24,7 @@ const routingFormBookingFormHandler = async ( const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: credentialId }); - if (!credential) return { email: null }; + if (!credential) return { email: null, recordType: null }; const crm = new SalesforceCRMService(credential, {}); @@ -34,7 +34,7 @@ const routingFormBookingFormHandler = async ( SalesforceRecordEnum.ACCOUNT ); - return { email: userLookupEmail ?? null }; + return { email: userLookupEmail?.email ?? null, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD as string }; }; export default routingFormBookingFormHandler; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index f54dbfcc3e..5cc7165e80 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -33,6 +33,8 @@ type StoreInitializeType = { org?: string | null; isInstantMeeting?: boolean; teamMemberEmail?: string | null; + crmOwnerRecordType?: string | null; + crmAppSlug?: string | null; }; type SeatedEventData = { @@ -151,6 +153,8 @@ export type BookerStore = { setOrg: (org: string | null | undefined) => void; teamMemberEmail?: string | null; + crmOwnerRecordType?: string | null; + crmAppSlug?: string | null; }; /** @@ -257,6 +261,8 @@ export const useBookerStore = create((set, get) => ({ org, isInstantMeeting, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }: StoreInitializeType) => { const selectedDateInStore = get().selectedDate; @@ -270,7 +276,9 @@ export const useBookerStore = create((set, get) => ({ get().bookingData?.responses.email === bookingData?.responses.email && get().layout === layout && get().rescheduledBy === rescheduledBy && - get().teamMemberEmail + get().teamMemberEmail === teamMemberEmail && + get().crmOwnerRecordType === crmOwnerRecordType && + get().crmAppSlug ) return; set({ @@ -290,6 +298,8 @@ export const useBookerStore = create((set, get) => ({ selectedDateInStore || (["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null), teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }); if (durationConfig?.includes(Number(getQueryParam("duration")))) { @@ -379,6 +389,8 @@ export const useInitializeBookerStore = ({ org, isInstantMeeting, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }: StoreInitializeType) => { const initializeStore = useBookerStore((state) => state.initialize); useEffect(() => { @@ -397,6 +409,8 @@ export const useInitializeBookerStore = ({ durationConfig, isInstantMeeting, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, }); }, [ initializeStore, @@ -414,5 +428,7 @@ export const useInitializeBookerStore = ({ durationConfig, isInstantMeeting, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, ]); }; diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 1c9ce2f145..36ab95304b 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -88,6 +88,8 @@ export interface BookerProps { hashedLink?: string | null; isInstantMeeting?: boolean; teamMemberEmail?: string | null; + crmOwnerRecordType?: string | null; + crmAppSlug?: string | null; } export type WrappedBookerPropsMain = { 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 0a362eccd0..465ca3aae6 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 @@ -22,6 +22,8 @@ export type BookingOptions = { seatReferenceUid?: string; hashedLink?: string | null; teamMemberEmail?: string | null; + crmOwnerRecordType?: string | null; + crmAppSlug?: string | null; orgSlug?: string; }; @@ -40,6 +42,8 @@ export const mapBookingToMutationInput = ({ seatReferenceUid, hashedLink, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, orgSlug, }: BookingOptions): BookingCreateBody => { const searchParams = new URLSearchParams(window.location.search); @@ -70,6 +74,8 @@ export const mapBookingToMutationInput = ({ seatReferenceUid, hashedLink, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, orgSlug, routedTeamMemberIds, routingFormResponseId, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 052bd5cdf5..b233a7a618 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1166,13 +1166,24 @@ async function handler( await usersRepository.updateLastActiveAt(booking.userId); } - if (routingFormResponseId && teamId) { - await AssignmentReasonRecorder.routingFormRoute({ - bookingId: booking.id, - routingFormResponseId, - organizerId: organizerUser.id, - teamId, - }); + // If it's a round robin event, record the reason for the host assignment + if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) { + await AssignmentReasonRecorder.CRMOwnership({ + bookingId: booking.id, + crmAppSlug: reqBody.crmAppSlug, + teamMemberEmail: contactOwnerEmail, + recordType: reqBody.crmOwnerRecordType, + routingFormResponseId, + }); + } else if (routingFormResponseId && teamId) { + await AssignmentReasonRecorder.routingFormRoute({ + bookingId: booking.id, + routingFormResponseId, + organizerId: organizerUser.id, + teamId, + }); + } } diff --git a/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts index 435bf8a3cb..98116c4c72 100644 --- a/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts +++ b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts @@ -109,7 +109,35 @@ export default class AssignmentReasonRecorder { // Separate method to handle rerouting - // Routing via CRM ownership, need to keep each specific CRM logic in it's own package + static async CRMOwnership({ + bookingId, + crmAppSlug, + teamMemberEmail, + recordType, + routingFormResponseId, + }: { + bookingId: number; + crmAppSlug: string; + teamMemberEmail: string; + recordType: string; + routingFormResponseId: number; + }) { + const appAssignmentReasonHandler = (await import("./appAssignmentReasonHandler")).default; + const appHandler = appAssignmentReasonHandler[crmAppSlug]; + if (!appHandler) return; + + const crmRoutingReason = await appHandler({ recordType, teamMemberEmail, routingFormResponseId }); + + if (!crmRoutingReason || !crmRoutingReason.assignmentReason) return; + + await prisma.assignmentReason.create({ + data: { + bookingId, + reasonEnum: crmRoutingReason.reasonEnum, + reasonString: crmRoutingReason.assignmentReason, + }, + }); + } static async roundRobinReassignment({ bookingId, diff --git a/packages/features/ee/round-robin/assignmentReason/appAssignmentReasonHandler.ts b/packages/features/ee/round-robin/assignmentReason/appAssignmentReasonHandler.ts new file mode 100644 index 0000000000..39932c0ed4 --- /dev/null +++ b/packages/features/ee/round-robin/assignmentReason/appAssignmentReasonHandler.ts @@ -0,0 +1,18 @@ +import { assignmentReasonHandler as salesforceAssignmentReasonHandler } from "@calcom/app-store/salesforce/lib/assignmentReasonHandler"; +import type { AssignmentReasonEnum } from "@calcom/prisma/enums"; + +type AppAssignmentReasonHandler = ({ + recordType, + teamMemberEmail, + routingFormResponseId, +}: { + recordType: string; + teamMemberEmail: string; + routingFormResponseId: number; +}) => Promise<{ assignmentReason: string | undefined; reasonEnum: AssignmentReasonEnum } | undefined>; + +const appBookingFormHandler: Record = { + salesforce: salesforceAssignmentReasonHandler, +}; + +export default appBookingFormHandler; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 48ff5a136c..42d9f75b01 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -52,6 +52,8 @@ export const useHandleBookEvent = ({ const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const orgSlug = useBookerStore((state) => state.org); const teamMemberEmail = useBookerStore((state) => state.teamMemberEmail); + const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType); + const crmAppSlug = useBookerStore((state) => state.crmAppSlug); const handleBookEvent = () => { const values = bookingForm.getValues(); @@ -91,6 +93,8 @@ export const useHandleBookEvent = ({ metadata: metadata, hashedLink, teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, orgSlug: orgSlug ? orgSlug : undefined, }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index adfccfa328..09362b0445 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -251,9 +251,11 @@ export const bookingCreateBodySchema = z.object({ seatReferenceUid: z.string().optional(), orgSlug: z.string().optional(), teamMemberEmail: z.string().nullish(), + crmOwnerRecordType: z.string().nullish(), routedTeamMemberIds: z.array(z.number()).nullish(), routingFormResponseId: z.number().optional(), skipContactOwner: z.boolean().optional(), + crmAppSlug: z.string().nullish().optional(), /** * Holds the corrected responses of the Form for a booking, provided during rerouting diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts index ca90c9f375..407bbd88bf 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts @@ -215,7 +215,7 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({ }); const timeBeforeCrm = performance.now(); - const contactOwnerEmail = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + const { email: contactOwnerEmail } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()), eventData: eventType, chosenRoute: route, diff --git a/packages/types/CrmService.d.ts b/packages/types/CrmService.d.ts index a4275ca2bb..b6fa02e326 100644 --- a/packages/types/CrmService.d.ts +++ b/packages/types/CrmService.d.ts @@ -18,6 +18,7 @@ export interface Contact { email: string; ownerId?: string; ownerEmail?: string; + recordType?: string; } export interface CrmEvent {