Skip to content

Commit

Permalink
feat: Salesforce choose how to save attendees & other options (calcom…
Browse files Browse the repository at this point in the history
…#17009)

* Refactor get SF user and create event

* Add new Salesforce options

* Pass app options to CrmService

* Add record enum

* Add creating new lead and contact under an account

* Handle if the contact already exists but not connected to account

* Create a lead if an account doesn't exist

* Merge fix

* Refactor - add generateCreateRecordBody

* Type fix

* Clean up

* Add getAppOptions to other CRM services

* Type fixes

* Fix typo

* feat: Salesforce round robin skip - choose which entity to search on (calcom#17038)

* Add option to search which entity for the owner

* Add logic to search entity for skipping RR assignment

* Type fixes

* Disable license

* Skip license check in component

* Revert license changes

---------

Co-authored-by: Udit Takkar <[email protected]>
  • Loading branch information
joeauyeung and Udit-takkar authored Oct 12, 2024
1 parent 47b9e8a commit b1d9e2b
Show file tree
Hide file tree
Showing 15 changed files with 406 additions and 96 deletions.
9 changes: 9 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2644,6 +2644,15 @@
"month_to_date": "month to date",
"year_to_date": "year to date",
"custom_range": "custom range",
"salesforce_create_record_as": "On booking, add events on and new attendees as:",
"salesforce_lead": "Lead",
"salesforce_contact_under_account": "Contact under an account",
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"mass_assign_attributes": "Mass assign attributes",
"salesforce_book_directly_with_attendee_owner": "If attendee exists in Salesforce, book directly with the owner",
"salesforce_check_owner_of": "Entity to check owner of to book directly",
"account": "Account",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
4 changes: 2 additions & 2 deletions packages/app-store/_utils/CRMRoundRobinSkip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function getCRMContactOwnerForRRLeadSkip(

if (!crm) return;

const contact = await crm.getContacts(bookerEmail, true);
const contact = await crm.getContacts({ emails: bookerEmail, forRoundRobinSkip: true });
if (!contact?.length) return;
return contact[0].ownerEmail;
}
Expand Down Expand Up @@ -57,5 +57,5 @@ async function getCRMManagerWithRRLeadSkip(apps: z.infer<typeof EventTypeAppMeta
},
});
if (!crmCredential) return;
return new CrmManager(crmCredential);
return new CrmManager(crmCredential, crmRoundRobinLeadSkip);
}
6 changes: 3 additions & 3 deletions packages/app-store/_utils/getCrm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import appStore from "..";

type Class<I, Args extends any[] = any[]> = new (...args: Args) => I;

type CrmClass = Class<CRM, [CredentialPayload]>;
type CrmClass = Class<CRM, [CredentialPayload, any]>;

const log = logger.getSubLogger({ prefix: ["CrmManager"] });
export const getCrm = async (credential: CredentialPayload) => {
export const getCrm = async (credential: CredentialPayload, appOptions: any) => {
if (!credential || !credential.key) return null;
const { type: crmType } = credential;

Expand All @@ -26,7 +26,7 @@ export const getCrm = async (credential: CredentialPayload) => {

if (crmApp && "lib" in crmApp && "CrmService" in crmApp.lib) {
const CrmService = crmApp.lib.CrmService as CrmClass;
return new CrmService(credential);
return new CrmService(credential, appOptions);
}
};

Expand Down
6 changes: 5 additions & 1 deletion packages/app-store/closecom/lib/CrmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export default class CloseComCRMService implements CRM {
await this.closeComDeleteCustomActivity(uid);
}

async getContacts(emails: string | string[]): Promise<Contact[]> {
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
const contactsQuery = await this.closeCom.contact.search({
emails: Array.isArray(emails) ? emails : [emails],
});
Expand Down Expand Up @@ -177,4 +177,8 @@ export default class CloseComCRMService implements CRM {

return contacts;
}

getAppOptions() {
console.log("No options implemented");
}
}
6 changes: 5 additions & 1 deletion packages/app-store/hubspot/lib/CrmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default class HubspotCalendarService implements CRM {
return await this.hubspotDeleteMeeting(uid);
}

async getContacts(emails: string | string[]): Promise<Contact[]> {
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
const auth = await this.auth;
await auth.getToken();

Expand Down Expand Up @@ -269,4 +269,8 @@ export default class HubspotCalendarService implements CRM {
};
});
}

getAppOptions() {
console.log("No options implemented");
}
}
8 changes: 6 additions & 2 deletions packages/app-store/pipedrive-crm/lib/CrmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ export default class PipedriveCrmService implements CRM {
return results.map((result) => result.result);
}

async getContacts(email: string | string[]): Promise<Contact[]> {
const emailArray = Array.isArray(email) ? email : [email];
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
const emailArray = Array.isArray(emails) ? emails : [emails];

const result = emailArray.map(async (attendeeEmail) => {
const headers = new Headers();
Expand Down Expand Up @@ -230,4 +230,8 @@ export default class PipedriveCrmService implements CRM {
async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}

getAppOptions() {
console.log("No options implemented");
}
}
136 changes: 111 additions & 25 deletions packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { usePathname } from "next/navigation";
import { useState } from "react";

import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
Expand All @@ -7,8 +8,9 @@ import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Switch, Alert } from "@calcom/ui";
import { Switch, Alert, Select } from "@calcom/ui";

import { SalesforceRecordEnum } from "../lib/recordEnum";
import type { appDataSchema } from "../zod";

const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
Expand All @@ -17,9 +19,32 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const { enabled, updateEnabled } = useIsAppEnabled(app);
const isRoundRobinLeadSkipEnabled = getAppData("roundRobinLeadSkip");
const roundRobinSkipCheckRecordOn =
getAppData("roundRobinSkipCheckRecordOn") ?? SalesforceRecordEnum.CONTACT;
const isSkipContactCreationEnabled = getAppData("skipContactCreation");
const createLeadIfAccountNull = getAppData("createLeadIfAccountNull");
const createNewContactUnderAccount = getAppData("createNewContactUnderAccount");
const createEventOn = getAppData("createEventOn") ?? SalesforceRecordEnum.CONTACT;
const { t } = useLocale();

const recordOptions = [
{ label: t("contact"), value: SalesforceRecordEnum.CONTACT },
{ label: t("salesforce_lead"), value: SalesforceRecordEnum.LEAD },
{ label: t("salesforce_contact_under_account"), value: SalesforceRecordEnum.ACCOUNT },
];
const [createEventOnSelectedOption, setCreateEventOnSelectedOption] = useState(
recordOptions.find((option) => option.value === createEventOn) ?? recordOptions[0]
);

const checkOwnerOptions = [
{ label: t("contact"), value: SalesforceRecordEnum.CONTACT },
{ label: t("salesforce_lead"), value: SalesforceRecordEnum.LEAD },
{ label: t("account"), value: SalesforceRecordEnum.ACCOUNT },
];
const [checkOwnerSelectedOption, setCheckOwnerSelectedOption] = useState(
checkOwnerOptions.find((option) => option.value === roundRobinSkipCheckRecordOn) ?? checkOwnerOptions[0]
);

return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
Expand All @@ -31,34 +56,95 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled}
hideSettingsIcon>
<>
<div>
<Switch
label={t("skip_contact_creation", { appName: "Salesforce" })}
labelOnLeading
checked={isSkipContactCreationEnabled}
onCheckedChange={(checked) => {
setAppData("skipContactCreation", checked);
}}
/>
</div>
</>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<div className="mt-4">
<Switch
label={t("skip_rr_assignment_label")}
labelOnLeading
checked={isRoundRobinLeadSkipEnabled}
onCheckedChange={(checked) => {
setAppData("roundRobinLeadSkip", checked);
if (checked) {
// temporary solution, enabled should always be already set
setAppData("enabled", checked);
<div className="mb-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_create_record_as")}
</label>
<Select
className="mt-2 w-60"
options={recordOptions}
value={createEventOnSelectedOption}
onChange={(e) => {
if (e) {
setCreateEventOnSelectedOption(e);
setAppData("createEventOn", e.value);
}
}}
/>
<Alert className="mt-2" severity="neutral" title={t("skip_rr_description")} />
</div>
) : null}
{createEventOnSelectedOption.value === SalesforceRecordEnum.CONTACT ? (
<div>
<Switch
label={t("skip_contact_creation", { appName: "Salesforce" })}
labelOnLeading
checked={isSkipContactCreationEnabled}
onCheckedChange={(checked) => {
setAppData("skipContactCreation", checked);
}}
/>
</div>
) : null}
{createEventOnSelectedOption.value === SalesforceRecordEnum.ACCOUNT ? (
<>
<div className="mb-4">
<Switch
label={t("salesforce_create_new_contact_under_account")}
labelOnLeading
checked={createNewContactUnderAccount}
onCheckedChange={(checked) => {
setAppData("createNewContactUnderAccount", checked);
}}
/>
</div>
<div>
<Switch
label={t("salesforce_if_account_does_not_exist")}
labelOnLeading
checked={createLeadIfAccountNull}
onCheckedChange={(checked) => {
setAppData("createLeadIfAccountNull", checked);
}}
/>
</div>
</>
) : null}

{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<div className="mt-4">
<Switch
label={t("salesforce_book_directly_with_attendee_owner")}
labelOnLeading
checked={isRoundRobinLeadSkipEnabled}
onCheckedChange={(checked) => {
setAppData("roundRobinLeadSkip", checked);
if (checked) {
// temporary solution, enabled should always be already set
setAppData("enabled", checked);
}
}}
/>
{isRoundRobinLeadSkipEnabled ? (
<div className="my-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_check_owner_of")}
</label>
<Select
className="mt-2 w-60"
options={checkOwnerOptions}
value={checkOwnerSelectedOption}
onChange={(e) => {
if (e) {
setCheckOwnerSelectedOption(e);
setAppData("roundRobinSkipCheckRecordOn", e.value);
}
}}
/>
</div>
) : null}
<Alert className="mt-2" severity="neutral" title={t("skip_rr_description")} />
</div>
) : null}
</>
</AppCard>
);
};
Expand Down
Loading

0 comments on commit b1d9e2b

Please sign in to comment.