-
Notifications
You must be signed in to change notification settings - Fork 8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Salesforce RR skip based on a user on a lookup field on an account #17526
Changes from all commits
feb63fd
90ada06
0e407eb
03583d4
dd859b0
071cf74
89191c6
20fcf6c
df3f4d5
b604055
ebcc41d
93e358b
868f302
2076d23
968d0b3
778382b
54a724a
cd03f79
74d72e9
7218477
f058483
8fbc654
def526d
2c72ee8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,12 @@ | ||
import type { Prisma } from "@prisma/client"; | ||
import type { GetServerSidePropsContext } from "next"; | ||
import type { ParsedUrlQuery } from "querystring"; | ||
import { z } from "zod"; | ||
|
||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; | ||
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants"; | ||
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps"; | ||
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod"; | ||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; | ||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; | ||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; | ||
|
@@ -79,6 +83,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => | |
} | ||
|
||
const eventData = team.eventTypes[0]; | ||
const eventTypeId = eventData.id; | ||
|
||
let booking: GetBookingType | null = null; | ||
if (rescheduleUid) { | ||
|
@@ -92,7 +97,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => | |
return { | ||
props: { | ||
eventData: { | ||
eventTypeId: eventData.id, | ||
eventTypeId, | ||
entity: { | ||
fromRedirectOfNonOrgLink, | ||
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink, | ||
|
@@ -112,21 +117,133 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => | |
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false, | ||
themeBasis: null, | ||
orgBannerUrl: team.parent?.bannerUrl ?? "", | ||
teamMemberEmail: await getTeamMemberEmail(eventData, email as string), | ||
teamMemberEmail: await handleGettingTeamMemberEmail(query, eventTypeId, eventData), | ||
}, | ||
}; | ||
}; | ||
|
||
async function getTeamMemberEmail( | ||
eventData: { | ||
id: number; | ||
isInstantEvent: boolean; | ||
schedulingType: SchedulingType | null; | ||
metadata: Prisma.JsonValue | null; | ||
length: number; | ||
}, | ||
email?: string | ||
): Promise<string | null> { | ||
interface EventData { | ||
id: number; | ||
isInstantEvent: boolean; | ||
schedulingType: SchedulingType | null; | ||
metadata: Prisma.JsonValue | null; | ||
length: number; | ||
} | ||
|
||
async function handleGettingTeamMemberEmail( | ||
query: ParsedUrlQuery, | ||
eventTypeId: number, | ||
eventData: EventData | ||
) { | ||
if ( | ||
!query.email || | ||
typeof query.email !== "string" || | ||
eventData.schedulingType !== SchedulingType.ROUND_ROBIN | ||
) | ||
Comment on lines
+138
to
+142
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We only need the |
||
return null; | ||
|
||
// Check if a routing form was completed and an routing form option is enabled | ||
if ( | ||
ROUTING_FORM_RESPONSE_ID_QUERY_STRING in query && | ||
Object.values(query).some((value) => value === "true") | ||
) { | ||
const { email, skipContactOwner } = await handleRoutingFormOption(query, eventTypeId); | ||
|
||
if (skipContactOwner) return null; | ||
if (email) return email; | ||
} else { | ||
return await getTeamMemberEmail(eventData, query.email); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
async function handleRoutingFormOption(query: ParsedUrlQuery, eventTypeId: number) { | ||
const nullReturnValue = { email: null, skipContactOwner: false }; | ||
|
||
if (typeof query.email !== "string") return nullReturnValue; | ||
|
||
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The app specific options live in the routing form data. I didn't want to leak any Salesforce specific information in the URL |
||
where: { | ||
responses: { | ||
some: { | ||
id: Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]), | ||
}, | ||
}, | ||
}, | ||
select: { | ||
routes: true, | ||
}, | ||
}); | ||
|
||
if (!routingFormQuery || !routingFormQuery?.routes) return nullReturnValue; | ||
|
||
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes); | ||
|
||
if (!parsedRoutes.success || !parsedRoutes.data) return nullReturnValue; | ||
|
||
// Find the route with the attributeRoutingConfig | ||
const route = parsedRoutes.data.find((route) => { | ||
if ("action" in route) { | ||
return route.action.eventTypeId === eventTypeId; | ||
} | ||
}); | ||
|
||
if (!route || !("attributeRoutingConfig" in route)) return nullReturnValue; | ||
|
||
// Get attributeRoutingConfig for the form | ||
const attributeRoutingConfig = route.attributeRoutingConfig; | ||
|
||
if (!attributeRoutingConfig) return nullReturnValue; | ||
|
||
// If the skipContactOwner is enabled then don't return an team member email | ||
if (attributeRoutingConfig?.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true }; | ||
|
||
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler | ||
// Routing form apps will have the format cal.appSlug | ||
let enabledRoutingFormApp; | ||
|
||
for (const key of Object.keys(query)) { | ||
const keySplit = key.split("."); | ||
|
||
const appSlug = keySplit[1]; | ||
|
||
if (enabledAppSlugs.includes(appSlug)) { | ||
enabledRoutingFormApp = appSlug; | ||
break; | ||
} | ||
} | ||
|
||
if (!enabledRoutingFormApp) return nullReturnValue; | ||
|
||
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler")) | ||
.default; | ||
const appHandler = appBookingFormHandler[enabledRoutingFormApp]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting us up for when we extend this functionality to other CRMs |
||
|
||
if (!appHandler) return nullReturnValue; | ||
|
||
const { email: userEmail } = await appHandler(query.email, attributeRoutingConfig, eventTypeId); | ||
|
||
if (!userEmail) return nullReturnValue; | ||
|
||
// Determine if the user is a part of the event type | ||
const userQuery = await await prisma.user.findFirst({ | ||
where: { | ||
email: userEmail, | ||
hosts: { | ||
some: { | ||
eventTypeId: eventTypeId, | ||
}, | ||
}, | ||
}, | ||
}); | ||
|
||
if (!userQuery) return nullReturnValue; | ||
|
||
return { ...nullReturnValue, email: userEmail }; | ||
} | ||
|
||
async function getTeamMemberEmail(eventData: EventData, email: string): Promise<string | null> { | ||
// Pre-requisites | ||
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null; | ||
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import routingFormBookingFormHandler from "../salesforce/lib/routingFormBookingFormHandler"; | ||
import type { AttributeRoutingConfig } from "./types/types"; | ||
|
||
type AppBookingFormHandler = ( | ||
attendeeEmail: string, | ||
attributeRoutingConfig: AttributeRoutingConfig, | ||
eventTypeId: number | ||
) => Promise<{ email: string | null }>; | ||
|
||
const appBookingFormHandler: Record<string, AppBookingFormHandler> = { | ||
salesforce: routingFormBookingFormHandler, | ||
}; | ||
|
||
export default appBookingFormHandler; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import dynamic from "next/dynamic"; | ||
|
||
export const routingFormAppComponents = { | ||
salesforce: dynamic(() => import("../salesforce/components/RoutingFormOptions")), | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { routingFormOptions as salesforce_routing_form_schema } from "../salesforce/zod"; | ||
|
||
export const routingFormAppDataSchemas = { | ||
salesforce: salesforce_routing_form_schema, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import type { Route, AttributeRoutingConfig } from "../types/types"; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export default function DynamicAppComponent<T extends Record<string, React.ComponentType<any>>>(props: { | ||
componentMap: T; | ||
slug: string; | ||
appData: any; | ||
route: Route; | ||
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void; | ||
wrapperClassName?: string; | ||
}) { | ||
const { componentMap, slug, wrapperClassName, appData, route, setAttributeRoutingConfig, ...rest } = props; | ||
|
||
// There can be apps with no matching component | ||
if (!componentMap[slug]) return null; | ||
|
||
const Component = componentMap[slug]; | ||
|
||
return ( | ||
<div className={wrapperClassName || ""}> | ||
<Component | ||
appData={appData} | ||
route={route} | ||
setAttributeRoutingConfig={setAttributeRoutingConfig} | ||
{...rest} | ||
/> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const ROUTING_FORM_RESPONSE_ID_QUERY_STRING = "cal.routingFormResponseId"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/** This is a list of apps which have functionality in routing forms */ | ||
export const enabledAppSlugs = ["salesforce"]; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; | ||
|
||
import { enabledAppSlugs } from "./enabledApps"; | ||
import type { Prisma } from ".prisma/client"; | ||
|
||
const getEventTypeAppMetadata = (metadata: Prisma.JsonValue) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is used to get the app metadata from the event type for apps that are enabled for routing forms. |
||
const eventTypeMetadataParse = EventTypeMetaDataSchema.safeParse(metadata); | ||
|
||
if (!eventTypeMetadataParse.success || !eventTypeMetadataParse.data) return; | ||
|
||
const appMetadata = eventTypeMetadataParse.data.apps; | ||
|
||
const eventTypeAppMetadata: Record<string, any> = {}; | ||
|
||
if (appMetadata) { | ||
for (const appSlug of Object.keys(appMetadata)) { | ||
if (enabledAppSlugs.includes(appSlug)) { | ||
eventTypeAppMetadata[appSlug] = appMetadata[appSlug as keyof typeof appMetadata]; | ||
} | ||
} | ||
} | ||
|
||
return eventTypeAppMetadata; | ||
}; | ||
|
||
export default getEventTypeAppMetadata; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Abstracted this since there are now two ways we can get the
teamMemberEmail
either through the routing form specified lookup field or the contact owner. Both through Salesforce for now.