feat: Record Salesforce ownership assignment (#17775)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -18,6 +18,7 @@ export interface Contact {
|
||||
email: string;
|
||||
ownerId?: string;
|
||||
ownerEmail?: string;
|
||||
recordType?: string;
|
||||
}
|
||||
|
||||
export interface CrmEvent {
|
||||
|
||||
Reference in New Issue
Block a user