feat: Salesforce RR skip based on a user on a lookup field on an account (#17526)

* move types to types file

* Create salesforce routing form components

* Save salesforce data to routing form

* Fixes

* Add event type service

* Change commenting

* Pass data from routing form to CRM

* Init Salesforce routing form booking form handler

* Salesforce find user associated with lookup field

* Add looking up the contact owner

* If salesforce is disabled then don't change the Salesforce option

* Small refactor

* Refactor getting event type app metadata

* Refactor eventType service

* Type fix

* Clean up

* Add translations

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2024-11-12 01:09:30 +00:00
committed by GitHub
co-authored by Alex van Andel
parent 6c40d29745
commit 49333addc2
23 changed files with 2408 additions and 125 deletions
@@ -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
)
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({
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];
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);
@@ -2764,5 +2764,10 @@
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
"lookup_field_name": "Lookup Field Name",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -9,7 +9,6 @@ import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
@@ -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;
@@ -0,0 +1,5 @@
import dynamic from "next/dynamic";
export const routingFormAppComponents = {
salesforce: dynamic(() => import("../salesforce/components/RoutingFormOptions")),
};
@@ -0,0 +1,5 @@
import { routingFormOptions as salesforce_routing_form_schema } from "../salesforce/zod";
export const routingFormAppDataSchemas = {
salesforce: salesforce_routing_form_schema,
};
@@ -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>
);
}
@@ -0,0 +1 @@
export const ROUTING_FORM_RESPONSE_ID_QUERY_STRING = "cal.routingFormResponseId";
@@ -0,0 +1,2 @@
/** This is a list of apps which have functionality in routing forms */
export const enabledAppSlugs = ["salesforce"];
@@ -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) => {
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;
@@ -23,9 +23,10 @@ import {
TextField,
Badge,
Divider,
Switch,
} from "@calcom/ui";
import { routingFormAppComponents } from "../../appComponents";
import DynamicAppComponent from "../../components/DynamicAppComponent";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
@@ -33,6 +34,7 @@ import SingleForm, {
import "../../components/react-awesome-query-builder/styles.css";
import { RoutingPages } from "../../lib/RoutingPages";
import { createFallbackRoute } from "../../lib/createFallbackRoute";
import getEventTypeAppMetadata from "../../lib/getEventTypeAppMetadata";
import {
getQueryBuilderConfigForFormFields,
getQueryBuilderConfigForAttributes,
@@ -41,62 +43,24 @@ import {
} from "../../lib/getQueryBuilderConfig";
import isRouter from "../../lib/isRouter";
import type { SerializableForm } from "../../types/types";
import type { GlobalRoute, LocalRoute, SerializableRoute, Attribute } from "../../types/types";
import type {
GlobalRoute,
LocalRoute,
SerializableRoute,
Attribute,
EditFormRoute,
AttributeRoutingConfig,
} from "../../types/types";
import { RouteActionType } from "../../zod";
type FormFieldsQueryBuilderState = {
tree: ImmutableTree;
config: FormFieldsQueryBuilderConfigWithRaqbFields;
};
type AttributesQueryBuilderState = {
tree: ImmutableTree;
config: AttributesQueryBuilderConfigWithRaqbFields;
};
type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
fallbackAttributesQueryBuilderState: AttributesQueryBuilderState | null;
};
type EventTypesByGroup = RouterOutputs["viewer"]["eventTypes"]["getByViewer"];
type Form = inferSSRProps<typeof getServerSideProps>["form"];
type Route = LocalRouteWithRaqbStates | GlobalRoute;
type SetRoute = (id: string, route: Partial<Route>) => void;
const RoundRobinContactOwnerOverrideSwitch = ({
route,
setAttributeRoutingConfig,
}: {
route: LocalRouteWithRaqbStates;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
}) => {
return (
<div className="mt-4 flex flex-col">
<Switch
label={
route.attributeRoutingConfig?.skipContactOwner
? "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)"
: "Contact owner will be the Round Robin host if available"
}
tooltip="Contact owner can only be used if the routed event has it enabled through Salesforce app"
checked={route.attributeRoutingConfig?.skipContactOwner ?? false}
onCheckedChange={(skipContactOwner) => {
setAttributeRoutingConfig(route.id, {
skipContactOwner,
});
}}
/>
</div>
);
};
type SetRoute = (id: string, route: Partial<EditFormRoute>) => void;
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
type FormFieldsQueryValue = LocalRoute["queryValue"];
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
/**
* We need eventTypeId in every redirect url action now for Rerouting to work smoothly.
@@ -107,7 +71,7 @@ function useEnsureEventTypeIdInRedirectUrlAction({
eventOptions,
setRoute,
}: {
route: Route;
route: EditFormRoute;
eventOptions: { label: string; value: string; eventTypeId: number }[];
setRoute: SetRoute;
}) {
@@ -134,7 +98,7 @@ function useEnsureEventTypeIdInRedirectUrlAction({
}, [eventOptions, setRoute, route.id, (route as unknown as any).action?.value]);
}
const hasRules = (route: Route) => {
const hasRules = (route: EditFormRoute) => {
if (isRouter(route)) return false;
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
};
@@ -169,13 +133,19 @@ const buildEventsData = ({
}: {
eventTypesByGroup: EventTypesByGroup | undefined;
form: Form;
route: Route;
route: EditFormRoute;
}) => {
const eventOptions: { label: string; value: string; eventTypeId: number }[] = [];
const eventOptions: {
label: string;
value: string;
eventTypeId: number;
eventTypeAppMetadata?: Record<string, any>;
}[] = [];
const eventTypesMap = new Map<
number,
{
schedulingType: SchedulingType | null;
eventTypeAppMetadata?: Record<string, any>;
}
>();
eventTypesByGroup?.eventTypeGroups.forEach((group) => {
@@ -199,13 +169,19 @@ const buildEventsData = ({
if (!isRouteAlreadyInUse && !eventTypeValidInContext) {
return;
}
// Pass app data that works with routing forms
const eventTypeAppMetadata = getEventTypeAppMetadata(eventType.metadata);
eventTypesMap.set(eventType.id, {
eventTypeAppMetadata,
schedulingType: eventType.schedulingType,
});
eventOptions.push({
label: uniqueSlug,
value: uniqueSlug,
eventTypeId: eventType.id,
eventTypeAppMetadata,
});
});
});
@@ -230,13 +206,13 @@ const Route = ({
eventTypesByGroup,
}: {
form: Form;
route: Route;
routes: Route[];
route: EditFormRoute;
routes: EditFormRoute[];
setRoute: SetRoute;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
setRoutes: React.Dispatch<React.SetStateAction<EditFormRoute[]>>;
fieldIdentifiers: string[];
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
@@ -271,7 +247,7 @@ const Route = ({
});
const onChangeFormFieldsQuery = (
route: Route,
route: EditFormRoute,
immutableTree: ImmutableTree,
config: FormFieldsQueryBuilderConfigWithRaqbFields
) => {
@@ -283,7 +259,7 @@ const Route = ({
};
const onChangeTeamMembersQuery = (
route: Route,
route: EditFormRoute,
immutableTree: ImmutableTree,
config: AttributesQueryBuilderConfigWithRaqbFields
) => {
@@ -295,7 +271,7 @@ const Route = ({
};
const onChangeFallbackTeamMembersQuery = (
route: Route,
route: EditFormRoute,
immutableTree: ImmutableTree,
config: AttributesQueryBuilderConfigWithRaqbFields
) => {
@@ -401,11 +377,17 @@ const Route = ({
and use only the Team Members that match the following criteria (matches all by default)
</span>
{isRoundRobinEventSelectedForRedirect ? (
<RoundRobinContactOwnerOverrideSwitch
route={route}
setAttributeRoutingConfig={setAttributeRoutingConfig}
/>
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
"salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
<div className="mt-4 px-2.5">
<DynamicAppComponent
componentMap={routingFormAppComponents}
slug="salesforce"
appData={eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata["salesforce"]}
route={route}
setAttributeRoutingConfig={setAttributeRoutingConfig}
/>
</div>
) : null}
<div className="mt-2">
@@ -544,11 +526,13 @@ const Route = ({
if (option.value !== "custom") {
setRoute(route.id, {
action: { ...route.action, value: option.value, eventTypeId: option.eventTypeId },
attributeRoutingConfig: {},
});
setCustomEventTypeSlug("");
} else {
setRoute(route.id, {
action: { ...route.action, value: "custom", eventTypeId: 0 },
attributeRoutingConfig: {},
});
setCustomEventTypeSlug("");
}
@@ -628,7 +612,7 @@ const deserializeRoute = ({
route: Exclude<SerializableRoute, GlobalRoute>;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
}): Route => {
}): EditFormRoute => {
const attributesQueryBuilderState =
route.attributesQueryValue && attributesQueryBuilderConfig
? buildState({
@@ -710,7 +694,7 @@ function useRoutes({
return newRoutes;
});
function getRoutesToSave(routes: Route[]) {
function getRoutesToSave(routes: EditFormRoute[]) {
return routes.map((route) => {
if (isRouter(route)) {
return route;
@@ -872,7 +856,7 @@ const Routes = ({
});
}
const setRoute = (id: string, route: Partial<Route>) => {
const setRoute = (id: string, route: Partial<EditFormRoute>) => {
const index = routes.findIndex((route) => route.id === id);
const existingRoute = routes[index];
const newRoutes = [...routes];
@@ -994,7 +978,7 @@ const Routes = ({
id: routerId,
name: option.name,
description: option.description,
} as Route,
} as EditFormRoute,
]);
}
}}
@@ -1,5 +1,6 @@
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "../../lib/constants";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import type { FormResponse, LocalRoute } from "../../types/types";
import type { getServerSideProps } from "./getServerSideProps";
@@ -80,6 +81,25 @@ export function getUrlSearchParamsToForward({
}
}
const attributeRoutingConfigParams: Record<string, any> = {};
if (attributeRoutingConfig) {
for (const key of Object.keys(attributeRoutingConfig)) {
if (key === "skipContactOwner" && attributeRoutingConfig[key]) {
attributeRoutingConfigParams["cal.skipContactOwner"] = "true";
}
// TODO: How do we move this logic to their respective app packages
if (key === "salesforce") {
const salesforceData = attributeRoutingConfig[key];
if (salesforceData?.rrSkipToAccountLookupField && salesforceData.rrSKipToAccountLookupFieldName) {
attributeRoutingConfigParams["cal.salesforce.rrSkipToAccountLookupField"] = "true";
}
}
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
@@ -87,8 +107,8 @@ export function getUrlSearchParamsToForward({
...(teamMembersMatchingAttributeLogic
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
: null),
["cal.routingFormResponseId"]: String(formResponseId),
...(attributeRoutingConfig?.skipContactOwner ? { ["cal.skipContactOwner"]: "true" } : {}),
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId),
...attributeRoutingConfigParams,
...(reroutingFormResponses
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
: null),
@@ -28,7 +28,7 @@ const Identifiers = {
};
async function enableContactOwnerOverride(page: Page) {
await page.click("text=Contact owner will be the Round Robin host");
await page.click("text=Contact owner will be the Round Robin host if available");
}
async function selectFirstAttributeOption({ fromLocator }: { fromLocator: Locator }) {
@@ -575,7 +575,6 @@ test.describe("Routing Forms", () => {
await selectNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await enableContactOwnerOverride(page);
await addAttributeRoutingRule(page);
await saveCurrentForm(page);
+25
View File
@@ -1,10 +1,15 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { ImmutableTree, Config } from "react-awesome-query-builder";
import type z from "zod";
import type { AttributeType } from "@calcom/prisma/client";
import type { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import type QueryBuilderInitialConfig from "../components/react-awesome-query-builder/config/config";
import type {
FormFieldsQueryBuilderConfigWithRaqbFields,
AttributesQueryBuilderConfigWithRaqbFields,
} from "../lib/getQueryBuilderConfig";
import type { zodRouterRouteView, zodNonRouterRoute, zodFieldsView, zodRoutesView } from "../zod";
export type RoutingForm = SerializableForm<App_RoutingForms_Form>;
@@ -74,3 +79,23 @@ export type Attribute = {
export type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
export type FormFieldsQueryValue = LocalRoute["queryValue"];
export type SerializableField = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
export type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
export type FormFieldsQueryBuilderState = {
tree: ImmutableTree;
config: FormFieldsQueryBuilderConfigWithRaqbFields;
};
export type AttributesQueryBuilderState = {
tree: ImmutableTree;
config: AttributesQueryBuilderConfigWithRaqbFields;
};
export type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
fallbackAttributesQueryBuilderState: AttributesQueryBuilderState | null;
};
export type EditFormRoute = LocalRouteWithRaqbStates | GlobalRoute;
+10 -5
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { routingFormAppDataSchemas } from "./appDataSchemas";
export const zodNonRouterField = z.object({
id: z.string(),
label: z.string(),
@@ -128,14 +130,17 @@ export const queryValueSaveValidationSchema = queryValueSchema
)
.nullish();
export const attributeRoutingConfigSchema = z
.object({
skipContactOwner: z.boolean().optional(),
salesforce: routingFormAppDataSchemas["salesforce"],
})
.nullish();
export const zodNonRouterRoute = z.object({
id: z.string(),
name: z.string().optional(),
attributeRoutingConfig: z
.object({
skipContactOwner: z.boolean().optional(),
})
.nullish(),
attributeRoutingConfig: attributeRoutingConfigSchema,
// TODO: It should be renamed to formFieldsQueryValue but it would take some effort
/**
@@ -0,0 +1,152 @@
import { useState, useEffect } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Select, Input, Label } from "@calcom/ui";
import type { LocalRouteWithRaqbStates, AttributeRoutingConfig } from "../../routing-forms/types/types";
import { appDataSchema } from "../zod";
enum SalesforceRoutingConfig {
ROUTE_OWNER = "route_owner",
SKIP_OWNER = "skip_owner",
ACCOUNT_FIELD_LOOKUP = "account_field_lookup",
}
const RoutingFormOptions = ({
appData,
route,
setAttributeRoutingConfig,
}: {
appData: any;
route: LocalRouteWithRaqbStates;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
}) => {
const { t } = useLocale();
const salesforceRoutingOptions = [
{
label: t("salesforce_route_to_owner"),
value: SalesforceRoutingConfig.ROUTE_OWNER,
},
{
label: t("salesforce_do_not_route_to_owner"),
value: SalesforceRoutingConfig.SKIP_OWNER,
},
{
label: t("salesforce_route_to_custom_lookup_field"),
value: SalesforceRoutingConfig.ACCOUNT_FIELD_LOOKUP,
},
];
const getInitialOption = () => {
if (route.attributeRoutingConfig?.salesforce?.rrSkipToAccountLookupField) {
return salesforceRoutingOptions.find(
(option) => option.value === SalesforceRoutingConfig.ACCOUNT_FIELD_LOOKUP
);
}
if (route.attributeRoutingConfig?.skipContactOwner) {
return salesforceRoutingOptions.find((option) => option.value === SalesforceRoutingConfig.SKIP_OWNER);
} else {
return salesforceRoutingOptions.find((option) => option.value === SalesforceRoutingConfig.ROUTE_OWNER);
}
};
const [selectedRoutingOption, setSelectedRoutingOption] = useState(getInitialOption());
useEffect(() => {
setSelectedRoutingOption(getInitialOption());
}, [route]);
const [rrSkipToAccountLookupFieldName, setRRSkipToAccountLookupFieldName] = useState(
route.attributeRoutingConfig?.salesforce?.rrSKipToAccountLookupFieldName ?? ""
);
const parsedAppData = appDataSchema.safeParse(appData);
if (!parsedAppData.success) return null;
appData = parsedAppData.data;
const isAppDisabled = !appData?.enabled;
const appDisabledOption = {
label: "Salesforce is not enabled on event type",
value: "disabled",
};
const onRRSkipToAccountLookupFieldNameChange = (value: string) => {
setAttributeRoutingConfig(route.id, {
...route.attributeRoutingConfig,
salesforce: { ...route.attributeRoutingConfig?.salesforce, rrSKipToAccountLookupFieldName: value },
});
};
const onOptionChange = (value: SalesforceRoutingConfig) => {
switch (value) {
case SalesforceRoutingConfig.ROUTE_OWNER:
setAttributeRoutingConfig(route.id, {
skipContactOwner: false,
salesforce: {
rrSkipToAccountLookupField: false,
},
});
break;
case SalesforceRoutingConfig.SKIP_OWNER:
setAttributeRoutingConfig(route.id, {
skipContactOwner: true,
salesforce: {
rrSkipToAccountLookupField: false,
},
});
break;
case SalesforceRoutingConfig.ACCOUNT_FIELD_LOOKUP:
setAttributeRoutingConfig(route.id, {
skipContactOwner: false,
salesforce: {
rrSkipToAccountLookupField: true,
rrSKipToAccountLookupFieldName: rrSkipToAccountLookupFieldName,
},
});
break;
}
};
return (
<div>
<div className="flex">
<span className="text-emphasis flex items-center text-sm">
<img src="/app-store/salesforce/icon.png" className="mr-2 h-6 w-6" /> {t("salesforce_option")}
</span>
{isAppDisabled ? (
<Select value={appDisabledOption} isDisabled className="ml-2 w-full" />
) : (
<Select
options={salesforceRoutingOptions}
value={selectedRoutingOption}
onChange={(e) => {
if (e) {
onOptionChange(e.value);
setSelectedRoutingOption(e);
}
}}
className="ml-2 w-full"
/>
)}
</div>
{selectedRoutingOption?.value === SalesforceRoutingConfig.ACCOUNT_FIELD_LOOKUP ? (
<div>
<Label className="mt-2">{t("lookup_field_name")}</Label>
<Input
value={rrSkipToAccountLookupFieldName}
onChange={(e) => {
setRRSkipToAccountLookupFieldName(e.target.value);
onRRSkipToAccountLookupFieldNameChange(e.target.value);
}}
/>
</div>
) : null}
</div>
);
};
export default RoutingFormOptions;
@@ -85,7 +85,6 @@ export default class SalesforceCRMService implements CRM {
throw new HttpError({ statusCode: 400, message: "Salesforce consumer secret missing." });
const credentialKey = credential.key as unknown as ExtendedTokenResponse;
try {
/* XXX: This code results in 'Bad Request', which indicates something is wrong with our salesforce integration.
Needs further investigation ASAP */
@@ -137,10 +136,10 @@ export default class SalesforceCRMService implements CRM {
}
};
private getSalesforceUserFromOwnerId = async (ownerId: string) => {
private getSalesforceUserFromUserId = async (userId: string) => {
const conn = await this.conn;
return await conn.query(`SELECT Id, Email, Name FROM User WHERE Id = '${ownerId}' AND IsActive = true`);
return await conn.query(`SELECT Id, Email, Name FROM User WHERE Id = '${userId}' AND IsActive = true`);
};
private getSalesforceEventBody = (event: CalendarEvent): string => {
@@ -380,7 +379,7 @@ export default class SalesforceCRMService implements CRM {
const ownersQuery = (await Promise.all(
Array.from(ownerIds).map(async (ownerId) => {
return this.getSalesforceUserFromOwnerId(ownerId);
return this.getSalesforceUserFromUserId(ownerId);
})
)) as { records: ContactRecord[] }[];
const contactsWithOwners = records.map((record) => {
@@ -691,7 +690,7 @@ export default class SalesforceCRMService implements CRM {
const ownerId = recordQuery.records[0].OwnerId;
const ownerQuery = await this.getSalesforceUserFromOwnerId(ownerId);
const ownerQuery = await this.getSalesforceUserFromUserId(ownerId);
if (!ownerQuery || !ownerQuery.records.length) return;
@@ -864,4 +863,45 @@ export default class SalesforceCRMService implements CRM {
return query.records[0] as Record<string, any>;
}
async findUserEmailFromLookupField(
attendeeEmail: string,
fieldName: string,
salesforceObject: SalesforceRecordEnum
) {
const conn = await this.conn;
// Ensure the field exists on the record
const existingFields = await this.ensureFieldsExistOnObject([fieldName], salesforceObject);
if (!existingFields.length) return;
const lookupField = existingFields[0];
if (salesforceObject === SalesforceRecordEnum.ACCOUNT) {
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
if (!accountId) return;
const accountQuery = (await conn.query(
`SELECT ${lookupField.name} FROM ${SalesforceRecordEnum.ACCOUNT} WHERE Id = '${accountId}'`
)) as {
records: { [key: string]: any };
};
if (!accountQuery.records.length) return;
const lookupFieldUserId = accountQuery.records[0][lookupField.name];
if (!lookupFieldUserId) return;
const userQuery = await this.getSalesforceUserFromUserId(lookupFieldUserId);
if (!userQuery.records.length) return;
const user = userQuery.records[0] as { Email: string };
return user.Email;
}
}
}
@@ -0,0 +1,40 @@
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";
const routingFormBookingFormHandler = async (
attendeeEmail: string,
attributeRoutingConfig: AttributeRoutingConfig,
eventTypeId: number
) => {
const salesforceSettings = attributeRoutingConfig?.salesforce;
if (
!salesforceSettings ||
!salesforceSettings.rrSkipToAccountLookupField ||
!salesforceSettings.rrSKipToAccountLookupFieldName
)
return { email: null };
const appData = await EventTypeService.getEventTypeAppDataFromId(eventTypeId, "salesforce");
const credentialId = appData.credentialId;
const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: credentialId });
if (!credential) return { email: null };
const crm = new SalesforceCRMService(credential, {});
const userLookupEmail = await crm.findUserEmailFromLookupField(
attendeeEmail,
salesforceSettings.rrSKipToAccountLookupFieldName,
SalesforceRecordEnum.ACCOUNT
);
return { email: userLookupEmail ?? null };
};
export default routingFormBookingFormHandler;
+7
View File
@@ -9,6 +9,13 @@ const writeToRecordEntry = z.object({
whenToWrite: z.nativeEnum(WhenToWriteToRecord),
});
export const routingFormOptions = z
.object({
rrSkipToAccountLookupField: z.boolean().optional(),
rrSKipToAccountLookupFieldName: z.string().optional(),
})
.optional();
export const appDataSchema = eventTypeAppCardZod.extend({
roundRobinLeadSkip: z.boolean().optional(),
roundRobinSkipCheckRecordOn: z
@@ -230,7 +230,7 @@ const BackButtonInSidebar = ({ name }: { name: string }) => {
data-testid={`vertical-tab-${name}`}>
<Icon
name="arrow-left"
className="h-4 w-4 stroke-[2px] md:mt-0 ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180"
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
/>
<Skeleton title={name} as="p" className="min-h-4 max-w-36 truncate" loadingClassName="ms-3">
{name}
@@ -313,7 +313,7 @@ const TeamListCollapsible = () => {
{!team.parentId && (
<img
src={getPlaceholderAvatar(team.logoUrl, team.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] md:mt-0 ltr:mr-2 rtl:ml-2"
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
)}
@@ -460,7 +460,7 @@ const SettingsSidebarContainer = ({
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] md:mt-0 ltr:mr-3 rtl:ml-3"
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
{!tab.icon && tab?.avatar && (
@@ -505,7 +505,7 @@ const SettingsSidebarContainer = ({
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] md:mt-0 ltr:mr-3 rtl:ml-3"
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
<Skeleton
@@ -539,7 +539,7 @@ const SettingsSidebarContainer = ({
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] md:mt-0 ltr:mr-3 rtl:ml-3"
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
<Skeleton
@@ -594,7 +594,7 @@ const SettingsSidebarContainer = ({
{!otherTeam.parentId && (
<img
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] md:mt-0 ltr:mr-2 rtl:ml-2"
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={otherTeam.name || "Team logo"}
/>
)}
@@ -1,5 +1,3 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { safeCredentialSelect } from "@calcom/prisma/selects/credential";
+34
View File
@@ -0,0 +1,34 @@
import type { Prisma } from "@prisma/client";
import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
import { prisma } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export class EventTypeService {
static async getEventTypeAppDataFromId(eventTypeId: number, appSlug: keyof typeof appDataSchemas) {
const eventType = await prisma.eventType.findFirst({
where: {
id: eventTypeId,
},
select: {
metadata: true,
},
});
if (!eventType) return null;
return this.getEventTypeAppDataFromMetadata(eventType.metadata, appSlug);
}
static getEventTypeAppDataFromMetadata(metadata: Prisma.JsonValue, appSlug: keyof typeof appDataSchemas) {
const parseEventTypeAppMetadata = EventTypeMetaDataSchema.safeParse(metadata);
if (!parseEventTypeAppMetadata.success || !parseEventTypeAppMetadata.data?.apps) return null;
const eventTypeAppMetadata = parseEventTypeAppMetadata.data.apps;
const appMetadata = eventTypeAppMetadata[appSlug];
return appMetadata;
}
}
+1799 -23
View File
File diff suppressed because it is too large Load Diff