Skip to content
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

Merged
merged 24 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
feb63fd
move types to types file
joeauyeung Nov 7, 2024
90ada06
Create salesforce routing form components
joeauyeung Nov 7, 2024
0e407eb
Save salesforce data to routing form
joeauyeung Nov 7, 2024
03583d4
Fixes
joeauyeung Nov 7, 2024
dd859b0
Add event type service
joeauyeung Nov 8, 2024
071cf74
Change commenting
joeauyeung Nov 8, 2024
89191c6
Pass data from routing form to CRM
joeauyeung Nov 8, 2024
20fcf6c
Init Salesforce routing form booking form handler
joeauyeung Nov 8, 2024
df3f4d5
Merge branch 'main' into salesforce-route-to-user-lookup-field
joeauyeung Nov 8, 2024
b604055
Salesforce find user associated with lookup field
joeauyeung Nov 8, 2024
ebcc41d
Add looking up the contact owner
joeauyeung Nov 8, 2024
93e358b
If salesforce is disabled then don't change the Salesforce option
joeauyeung Nov 8, 2024
868f302
Small refactor
joeauyeung Nov 8, 2024
2076d23
Refactor getting event type app metadata
joeauyeung Nov 8, 2024
968d0b3
Refactor eventType service
joeauyeung Nov 8, 2024
778382b
Type fix
joeauyeung Nov 8, 2024
54a724a
Clean up
joeauyeung Nov 8, 2024
cd03f79
Merge branch 'main' into salesforce-route-to-user-lookup-field
joeauyeung Nov 11, 2024
74d72e9
Add translations
joeauyeung Nov 11, 2024
7218477
Bump lockfile
emrysal Nov 11, 2024
f058483
Move appBookingFormHandler to inside of getServerSideProps call
keithwillcode Nov 11, 2024
8fbc654
Fix e2e test, outdated i18n string
emrysal Nov 11, 2024
def526d
Merge branch 'salesforce-route-to-user-lookup-field' of https://githu…
emrysal Nov 11, 2024
2c72ee8
Remove click switch
joeauyeung Nov 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 129 additions & 12 deletions apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx
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";
Expand Down Expand Up @@ -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) {
Expand All @@ -92,7 +97,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
eventData: {
eventTypeId: eventData.id,
eventTypeId,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
Expand All @@ -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(
Copy link
Contributor Author

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.

query: ParsedUrlQuery,
eventTypeId: number,
eventData: EventData
) {
if (
!query.email ||
typeof query.email !== "string" ||
eventData.schedulingType !== SchedulingType.ROUND_ROBIN
)
Comment on lines +138 to +142
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need the teamMemberEmail for skipping round robin assignment

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({
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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];
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2760,5 +2760,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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
1 change: 0 additions & 1 deletion packages/app-store/googlecalendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 14 additions & 0 deletions packages/app-store/routing-forms/appBookingFormHandler.ts
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;
5 changes: 5 additions & 0 deletions packages/app-store/routing-forms/appComponents.ts
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")),
};
5 changes: 5 additions & 0 deletions packages/app-store/routing-forms/appDataSchemas.ts
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>
);
}
1 change: 1 addition & 0 deletions packages/app-store/routing-forms/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ROUTING_FORM_RESPONSE_ID_QUERY_STRING = "cal.routingFormResponseId";
2 changes: 2 additions & 0 deletions packages/app-store/routing-forms/lib/enabledApps.ts
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"];
26 changes: 26 additions & 0 deletions packages/app-store/routing-forms/lib/getEventTypeAppMetadata.ts
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) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
Loading
Loading