* fix timezone display on booking page to reflect event availability timezone * migrate fetching event owner's schedule to server side * migrate fetching event owner's schedule to server side * fix e2e test errors * Add WEBAPP_URL_FOR_OAUTH to salesforce auth * In event manager constructor include "_crm" credentials as calendar creds * Change crm apps to type to end with `_crm` * Move sendgrid out of CRM * Add zoho bigin to CRM apps * When getting apps, use slug * Add `crm` variants * Hubspot Oauth use `WEBAPP_URL_FOR_OAUTH` * Refactor creating credentials * Fix empty CRM page * Use credentials with `_crm` * Abstract getAppCategoryTitle * Add integration.handler changes * Init crmManager * Change salesforce to CrmService * Create crmManager * Create contact on new event * Create event * Create new CRM reference * - Fix create new contact for salesforce - Add reschedule to crmManager * Create deleteAllCRMEvents * When searching for credential, look for current credentials in class * On cancel, delete 3rd party events * Add delete method * Type fix * Type fix * Convert Close.com to CrmService * Convert Close.com to CrmService * Move hubspot to CrmService * Convert Pipedrive to CrmService * Rename classes to CrmService * Move ZohoCrm to CrmService * Move Bigin to CrmService * Type return for CrmServices * Fix type errors * Close.com create leads and contacts * Fix tests * Type fix * Zoho bug fixes * Clean up * Type fixes * Remove apiDeletes * Type fixes * Specific typing * Type fix * Type fix * Type fix * Type fix * Type fix * feat: Enable CRM apps on a per event type basis (#14450) * Add Salesforce to be an event type app * Handle new booking, only get enabled CRM credentials * Abstract generating search params * Add close.com to event type * Clean up * Move hubspot to event type * Add pipedrive to event type * Add zoho bigin to event type * Add zoho crm to event type * Remove console.log * Add deleting CRM apps from event type * Delete event type apps * Fix deleting credentials * Add CRM app data to event type metadata * Backwards compatibility: add CRM credential if doesn't exist on event type * Don't include user CRM credentials for backwards comp * Backwards compatibility show CRM app is enabled and dirty field * Clean up * Type fixes * Type fixes * Type fix * Type fix * Remove console.log * Test fix * Upgrade embed-react vite version - dev * Change build can't find error message * Add back omni install prop * Clean up * Refactor `writeAppDataToEventType` * Use eventType repository in writeAppDataToEventType * Clean up old comments * Add error logging * createCRMEvents pass event uid as created event uid * Use `getUid` * Clean up props in create crm event * Small changes to `crmManager` * Fix zoho CRM * refactor crmManager * Undo vite config change * Fix teamId query * Fix bigin error * Remove need for `writeAppDataToEventType` * Add `getAllCredentials` test * Add crmManager tests * Type fixes * Fix type errors * Fix getAllCredentials test * Fix tests * Skip CRM manager tests for now * feat: Skip RR Assignment if Contact Exists In Salesforce (#14556) Co-authored-by: CarinaWolli <wollencarina@gmail.com> * Update yarn.lock * @zomars feedback - use new URL for state params * fix: update hook to not produce enabled === undefined * fix: update app card interfaces to use the new enabled from useIsAppEnabled * fix: feedback for crm RR skip (#15160) * code clean up * fix type any * test setup for RR lead skip * code clean up * simplify code * type error * finshed first test for RR lead skip * add seconds test * add test for handleNewBooking * test if teamMember is set * fix missing enabled key * fix tests * fix type error * use setSystemTime instead of getDate * remove nested if --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> * fix type error * fix: remove app metadata from all eventTypes on deleting the app * fix: update hook to not produce enabled === undefined (default to false) --------- Co-authored-by: Shaik-Sirajuddin <sirajuddinshaik30gmail.com> Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com> Co-authored-by: Shaik-Sirajuddin <89742297+Shaik-Sirajuddin@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: sean-brydon <sean@cal.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
273 lines
9.7 KiB
TypeScript
273 lines
9.7 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import * as hubspot from "@hubspot/api-client";
|
|
import type { BatchInputPublicAssociation } from "@hubspot/api-client/lib/codegen/crm/associations";
|
|
import type { PublicObjectSearchRequest } from "@hubspot/api-client/lib/codegen/crm/contacts";
|
|
import type {
|
|
SimplePublicObject,
|
|
SimplePublicObjectInput,
|
|
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
|
|
|
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 prisma from "@calcom/prisma";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
|
import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/CrmService";
|
|
|
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
|
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
|
import type { HubspotToken } from "../api/callback";
|
|
|
|
const hubspotClient = new hubspot.Client();
|
|
|
|
interface CustomPublicObjectInput extends SimplePublicObjectInput {
|
|
id?: string;
|
|
}
|
|
|
|
export default class HubspotCalendarService implements CRM {
|
|
private url = "";
|
|
private integrationName = "";
|
|
private auth: Promise<{ getToken: () => Promise<HubspotToken | void | never[]> }>;
|
|
private log: typeof logger;
|
|
private client_id = "";
|
|
private client_secret = "";
|
|
|
|
constructor(credential: CredentialPayload) {
|
|
this.integrationName = "hubspot_other_calendar";
|
|
|
|
this.auth = this.hubspotAuth(credential).then((r) => r);
|
|
|
|
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
|
}
|
|
|
|
private getHubspotMeetingBody = (event: CalendarEvent): string => {
|
|
return `<b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
|
|
event.attendees[0].timeZone
|
|
}<br><br><b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
|
|
event.additionalNotes || "-"
|
|
}`;
|
|
};
|
|
|
|
private hubspotCreateMeeting = async (event: CalendarEvent) => {
|
|
const simplePublicObjectInput: SimplePublicObjectInput = {
|
|
properties: {
|
|
hs_timestamp: Date.now().toString(),
|
|
hs_meeting_title: event.title,
|
|
hs_meeting_body: this.getHubspotMeetingBody(event),
|
|
hs_meeting_location: getLocation(event),
|
|
hs_meeting_start_time: new Date(event.startTime).toISOString(),
|
|
hs_meeting_end_time: new Date(event.endTime).toISOString(),
|
|
hs_meeting_outcome: "SCHEDULED",
|
|
},
|
|
};
|
|
|
|
return hubspotClient.crm.objects.meetings.basicApi.create(simplePublicObjectInput);
|
|
};
|
|
|
|
private hubspotAssociate = async (meeting: SimplePublicObject, contacts: Array<{ id: string }>) => {
|
|
const batchInputPublicAssociation: BatchInputPublicAssociation = {
|
|
inputs: contacts.map((contact: { id: string }) => ({
|
|
_from: { id: meeting.id },
|
|
to: { id: contact.id },
|
|
type: "meeting_event_to_contact",
|
|
})),
|
|
};
|
|
return hubspotClient.crm.associations.batchApi.create(
|
|
"meetings",
|
|
"contacts",
|
|
batchInputPublicAssociation
|
|
);
|
|
};
|
|
|
|
private hubspotUpdateMeeting = async (uid: string, event: CalendarEvent) => {
|
|
const simplePublicObjectInput: SimplePublicObjectInput = {
|
|
properties: {
|
|
hs_timestamp: Date.now().toString(),
|
|
hs_meeting_title: event.title,
|
|
hs_meeting_body: this.getHubspotMeetingBody(event),
|
|
hs_meeting_location: getLocation(event),
|
|
hs_meeting_start_time: new Date(event.startTime).toISOString(),
|
|
hs_meeting_end_time: new Date(event.endTime).toISOString(),
|
|
hs_meeting_outcome: "RESCHEDULED",
|
|
},
|
|
};
|
|
|
|
return hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
|
};
|
|
|
|
private hubspotDeleteMeeting = async (uid: string) => {
|
|
return hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
|
};
|
|
|
|
private hubspotAuth = async (credential: CredentialPayload) => {
|
|
const appKeys = await getAppKeysFromSlug("hubspot");
|
|
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
|
|
if (typeof appKeys.client_secret === "string") this.client_secret = appKeys.client_secret;
|
|
if (!this.client_id) throw new HttpError({ statusCode: 400, message: "Hubspot client_id missing." });
|
|
if (!this.client_secret)
|
|
throw new HttpError({ statusCode: 400, message: "Hubspot client_secret missing." });
|
|
const credentialKey = credential.key as unknown as HubspotToken;
|
|
const isTokenValid = (token: HubspotToken) =>
|
|
token &&
|
|
token.tokenType &&
|
|
token.accessToken &&
|
|
token.expiryDate &&
|
|
(token.expiresIn || token.expiryDate) < Date.now();
|
|
|
|
const refreshAccessToken = async (refreshToken: string) => {
|
|
try {
|
|
const hubspotRefreshToken: HubspotToken = await refreshOAuthTokens(
|
|
async () =>
|
|
await hubspotClient.oauth.tokensApi.createToken(
|
|
"refresh_token",
|
|
undefined,
|
|
`${WEBAPP_URL}/api/integrations/hubspot/callback`,
|
|
this.client_id,
|
|
this.client_secret,
|
|
refreshToken
|
|
),
|
|
"hubspot",
|
|
credential.userId
|
|
);
|
|
|
|
// set expiry date as offset from current time.
|
|
hubspotRefreshToken.expiryDate = Math.round(Date.now() + hubspotRefreshToken.expiresIn * 1000);
|
|
await prisma.credential.update({
|
|
where: {
|
|
id: credential.id,
|
|
},
|
|
data: {
|
|
key: hubspotRefreshToken as any,
|
|
},
|
|
});
|
|
|
|
hubspotClient.setAccessToken(hubspotRefreshToken.accessToken);
|
|
} catch (e: unknown) {
|
|
this.log.error(e);
|
|
}
|
|
};
|
|
|
|
return {
|
|
getToken: () =>
|
|
!isTokenValid(credentialKey) ? Promise.resolve([]) : refreshAccessToken(credentialKey.refreshToken),
|
|
};
|
|
};
|
|
|
|
async handleMeetingCreation(event: CalendarEvent, contacts: Contact[]) {
|
|
const contactIds: { id?: string }[] = contacts.map((contact) => ({ id: contact.id }));
|
|
const meetingEvent = await this.hubspotCreateMeeting(event);
|
|
if (meetingEvent) {
|
|
this.log.debug("meeting:creation:ok", { meetingEvent });
|
|
const associatedMeeting = await this.hubspotAssociate(meetingEvent, contactIds as any);
|
|
if (associatedMeeting) {
|
|
this.log.debug("association:creation:ok", { associatedMeeting });
|
|
return Promise.resolve({
|
|
uid: meetingEvent.id,
|
|
id: meetingEvent.id,
|
|
type: "hubspot_other_calendar",
|
|
password: "",
|
|
url: "",
|
|
additionalInfo: { contacts, associatedMeeting },
|
|
});
|
|
}
|
|
return Promise.reject("Something went wrong when associating the meeting and attendees in HubSpot");
|
|
}
|
|
this.log.debug("meeting:creation:notOk", { meetingEvent, event, contacts });
|
|
return Promise.reject("Something went wrong when creating a meeting in HubSpot");
|
|
}
|
|
|
|
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
return await this.handleMeetingCreation(event, contacts);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
return await this.hubspotUpdateMeeting(uid, event);
|
|
}
|
|
|
|
async deleteEvent(uid: string): Promise<void> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
return await this.hubspotDeleteMeeting(uid);
|
|
}
|
|
|
|
async getContacts(emails: string | string[]): Promise<Contact[]> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
const emailArray = Array.isArray(emails) ? emails : [emails];
|
|
|
|
const publicObjectSearchRequest: PublicObjectSearchRequest = {
|
|
filterGroups: emailArray.map((attendeeEmail) => ({
|
|
filters: [
|
|
{
|
|
value: attendeeEmail,
|
|
propertyName: "email",
|
|
operator: "EQ",
|
|
},
|
|
],
|
|
})),
|
|
sorts: ["hs_object_id"],
|
|
properties: ["hs_object_id", "email"],
|
|
limit: 10,
|
|
after: 0,
|
|
};
|
|
|
|
const contacts = await hubspotClient.crm.contacts.searchApi
|
|
.doSearch(publicObjectSearchRequest)
|
|
.then((apiResponse) => apiResponse.results);
|
|
|
|
return contacts.map((contact) => {
|
|
return {
|
|
id: contact.id,
|
|
email: contact.properties.email,
|
|
};
|
|
});
|
|
}
|
|
|
|
async createContacts(contactsToCreate: ContactCreateInput[]): Promise<Contact[]> {
|
|
const auth = await this.auth;
|
|
await auth.getToken();
|
|
|
|
const simplePublicObjectInputs = contactsToCreate.map((attendee) => {
|
|
const [firstname, lastname] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
|
return {
|
|
properties: {
|
|
firstname,
|
|
lastname,
|
|
email: attendee.email,
|
|
},
|
|
};
|
|
});
|
|
const createdContacts = await Promise.all(
|
|
simplePublicObjectInputs.map((contact) =>
|
|
hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => {
|
|
// If multiple events are created, subsequent events may fail due to
|
|
// contact was created by previous event creation, so we introduce a
|
|
// fallback taking advantage of the error message providing the contact id
|
|
if (error.body.message.includes("Contact already exists. Existing ID:")) {
|
|
const split = error.body.message.split("Contact already exists. Existing ID: ");
|
|
return { id: split[1], properties: contact.properties };
|
|
} else {
|
|
throw error;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
return createdContacts.map((contact) => {
|
|
return {
|
|
id: contact.id,
|
|
email: contact.properties.email,
|
|
};
|
|
});
|
|
}
|
|
}
|