2023-02-16 22:39:57 +00:00
|
|
|
import type { TokenResponse } from "jsforce";
|
|
|
|
import jsforce from "jsforce";
|
2022-11-22 23:10:31 +00:00
|
|
|
import { RRule } from "rrule";
|
2023-09-20 18:01:54 +00:00
|
|
|
import { z } from "zod";
|
2022-11-22 23:10:31 +00:00
|
|
|
|
|
|
|
import { getLocation } from "@calcom/lib/CalEventParser";
|
|
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
|
|
import logger from "@calcom/lib/logger";
|
|
|
|
import type {
|
|
|
|
Calendar,
|
|
|
|
CalendarEvent,
|
|
|
|
IntegrationCalendar,
|
|
|
|
NewCalendarEventType,
|
|
|
|
Person,
|
|
|
|
} from "@calcom/types/Calendar";
|
2023-02-16 22:39:57 +00:00
|
|
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
2022-11-22 23:10:31 +00:00
|
|
|
|
|
|
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
2023-09-20 18:01:54 +00:00
|
|
|
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
|
|
|
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
2022-11-22 23:10:31 +00:00
|
|
|
|
|
|
|
type ExtendedTokenResponse = TokenResponse & {
|
|
|
|
instance_url: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
type ContactSearchResult = {
|
|
|
|
attributes: {
|
|
|
|
type: string;
|
|
|
|
url: string;
|
|
|
|
};
|
|
|
|
Id: string;
|
|
|
|
Email: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
const sfApiErrors = {
|
|
|
|
INVALID_EVENTWHOIDS: "INVALID_FIELD: No such column 'EventWhoIds' on sobject of type Event",
|
|
|
|
};
|
|
|
|
|
2023-09-20 18:01:54 +00:00
|
|
|
const salesforceTokenSchema = z.object({
|
|
|
|
id: z.string(),
|
|
|
|
issued_at: z.string(),
|
|
|
|
instance_url: z.string(),
|
|
|
|
signature: z.string(),
|
|
|
|
access_token: z.string(),
|
|
|
|
scope: z.string(),
|
|
|
|
token_type: z.string(),
|
|
|
|
});
|
|
|
|
|
2022-11-22 23:10:31 +00:00
|
|
|
export default class SalesforceCalendarService implements Calendar {
|
|
|
|
private integrationName = "";
|
|
|
|
private conn: Promise<jsforce.Connection>;
|
|
|
|
private log: typeof logger;
|
|
|
|
private calWarnings: string[] = [];
|
|
|
|
|
|
|
|
constructor(credential: CredentialPayload) {
|
|
|
|
this.integrationName = "salesforce_other_calendar";
|
|
|
|
this.conn = this.getClient(credential).then((c) => c);
|
|
|
|
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
|
|
|
}
|
|
|
|
|
|
|
|
private getClient = async (credential: CredentialPayload) => {
|
|
|
|
let consumer_key = "";
|
|
|
|
let consumer_secret = "";
|
|
|
|
|
|
|
|
const appKeys = await getAppKeysFromSlug("salesforce");
|
|
|
|
if (typeof appKeys.consumer_key === "string") consumer_key = appKeys.consumer_key;
|
|
|
|
if (typeof appKeys.consumer_secret === "string") consumer_secret = appKeys.consumer_secret;
|
|
|
|
if (!consumer_key)
|
|
|
|
throw new HttpError({ statusCode: 400, message: "Salesforce consumer key is missing." });
|
|
|
|
if (!consumer_secret)
|
|
|
|
throw new HttpError({ statusCode: 400, message: "Salesforce consumer secret missing." });
|
|
|
|
|
|
|
|
const credentialKey = credential.key as unknown as ExtendedTokenResponse;
|
|
|
|
|
2023-09-25 11:33:53 +00:00
|
|
|
try {
|
|
|
|
/* XXX: This code results in 'Bad Request', which indicates something is wrong with our salesforce integration.
|
|
|
|
Needs further investigation ASAP */
|
|
|
|
const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
|
|
|
|
method: "POST",
|
|
|
|
headers: {
|
|
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
|
|
},
|
|
|
|
body: new URLSearchParams({
|
|
|
|
grant_type: "refresh_token",
|
|
|
|
client_id: consumer_key,
|
|
|
|
client_secret: consumer_secret,
|
|
|
|
refresh_token: credentialKey.refresh_token,
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
const message = `${response.statusText}: ${JSON.stringify(await response.json())}`;
|
|
|
|
throw new Error(message);
|
|
|
|
}
|
|
|
|
|
|
|
|
const accessTokenJson = await response.json();
|
|
|
|
|
|
|
|
const accessTokenParsed: ParseRefreshTokenResponse<typeof salesforceTokenSchema> =
|
|
|
|
parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
|
|
|
|
|
|
|
|
await prisma.credential.update({
|
|
|
|
where: { id: credential.id },
|
|
|
|
data: { key: { ...accessTokenParsed, refresh_token: credentialKey.refresh_token } },
|
|
|
|
});
|
|
|
|
} catch (err: unknown) {
|
|
|
|
console.error(err); // log but proceed
|
|
|
|
}
|
2023-09-20 18:01:54 +00:00
|
|
|
|
2022-11-22 23:10:31 +00:00
|
|
|
return new jsforce.Connection({
|
|
|
|
clientId: consumer_key,
|
|
|
|
clientSecret: consumer_secret,
|
2023-10-03 18:52:19 +00:00
|
|
|
redirectUri: `${WEBAPP_URL}/api/integrations/salesforce/callback`,
|
2022-11-22 23:10:31 +00:00
|
|
|
instanceUrl: credentialKey.instance_url,
|
|
|
|
accessToken: credentialKey.access_token,
|
|
|
|
refreshToken: credentialKey.refresh_token,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceContactCreate = async (attendees: Person[]) => {
|
|
|
|
const conn = await this.conn;
|
|
|
|
const createdContacts = await Promise.all(
|
|
|
|
attendees.map(async (attendee) => {
|
|
|
|
const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
|
|
|
return await conn
|
|
|
|
.sobject("Contact")
|
|
|
|
.create({
|
|
|
|
FirstName,
|
2023-07-11 07:41:21 +00:00
|
|
|
LastName: LastName || "",
|
2022-11-22 23:10:31 +00:00
|
|
|
Email: attendee.email,
|
|
|
|
})
|
|
|
|
.then((result) => {
|
|
|
|
if (result.success) {
|
|
|
|
return { Id: result.id, Email: attendee.email };
|
|
|
|
}
|
|
|
|
});
|
|
|
|
})
|
|
|
|
);
|
|
|
|
return createdContacts.filter(
|
|
|
|
(contact): contact is Omit<ContactSearchResult, "attributes"> => contact !== undefined
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceContactSearch = async (event: CalendarEvent) => {
|
|
|
|
const conn = await this.conn;
|
|
|
|
const search: ContactSearchResult[] = await conn.sobject("Contact").find(
|
|
|
|
event.attendees.map((att) => ({ Email: att.email })),
|
|
|
|
["Id", "Email"]
|
|
|
|
);
|
|
|
|
return search;
|
|
|
|
};
|
|
|
|
|
|
|
|
private getSalesforceEventBody = (event: CalendarEvent): string => {
|
|
|
|
return `${event.organizer.language.translate("invitee_timezone")}: ${
|
|
|
|
event.attendees[0].timeZone
|
|
|
|
} \r\n\r\n ${event.organizer.language.translate("share_additional_notes")}\r\n${
|
|
|
|
event.additionalNotes || "-"
|
|
|
|
}`;
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceCreateEventApiCall = async (
|
|
|
|
event: CalendarEvent,
|
|
|
|
options: { [key: string]: unknown }
|
|
|
|
) => {
|
|
|
|
const conn = await this.conn;
|
|
|
|
return await conn.sobject("Event").create({
|
|
|
|
StartDateTime: new Date(event.startTime).toISOString(),
|
|
|
|
EndDateTime: new Date(event.endTime).toISOString(),
|
|
|
|
Subject: event.title,
|
|
|
|
Description: this.getSalesforceEventBody(event),
|
|
|
|
Location: getLocation(event),
|
|
|
|
...options,
|
|
|
|
...(event.recurringEvent && {
|
|
|
|
IsRecurrence2: true,
|
|
|
|
Recurrence2PatternText: new RRule(event.recurringEvent).toString(),
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceCreateEvent = async (
|
|
|
|
event: CalendarEvent,
|
|
|
|
contacts: Omit<ContactSearchResult, "attributes">[]
|
|
|
|
) => {
|
|
|
|
const createdEvent = await this.salesforceCreateEventApiCall(event, {
|
|
|
|
EventWhoIds: contacts.map((contact) => contact.Id),
|
|
|
|
}).catch(async (reason) => {
|
|
|
|
if (reason === sfApiErrors.INVALID_EVENTWHOIDS) {
|
|
|
|
this.calWarnings.push(
|
|
|
|
`Please enable option "Allow Users to Relate Multiple Contacts to Tasks and Events" under
|
|
|
|
"Setup > Feature Settings > Sales > Activity Settings" to be able to create events with
|
|
|
|
multiple contact attendees.`
|
|
|
|
);
|
|
|
|
// User has not configured "Allow Users to Relate Multiple Contacts to Tasks and Events"
|
|
|
|
// proceeding to create the event using just the first attendee as the primary WhoId
|
|
|
|
return await this.salesforceCreateEventApiCall(event, {
|
|
|
|
WhoId: contacts[0],
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
return Promise.reject();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return createdEvent;
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceUpdateEvent = async (uid: string, event: CalendarEvent) => {
|
|
|
|
const conn = await this.conn;
|
|
|
|
return await conn.sobject("Event").update({
|
|
|
|
Id: uid,
|
|
|
|
StartDateTime: new Date(event.startTime).toISOString(),
|
|
|
|
EndDateTime: new Date(event.endTime).toISOString(),
|
|
|
|
Subject: event.title,
|
|
|
|
Description: this.getSalesforceEventBody(event),
|
|
|
|
Location: getLocation(event),
|
|
|
|
...(event.recurringEvent && {
|
|
|
|
IsRecurrence2: true,
|
|
|
|
Recurrence2PatternText: new RRule(event.recurringEvent).toString(),
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
private salesforceDeleteEvent = async (uid: string) => {
|
|
|
|
const conn = await this.conn;
|
|
|
|
return await conn.sobject("Event").delete(uid);
|
|
|
|
};
|
|
|
|
|
|
|
|
async handleEventCreation(event: CalendarEvent, contacts: Omit<ContactSearchResult, "attributes">[]) {
|
|
|
|
const sfEvent = await this.salesforceCreateEvent(event, contacts);
|
|
|
|
if (sfEvent.success) {
|
|
|
|
this.log.debug("event:creation:ok", { sfEvent });
|
|
|
|
return Promise.resolve({
|
|
|
|
uid: sfEvent.id,
|
|
|
|
id: sfEvent.id,
|
|
|
|
type: "salesforce_other_calendar",
|
|
|
|
password: "",
|
|
|
|
url: "",
|
|
|
|
additionalInfo: { contacts, sfEvent, calWarnings: this.calWarnings },
|
|
|
|
});
|
|
|
|
}
|
|
|
|
this.log.debug("event:creation:notOk", { event, sfEvent, contacts });
|
|
|
|
return Promise.reject({
|
|
|
|
calError: "Something went wrong when creating an event in Salesforce",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
|
|
|
const contacts = await this.salesforceContactSearch(event);
|
|
|
|
if (contacts.length) {
|
|
|
|
if (contacts.length == event.attendees.length) {
|
|
|
|
// All attendees do exist in Salesforce
|
|
|
|
this.log.debug("contact:search:all", { event, contacts });
|
|
|
|
return await this.handleEventCreation(event, contacts);
|
|
|
|
} else {
|
|
|
|
// Some attendees don't exist in Salesforce
|
|
|
|
// Get the existing contacts' email to filter out
|
|
|
|
this.log.debug("contact:search:notAll", { event, contacts });
|
|
|
|
const existingContacts = contacts.map((contact) => contact.Email);
|
|
|
|
this.log.debug("contact:filter:existing", { existingContacts });
|
|
|
|
// Get non existing contacts filtering out existing from attendees
|
|
|
|
const nonExistingContacts = event.attendees.filter(
|
|
|
|
(attendee) => !existingContacts.includes(attendee.email)
|
|
|
|
);
|
|
|
|
this.log.debug("contact:filter:nonExisting", { nonExistingContacts });
|
|
|
|
// Only create contacts in Salesforce that were not present in the previous contact search
|
|
|
|
const createContacts = await this.salesforceContactCreate(nonExistingContacts);
|
|
|
|
this.log.debug("contact:created", { createContacts });
|
|
|
|
// Continue with event creation and association only when all contacts are present in Salesforce
|
|
|
|
if (createContacts.length) {
|
|
|
|
this.log.debug("contact:creation:ok");
|
|
|
|
return await this.handleEventCreation(event, createContacts.concat(contacts));
|
|
|
|
}
|
|
|
|
return Promise.reject({
|
|
|
|
calError: "Something went wrong when creating non-existing attendees in Salesforce",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
this.log.debug("contact:search:none", { event, contacts });
|
|
|
|
const createContacts = await this.salesforceContactCreate(event.attendees);
|
|
|
|
this.log.debug("contact:created", { createContacts });
|
|
|
|
if (createContacts.length) {
|
|
|
|
this.log.debug("contact:creation:ok");
|
|
|
|
return await this.handleEventCreation(event, createContacts);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return Promise.reject({
|
|
|
|
calError: "Something went wrong when searching/creating the attendees in Salesforce",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
|
|
|
|
const updatedEvent = await this.salesforceUpdateEvent(uid, event);
|
|
|
|
if (updatedEvent.success) {
|
|
|
|
return Promise.resolve({
|
|
|
|
uid: updatedEvent.id,
|
|
|
|
id: updatedEvent.id,
|
|
|
|
type: "salesforce_other_calendar",
|
|
|
|
password: "",
|
|
|
|
url: "",
|
|
|
|
additionalInfo: { calWarnings: this.calWarnings },
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
return Promise.reject({ calError: "Something went wrong when updating the event in Salesforce" });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async deleteEvent(uid: string) {
|
|
|
|
const deletedEvent = await this.salesforceDeleteEvent(uid);
|
|
|
|
if (deletedEvent.success) {
|
|
|
|
Promise.resolve();
|
|
|
|
} else {
|
|
|
|
Promise.reject({ calError: "Something went wrong when deleting the event in Salesforce" });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-06 11:59:57 +00:00
|
|
|
async getAvailability(_dateFrom: string, _dateTo: string, _selectedCalendars: IntegrationCalendar[]) {
|
2022-11-22 23:10:31 +00:00
|
|
|
return Promise.resolve([]);
|
|
|
|
}
|
|
|
|
|
2023-06-06 11:59:57 +00:00
|
|
|
async listCalendars(_event?: CalendarEvent) {
|
2022-11-22 23:10:31 +00:00
|
|
|
return Promise.resolve([]);
|
|
|
|
}
|
|
|
|
}
|