feat: Record Salesforce ownership assignment (#17775)

This commit is contained in:
Joe Au-Yeung
2024-11-21 14:22:39 -07:00
committed by GitHub
parent 04b49acbb0
commit 1490642260
20 changed files with 279 additions and 63 deletions
@@ -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);
});
});
});
+20 -15
View File
@@ -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<string | null> {
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 }));
@@ -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,
},
};
};
+4
View File
@@ -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}
/>
</main>
);
+12 -7
View File
@@ -10,19 +10,21 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export async function getCRMContactOwnerForRRLeadSkip(
bookerEmail: string,
eventTypeMetadata: Prisma.JsonValue
): Promise<string | undefined> {
): 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<typeof EventTypeAppMetadataSchema>) {
@@ -53,5 +55,8 @@ async function getCRMManagerWithRRLeadSkip(apps: z.infer<typeof EventTypeAppMeta
},
});
if (!crmCredential) return;
return new CrmManager(crmCredential, crmRoundRobinLeadSkip);
return {
crmManager: new CrmManager(crmCredential, crmRoundRobinLeadSkip),
crmAppSlug: crmCredential.appId,
};
}
@@ -5,7 +5,7 @@ type AppBookingFormHandler = (
attendeeEmail: string,
attributeRoutingConfig: AttributeRoutingConfig,
eventTypeId: number
) => Promise<{ email: string | null }>;
) => Promise<{ email: string | null; recordType: string | null }>;
const appBookingFormHandler: Record<string, AppBookingFormHandler> = {
salesforce: routingFormBookingFormHandler,
@@ -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<string> = 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 };
}
}
}
@@ -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;
}
@@ -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",
}
@@ -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;
+17 -1
View File
@@ -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<BookerStore>((set, get) => ({
org,
isInstantMeeting,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
@@ -270,7 +276,9 @@ export const useBookerStore = create<BookerStore>((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<BookerStore>((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,
]);
};
@@ -88,6 +88,8 @@ export interface BookerProps {
hashedLink?: string | null;
isInstantMeeting?: boolean;
teamMemberEmail?: string | null;
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
}
export type WrappedBookerPropsMain = {
@@ -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,
@@ -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,
});
}
}
@@ -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,
@@ -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<string, AppAssignmentReasonHandler> = {
salesforce: salesforceAssignmentReasonHandler,
};
export default appBookingFormHandler;
@@ -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,
};
+2
View File
@@ -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
@@ -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,
+1
View File
@@ -18,6 +18,7 @@ export interface Contact {
email: string;
ownerId?: string;
ownerEmail?: string;
recordType?: string;
}
export interface CrmEvent {