feat: Salesforce choose how to save attendees & other options (#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 (#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 <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
1 parent
47b9e8a8ac
commit
b1d9e2b84c
15 files changed
+406
-96
No files matched your search
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
@@ -177,4 +177,8 @@ export default class CloseComCRMService implements CRM {
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -269,4 +269,8 @@ export default class HubspotCalendarService implements CRM {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 }) {
|
||||
@@ -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`}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService";
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import { SalesforceRecordEnum } from "./recordEnum";
|
||||
|
||||
type ExtendedTokenResponse = TokenResponse & {
|
||||
instance_url: string;
|
||||
@@ -55,11 +56,18 @@ export default class SalesforceCRMService implements CRM {
|
||||
private conn: Promise<jsforce.Connection>;
|
||||
private log: typeof logger;
|
||||
private calWarnings: string[] = [];
|
||||
private appOptions: any;
|
||||
private doNotCreateEvent = false;
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
constructor(credential: CredentialPayload, appOptions: any) {
|
||||
this.integrationName = "salesforce_other_calendar";
|
||||
this.conn = this.getClient(credential).then((c) => c);
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
this.appOptions = appOptions;
|
||||
}
|
||||
|
||||
public getAppOptions() {
|
||||
return this.appOptions;
|
||||
}
|
||||
|
||||
private getClient = async (credential: CredentialPayload) => {
|
||||
@@ -224,7 +232,10 @@ export default class SalesforceCRMService implements CRM {
|
||||
});
|
||||
}
|
||||
|
||||
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent> {
|
||||
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent | undefined> {
|
||||
const skipEventCreation = this.getDoNotCreateEvent();
|
||||
if (skipEventCreation) return undefined;
|
||||
|
||||
const sfEvent = await this.salesforceCreateEvent(event, contacts);
|
||||
if (sfEvent.success) {
|
||||
return Promise.resolve({
|
||||
@@ -265,17 +276,46 @@ export default class SalesforceCRMService implements CRM {
|
||||
}
|
||||
}
|
||||
|
||||
async getContacts(email: string | string[], includeOwner?: boolean) {
|
||||
async getContacts({
|
||||
emails,
|
||||
includeOwner,
|
||||
forRoundRobinSkip,
|
||||
}: {
|
||||
emails: string | string[];
|
||||
includeOwner?: boolean;
|
||||
forRoundRobinSkip?: boolean;
|
||||
}) {
|
||||
const conn = await this.conn;
|
||||
const emails = Array.isArray(email) ? email : [email];
|
||||
const soql = `SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('${emails.join("','")}')`;
|
||||
const emailArray = Array.isArray(emails) ? emails : [emails];
|
||||
const appOptions = this.getAppOptions();
|
||||
const recordToSearch =
|
||||
(forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ??
|
||||
SalesforceRecordEnum.CONTACT;
|
||||
let soql: string;
|
||||
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];
|
||||
soql = `SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = '${attendeeEmail}' AND AccountId != null`;
|
||||
|
||||
// If this is for a round robin skip then we need to return the account record
|
||||
if (forRoundRobinSkip) {
|
||||
const results = await conn.query(soql);
|
||||
if (results.records.length) {
|
||||
const contact = results.records[0] as { AccountId: string };
|
||||
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${contact.AccountId}'`;
|
||||
}
|
||||
}
|
||||
// If creating events on contacts or leads
|
||||
} else {
|
||||
soql = `SELECT Id, Email, OwnerId FROM ${recordToSearch} WHERE Email IN ('${emailArray.join("','")}')`;
|
||||
}
|
||||
const results = await conn.query(soql);
|
||||
|
||||
if (!results || !results.records.length) return [];
|
||||
|
||||
const records = results.records as ContactRecord[];
|
||||
|
||||
if (includeOwner) {
|
||||
if (includeOwner || forRoundRobinSkip) {
|
||||
const ownerIds: Set<string> = new Set();
|
||||
records.forEach((record) => {
|
||||
ownerIds.add(record.OwnerId);
|
||||
@@ -304,27 +344,160 @@ export default class SalesforceCRMService implements CRM {
|
||||
|
||||
async createContacts(contactsToCreate: { email: string; name: string }[], organizerEmail?: string) {
|
||||
const conn = await this.conn;
|
||||
|
||||
// See if the organizer exists in the CRM
|
||||
const appOptions = this.getAppOptions();
|
||||
const createEventOn = appOptions.createEventOn ?? SalesforceRecordEnum.CONTACT;
|
||||
const organizerId = organizerEmail ? await this.getSalesforceUserIdFromEmail(organizerEmail) : undefined;
|
||||
const createdContacts = await Promise.all(
|
||||
contactsToCreate.map(async (attendee) => {
|
||||
const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
||||
return await conn
|
||||
.sobject("Contact")
|
||||
const createdContacts: { id: string; email: string }[] = [];
|
||||
|
||||
if (createEventOn === SalesforceRecordEnum.CONTACT || createEventOn === SalesforceRecordEnum.LEAD) {
|
||||
// See if the organizer exists in the CRM
|
||||
await Promise.all(
|
||||
contactsToCreate.map(async (attendee) => {
|
||||
return await conn
|
||||
.sobject(createEventOn)
|
||||
.create(
|
||||
this.generateCreateRecordBody({
|
||||
attendee,
|
||||
recordType: createEventOn,
|
||||
organizerId,
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
createdContacts.push({ id: result.id, email: attendee.email });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (createEventOn === SalesforceRecordEnum.ACCOUNT) {
|
||||
if (!appOptions.createNewContactUnderAccount && !appOptions.createLeadIfAccountNull) {
|
||||
this.setDoNotCreateEvent(true);
|
||||
return [{ id: "Do not create event", email: "placeholder" }];
|
||||
}
|
||||
|
||||
// Base this off of the first contact
|
||||
const attendee = contactsToCreate[0];
|
||||
|
||||
const emailDomain = attendee.email.split("@")[1];
|
||||
|
||||
const response = await conn.query(
|
||||
`SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${emailDomain}' AND AccountId != null`
|
||||
);
|
||||
|
||||
const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]);
|
||||
|
||||
if (accountId && appOptions.createNewContactUnderAccount) {
|
||||
// First see if the contact already exists and connect it to the account
|
||||
const userQuery = await conn.query(`SELECT Id, Email FROM Contact WHERE Email = '${attendee.email}'`);
|
||||
if (userQuery.records.length) {
|
||||
const contact = userQuery.records[0] as { Id: string; Email: string };
|
||||
await conn.sobject(SalesforceRecordEnum.CONTACT).update({
|
||||
// The first argument is the WHERE clause
|
||||
Id: contact.Id,
|
||||
AccountId: accountId,
|
||||
});
|
||||
return [{ id: contact.Id, email: contact.Email }];
|
||||
}
|
||||
|
||||
await conn
|
||||
.sobject(SalesforceRecordEnum.CONTACT)
|
||||
.create({
|
||||
FirstName,
|
||||
LastName: LastName || "-",
|
||||
Email: attendee.email,
|
||||
...(organizerId && { OwnerId: organizerId }),
|
||||
...this.generateCreateRecordBody({
|
||||
attendee,
|
||||
recordType: SalesforceRecordEnum.CONTACT,
|
||||
organizerId,
|
||||
}),
|
||||
AccountId: accountId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
return { id: result.id, email: attendee.email };
|
||||
createdContacts.push({ id: result.id, email: attendee.email });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
return createdContacts.filter((contact): contact is Contact => contact !== undefined);
|
||||
}
|
||||
|
||||
if (appOptions.createLeadIfAccountNull) {
|
||||
await Promise.all(
|
||||
contactsToCreate.map(async (attendee) => {
|
||||
return await conn
|
||||
.sobject(SalesforceRecordEnum.LEAD)
|
||||
.create(
|
||||
this.generateCreateRecordBody({
|
||||
attendee,
|
||||
recordType: SalesforceRecordEnum.LEAD,
|
||||
organizerId,
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
createdContacts.push({ id: result.id, email: attendee.email });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return createdContacts;
|
||||
}
|
||||
|
||||
private setDoNotCreateEvent(boolean: boolean) {
|
||||
this.doNotCreateEvent = boolean;
|
||||
}
|
||||
|
||||
private getDoNotCreateEvent() {
|
||||
return this.doNotCreateEvent;
|
||||
}
|
||||
|
||||
private getDominantAccountId(contacts: { AccountId: string }[]) {
|
||||
// To get the dominant AccountId we only need to iterate through half the array
|
||||
const iterateLength = Math.ceil(contacts.length / 2);
|
||||
// Store AccountId frequencies
|
||||
const accountIdCounts: { [accountId: string]: number } = {};
|
||||
|
||||
for (const contact of contacts) {
|
||||
const accountId = contact.AccountId;
|
||||
accountIdCounts[accountId] = (accountIdCounts[accountId] || 0) + 1;
|
||||
// If the number of AccountIds makes up 50% of the array length then return early
|
||||
if (accountIdCounts[accountId] > iterateLength) return accountId;
|
||||
}
|
||||
|
||||
// Else figure out which AccountId occurs the most
|
||||
let dominantAccountId;
|
||||
let highestCount = 0;
|
||||
|
||||
for (const accountId in accountIdCounts) {
|
||||
if (accountIdCounts[accountId] > highestCount) {
|
||||
highestCount = accountIdCounts[accountId];
|
||||
dominantAccountId = accountId;
|
||||
}
|
||||
}
|
||||
|
||||
return dominantAccountId;
|
||||
}
|
||||
|
||||
private generateCreateRecordBody({
|
||||
attendee,
|
||||
recordType,
|
||||
organizerId,
|
||||
}: {
|
||||
attendee: { email: string; name: string };
|
||||
recordType: SalesforceRecordEnum;
|
||||
organizerId?: string;
|
||||
}) {
|
||||
const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
||||
|
||||
// Assume that the first part of the email domain is the company title
|
||||
const company = attendee.email.split("@")[1].split(".")[0];
|
||||
|
||||
return {
|
||||
LastName: LastName || "-",
|
||||
FirstName,
|
||||
Email: attendee.email,
|
||||
...(organizerId && { OwnerId: organizerId }),
|
||||
...(recordType === SalesforceRecordEnum.LEAD && { Company: company }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum SalesforceRecordEnum {
|
||||
CONTACT = "Contact",
|
||||
LEAD = "Lead",
|
||||
ACCOUNT = "Account",
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
import { SalesforceRecordEnum } from "./lib/recordEnum";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.extend({
|
||||
roundRobinLeadSkip: z.boolean().optional(),
|
||||
roundRobinSkipCheckRecordOn: z
|
||||
.nativeEnum(SalesforceRecordEnum)
|
||||
.default(SalesforceRecordEnum.CONTACT)
|
||||
.optional(),
|
||||
skipContactCreation: z.boolean().optional(),
|
||||
createEventOn: z.nativeEnum(SalesforceRecordEnum).default(SalesforceRecordEnum.CONTACT).optional(),
|
||||
createNewContactUnderAccount: z.boolean().optional(),
|
||||
createLeadIfAccountNull: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
|
||||
@@ -155,7 +155,7 @@ export default class BiginCrmService implements CRM {
|
||||
/***
|
||||
* Finds existing Zoho Bigin Contact record based on email address. Returns a list of contacts objects that matched.
|
||||
*/
|
||||
async getContacts(emails: string | string[]) {
|
||||
async getContacts({ emails }: { emails: string | string[] }) {
|
||||
const token = await this.auth.getToken();
|
||||
const emailsArray = Array.isArray(emails) ? emails : [emails];
|
||||
|
||||
@@ -288,6 +288,10 @@ export default class BiginCrmService implements CRM {
|
||||
async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
}
|
||||
}
|
||||
|
||||
const toISO8601String = (date: Date) => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export default class ZohoCrmCrmService implements CRM {
|
||||
});
|
||||
}
|
||||
|
||||
async getContacts(emails: string | string[]) {
|
||||
async getContacts({ emails }: { emails: string | string[] }) {
|
||||
const auth = await this.auth;
|
||||
await auth.getToken();
|
||||
const emailsArray = Array.isArray(emails) ? emails : [emails];
|
||||
@@ -289,4 +289,8 @@ export default class ZohoCrmCrmService implements CRM {
|
||||
await auth.getToken();
|
||||
return await this.deleteMeeting(uid);
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export default class EventManager {
|
||||
calendarCredentials: CredentialPayload[];
|
||||
videoCredentials: CredentialPayload[];
|
||||
crmCredentials: CredentialPayload[];
|
||||
appOptions: AppOptions;
|
||||
appOptions?: z.infer<typeof EventTypeAppMetadataSchema>;
|
||||
|
||||
/**
|
||||
* Takes an array of credentials and initializes a new instance of the EventManager.
|
||||
@@ -132,7 +132,7 @@ export default class EventManager {
|
||||
(cred) => cred.type.endsWith("_crm") || cred.type.endsWith("_other_calendar")
|
||||
);
|
||||
|
||||
this.appOptions = this.generateAppOptions(eventTypeAppMetadata);
|
||||
this.appOptions = eventTypeAppMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -969,29 +969,32 @@ export default class EventManager {
|
||||
const createdEvents = [];
|
||||
const uid = getUid(event);
|
||||
for (const credential of this.crmCredentials) {
|
||||
const crm = new CrmManager(credential);
|
||||
const currentAppOption = this.getAppOptionsFromEventMetadata(credential);
|
||||
|
||||
const crm = new CrmManager(credential, currentAppOption);
|
||||
|
||||
let success = true;
|
||||
const skipContactCreation = this.appOptions.crm.skipContactCreation.includes(credential.appId || "");
|
||||
const createdEvent = await crm.createEvent(event, skipContactCreation).catch((error) => {
|
||||
const createdEvent = await crm.createEvent(event, currentAppOption).catch((error) => {
|
||||
success = false;
|
||||
log.warn(`Error creating crm event for ${credential.type}`, error);
|
||||
});
|
||||
|
||||
createdEvents.push({
|
||||
type: credential.type,
|
||||
appName: credential.appId || "",
|
||||
uid,
|
||||
success,
|
||||
createdEvent: {
|
||||
id: createdEvent?.id || "",
|
||||
if (createdEvent) {
|
||||
createdEvents.push({
|
||||
type: credential.type,
|
||||
appName: credential.appId || "",
|
||||
uid,
|
||||
success,
|
||||
createdEvent: {
|
||||
id: createdEvent?.id || "",
|
||||
type: credential.type,
|
||||
credentialId: credential.id,
|
||||
},
|
||||
id: createdEvent?.id || "",
|
||||
originalEvent: event,
|
||||
credentialId: credential.id,
|
||||
},
|
||||
id: createdEvent?.id || "",
|
||||
originalEvent: event,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return createdEvents;
|
||||
}
|
||||
@@ -1031,20 +1034,10 @@ export default class EventManager {
|
||||
}
|
||||
}
|
||||
|
||||
private generateAppOptions(eventTypeAppMetadata?: z.infer<typeof EventTypeAppMetadataSchema>) {
|
||||
const appOptions: AppOptions = {
|
||||
crm: {
|
||||
skipContactCreation: [],
|
||||
},
|
||||
};
|
||||
private getAppOptionsFromEventMetadata(credential: CredentialPayload) {
|
||||
if (!this.appOptions || !credential.appId) return {};
|
||||
|
||||
if (eventTypeAppMetadata) {
|
||||
for (const key in eventTypeAppMetadata) {
|
||||
const app = eventTypeAppMetadata[key as keyof typeof eventTypeAppMetadata];
|
||||
if (app?.skipContactCreation) appOptions.crm.skipContactCreation.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return appOptions;
|
||||
if (credential.appId in this.appOptions)
|
||||
return this.appOptions[credential.appId as keyof typeof this.appOptions];
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,15 @@ const log = logger.getSubLogger({ prefix: ["CrmManager"] });
|
||||
export default class CrmManager {
|
||||
crmService: CRM | null | undefined = null;
|
||||
credential: CredentialPayload;
|
||||
constructor(credential: CredentialPayload) {
|
||||
appOptions: any;
|
||||
constructor(credential: CredentialPayload, appOptions?: any) {
|
||||
this.credential = credential;
|
||||
this.appOptions = appOptions;
|
||||
}
|
||||
|
||||
private async getCrmService(credential: CredentialPayload) {
|
||||
if (this.crmService) return this.crmService;
|
||||
const crmService = await getCrm(credential);
|
||||
const crmService = await getCrm(credential, this.appOptions);
|
||||
this.crmService = crmService;
|
||||
|
||||
if (this.crmService === null) {
|
||||
@@ -25,10 +27,11 @@ export default class CrmManager {
|
||||
return crmService;
|
||||
}
|
||||
|
||||
public async createEvent(event: CalendarEvent, skipContactCreation?: boolean) {
|
||||
public async createEvent(event: CalendarEvent, appOptions?: any) {
|
||||
const crmService = await this.getCrmService(this.credential);
|
||||
const { skipContactCreation } = crmService?.getAppOptions();
|
||||
// First see if the attendees already exist in the crm
|
||||
let contacts = (await this.getContacts(event.attendees.map((a) => a.email))) || [];
|
||||
let contacts = (await this.getContacts({ emails: event.attendees.map((a) => a.email) })) || [];
|
||||
// Ensure that all attendees are in the crm
|
||||
if (contacts.length == event.attendees.length) {
|
||||
return await crmService?.createEvent(event, contacts);
|
||||
@@ -54,9 +57,13 @@ export default class CrmManager {
|
||||
return await crmService?.deleteEvent(uid);
|
||||
}
|
||||
|
||||
public async getContacts(emailOrEmails: string | string[], includeOwner?: boolean) {
|
||||
public async getContacts(params: {
|
||||
emails: string | string[];
|
||||
includeOwner?: boolean;
|
||||
forRoundRobinSkip?: boolean;
|
||||
}) {
|
||||
const crmService = await this.getCrmService(this.credential);
|
||||
const contacts = await crmService?.getContacts(emailOrEmails, includeOwner);
|
||||
const contacts = await crmService?.getContacts(params);
|
||||
return contacts;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
-2
@@ -25,9 +25,18 @@ export interface CrmEvent {
|
||||
}
|
||||
|
||||
export interface CRM {
|
||||
createEvent: (event: CalendarEvent, contacts: Contact[]) => Promise<CrmEvent>;
|
||||
createEvent: (event: CalendarEvent, contacts: Contact[]) => Promise<CrmEvent | undefined>;
|
||||
updateEvent: (uid: string, event: CalendarEvent) => Promise<CrmEvent>;
|
||||
deleteEvent: (uid: string) => Promise<void>;
|
||||
getContacts: (emails: string | string[], includeOwner?: boolean) => Promise<Contact[]>;
|
||||
getContacts: ({
|
||||
emails,
|
||||
includeOwner,
|
||||
forRoundRobinSkip,
|
||||
}: {
|
||||
emails: string | string[];
|
||||
includeOwner?: boolean;
|
||||
forRoundRobinSkip?: boolean;
|
||||
}) => Promise<Contact[]>;
|
||||
createContacts: (contactsToCreate: ContactCreateInput[], organizerEmail?: string) => Promise<Contact[]>;
|
||||
getAppOptions: () => any;
|
||||
}
|
||||
Reference in new issue
Block a user