HubSpot App (#2380)

* Initial changes

* OAuth done and credentials stored

* Added "other" integrations

* Switching to hubspot api client

* Event creation for all attendees

* Update and delete done

* Doc update

* Fixing types

* App label is not mandatory

* Fixing bad merge: App label deleted

* Fixing bad automerge

* Removing  c.log

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Leo Giovanetti 2022-04-15 23:23:38 -03:00 committed by GitHub
parent 2cafe2d98e
commit ffebe8e901
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 562 additions and 1925 deletions

View File

@ -8,6 +8,7 @@
# - APP STORE
# - DAILY.CO VIDEO
# - GOOGLE CALENDAR/MEET/LOGIN
# - HUBSPOT
# - OFFICE 365
# - SLACK
# - STRIPE
@ -124,6 +125,12 @@ GOOGLE_API_CREDENTIALS='{}'
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
GOOGLE_LOGIN_ENABLED=false
# - HUBSPOT
# Used for the HubSpot integration
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
HUBSPOT_CLIENT_ID=""
HUBSPOT_CLIENT_SECRET=""
# - OFFICE 365
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
@ -145,7 +152,7 @@ PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
# - TANDEM
# Used for the Tandem integration -- contact support@tandem.chat to for API access.
# Used for the Tandem integration -- contact support@tandem.chat for API access.
TANDEM_CLIENT_ID=""
TANDEM_CLIENT_SECRET=""
TANDEM_BASE_URL="https://tandem.chat"

View File

@ -384,6 +384,19 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
### Obtaining HubSpot Client ID and Secret
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
2. From within the home of the Developer account page, go to "Manage apps".
3. Click "Create app" button top right.
4. Fill in any information you want in the "App info" tab
5. Go to tab "Auth"
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
<!-- LICENSE -->
## License

View File

@ -31,7 +31,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
throw new HttpError({ statusCode: 404, message: `API handler not found` });
const response = await handler(req, res);
console.log("response", response);
return res.status(200);
} catch (error) {

View File

@ -574,8 +574,8 @@ const loggedInViewerRouter = createProtectedRouter()
// `flatMap()` these work like `.filter()` but infers the types correctly
const conferencing = apps.flatMap((item) => (item.variant === "conferencing" ? [item] : []));
const payment = apps.flatMap((item) => (item.variant === "payment" ? [item] : []));
const other = apps.flatMap((item) => (item.variant.startsWith("other") ? [item] : []));
const calendar = apps.flatMap((item) => (item.variant === "calendar" ? [item] : []));
const other = apps.flatMap((item) => (item.variant === "other" ? [item] : []));
return {
conferencing: {
items: conferencing,

View File

@ -12,7 +12,7 @@ export const getCalendar = (credential: Credential | null): Calendar | null => {
const { type: calendarType } = credential;
const calendarApp = appStore[calendarType.split("_").join("") as keyof typeof appStore];
if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) {
log.warn(`calendar of type ${calendarType} does not implemented`);
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
const CalendarService = calendarApp.lib.CalendarService;

View File

@ -5,7 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { App } from "@calcom/types/App";
function useAddAppMutation(type: App["type"], options?: Parameters<typeof useMutation>[2]) {
const appName = type.replace("_", "");
const appName = type.replaceAll("_", "");
const mutation = useMutation(async () => {
const state: IntegrationOAuthCallbackState = {
returnTo: WEBAPP_URL + "/apps/installed" + location.search,

View File

@ -13,6 +13,7 @@ export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
hubspotothercalendar: dynamic(() => import("./hubspotothercalendar/components/InstallAppButton")),
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
@ -29,7 +30,7 @@ export const InstallAppButton = (
) => {
const { status } = useSession();
const { t } = useLocale();
const appName = props.type.replace("_", "") as keyof typeof InstallAppButtonMap;
const appName = props.type.replaceAll("_", "") as keyof typeof InstallAppButtonMap;
const InstallAppButtonComponent = InstallAppButtonMap[appName];
if (!InstallAppButtonComponent) return null;
if (status === "unauthenticated")

View File

@ -0,0 +1,8 @@
---
items:
- /api/app-store/hubspotothercalendar/hubspot01.webp
---
<Slider items={items} />
HubSpot is a cloud-based CRM designed to help align sales and marketing teams, foster sales enablement, boost ROI and optimize your inbound marketing strategy to generate more, qualified leads.

View File

@ -0,0 +1,22 @@
import * as hubspot from "@hubspot/api-client";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
const scopes = ["crm.objects.contacts.read", "crm.objects.contacts.write"];
const client_id = process.env.HUBSPOT_CLIENT_ID;
const hubspotClient = new hubspot.Client();
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!client_id) {
res.status(400).json({ message: "HubSpot client id missing." });
return;
}
if (req.method === "GET") {
const redirectUri = WEBAPP_URL + "/api/integrations/hubspotothercalendar/callback";
const url = hubspotClient.oauth.getAuthorizationUrl(client_id, redirectUri, scopes.join(" "));
res.status(200).json({ url });
}
}

View File

@ -0,0 +1,56 @@
import * as hubspot from "@hubspot/api-client";
import { TokenResponseIF } from "@hubspot/api-client/lib/codegen/oauth/models/TokenResponseIF";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
const client_id = process.env.HUBSPOT_CLIENT_ID;
const client_secret = process.env.HUBSPOT_CLIENT_SECRET;
const hubspotClient = new hubspot.Client();
export type HubspotToken = TokenResponseIF & {
expiryDate?: number;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
if (!client_id) {
res.status(400).json({ message: "HubSpot client id missing." });
return;
}
if (!client_secret) {
res.status(400).json({ message: "HubSpot client secret missing." });
return;
}
const hubspotToken: HubspotToken = await hubspotClient.oauth.tokensApi.createToken(
"authorization_code",
code,
WEBAPP_URL + "/api/integrations/hubspotothercalendar/callback",
client_id,
client_secret
);
// set expiry date as offset from current time.
hubspotToken.expiryDate = Math.round(Date.now() + hubspotToken.expiresIn * 1000);
await prisma.credential.create({
data: {
type: "hubspot_other_calendar",
key: hubspotToken as any,
userId: req.session?.user.id,
},
});
const state = decodeOAuthState(req);
res.redirect(state?.returnTo ?? "/apps/installed");
}

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";

View File

@ -0,0 +1,18 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("hubspot_other_calendar");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}

View File

@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -0,0 +1,27 @@
import type { App } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "HubSpot CRM",
description: _package.description,
installed: !!(process.env.HUBSPOT_CLIENT_ID && process.env.HUBSPOT_CLIENT_SECRET),
type: "hubspot_other_calendar",
imageSrc: "/api/app-store/hubspotothercalendar/icon.svg",
variant: "other_calendar",
logo: "/api/app-store/hubspotothercalendar/icon.svg",
publisher: "Cal.com",
url: "https://hubspot.com/",
verified: true,
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "other",
label: "HubSpot CRM",
slug: "hubspot",
title: "HubSpot CRM",
trending: true,
email: "help@cal.com",
} as App;
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,212 @@
import * as hubspot from "@hubspot/api-client";
import { BatchInputPublicAssociation } from "@hubspot/api-client/lib/codegen/crm/associations";
import { PublicObjectSearchRequest } from "@hubspot/api-client/lib/codegen/crm/contacts";
import { BatchInputSimplePublicObjectInput } from "@hubspot/api-client/lib/codegen/crm/objects";
import { SimplePublicObjectInput } from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import { Credential } from "@prisma/client";
import { getLocation, getAdditionalNotes } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type {
AdditionInformation,
Calendar,
CalendarEvent,
ConferenceData,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { HubspotToken } from "../api/callback";
const hubspotClient = new hubspot.Client();
const client_id = process.env.HUBSPOT_CLIENT_ID;
const client_secret = process.env.HUBSPOT_CLIENT_SECRET;
export default class HubspotOtherCalendarService implements Calendar {
private url = "";
private integrationName = "";
private auth: { getToken: () => Promise<any> };
private log: typeof logger;
constructor(credential: Credential) {
this.integrationName = "hubspot_other_calendar";
this.auth = this.hubspotAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private hubspotContactSearch = async (event: CalendarEvent) => {
const publicObjectSearchRequest: PublicObjectSearchRequest = {
filterGroups: event.attendees.map((attendee) => ({
filters: [
{
value: attendee.email,
propertyName: "email",
operator: "EQ",
},
],
})),
sorts: ["hs_object_id"],
properties: ["hs_object_id", "email"],
limit: 10,
after: 0,
};
return await hubspotClient.crm.contacts.searchApi
.doSearch(publicObjectSearchRequest)
.then((apiResponse) => apiResponse.results);
};
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: any, contacts: any) => {
const batchInputPublicAssociation: BatchInputPublicAssociation = {
inputs: contacts.map((contact: any) => ({
_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 = (credential: Credential) => {
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 hubspotClient.oauth.tokensApi.createToken(
"refresh_token",
undefined,
WEBAPP_URL + "/api/integrations/hubspotothercalendar/callback",
client_id,
client_secret,
refreshToken
);
// 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 createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
await this.auth.getToken();
const contacts = await this.hubspotContactSearch(event);
if (contacts) {
const meetingEvent = await this.hubspotCreateMeeting(event);
if (meetingEvent) {
const associatedMeeting = await this.hubspotAssociate(meetingEvent, contacts);
if (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");
}
return Promise.reject("Something went wrong when creating a meeting in HubSpot");
}
return Promise.reject("Something went wrong when searching the atendee in HubSpot");
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
await this.auth.getToken();
return await this.hubspotUpdateMeeting(uid, event);
}
async deleteEvent(uid: string): Promise<void> {
await this.auth.getToken();
return await this.hubspotDeleteMeeting(uid);
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return Promise.resolve([]);
}
async listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}
}

View File

@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";

View File

@ -0,0 +1,15 @@
{
"private": true,
"name": "@calcom/hubspotothercalendar",
"version": "0.0.0",
"main": "./index.ts",
"description": "HubSpot is a cloud-based CRM designed to help align sales and marketing teams, foster sales enablement, boost ROI and optimize your inbound marketing strategy to generate more, qualified leads.",
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@hubspot/api-client": "^6.0.1-beta4"
},
"devDependencies": {
"@calcom/types": "*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="512px" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" width="512px" xml:space="preserve"><g id="_x31_68-hubspot"><g><path d="M266.197,216.109c-22.551,21.293-36.655,51.48-36.655,84.991c0,26.326,8.714,50.582,23.359,70.08 l-44.473,44.74c-3.953-1.438-8.176-2.245-12.579-2.245c-9.702,0-18.776,3.774-25.605,10.602 c-6.828,6.827-10.602,15.989-10.602,25.696c0,9.701,3.773,18.775,10.602,25.605c6.829,6.826,15.993,10.42,25.605,10.42 c9.703,0,18.777-3.505,25.695-10.42c6.829-6.83,10.602-15.994,10.602-25.605c0-3.774-0.538-7.369-1.707-10.873l44.923-45.102 c19.765,15.183,44.381,24.169,71.244,24.169c64.599,0,116.797-52.38,116.797-116.977c0-58.578-42.854-107.093-99.007-115.628 v-55.343c15.723-6.65,25.335-21.384,25.335-38.545c0-23.449-18.777-43.034-42.227-43.034c-23.448,0-41.956,19.585-41.956,43.034 c0,17.161,9.613,31.895,25.335,38.545v54.983c-13.655,1.887-26.593,6.019-38.362,12.219 c-24.796-18.778-105.565-76.997-151.746-112.126c1.078-3.953,1.798-8.085,1.798-12.397c0-25.875-21.113-46.898-47.078-46.898 c-25.875,0-46.898,21.023-46.898,46.898c0,25.965,21.023,46.988,46.898,46.988c8.805,0,16.98-2.606,24.078-6.828L266.197,216.109z M346.606,363.095c-34.229,0-61.991-27.763-61.991-61.994c0-34.229,27.762-61.99,61.991-61.99c34.23,0,61.992,27.761,61.992,61.99 C408.599,335.332,380.837,363.095,346.606,363.095z" style="fill:#FF7A59;"/></g></g><g id="Layer_1"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -4,6 +4,7 @@ import * as caldavcalendar from "./caldavcalendar";
import * as dailyvideo from "./dailyvideo";
import * as googlecalendar from "./googlecalendar";
import * as googlevideo from "./googlevideo";
import * as hubspotothercalendar from "./hubspotothercalendar";
import * as huddle01video from "./huddle01video";
import * as jitsivideo from "./jitsivideo";
import * as office365calendar from "./office365calendar";
@ -21,6 +22,7 @@ const appStore = {
dailyvideo,
googlecalendar,
googlevideo,
hubspotothercalendar,
huddle01video,
jitsivideo,
office365calendar,

View File

@ -107,6 +107,7 @@ export const createEvent = async (credential: Credential, calEvent: CalendarEven
calEvent.additionalNotes = "Notes have been hidden by the organiser"; // TODO: i18n this string?
}
// TODO: Surfice success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar.createEvent(calEvent).catch((e) => {
log.error("createEvent failed", e, calEvent);

View File

@ -19,7 +19,8 @@ export interface App {
| `${string}_payment`
| `${string}_video`
| `${string}_web3`
| `${string}_other`;
| `${string}_other`
| `${string}_other_calendar`;
/** The display name for the app, TODO settle between this or name */
title: string;
/** The display name for the app */
@ -29,7 +30,7 @@ export interface App {
/** The icon to display in /apps/installed */
imageSrc: string;
/** TODO determine if we should use this instead of category */
variant: "calendar" | "payment" | "conferencing" | "other";
variant: "calendar" | "payment" | "conferencing" | "other" | "other_calendar";
/** The slug for the app store public page inside `/apps/[slug] */
slug: string;
/** The category to which this app belongs, currently we have `calendar`, `payment` or `video` */

2084
yarn.lock

File diff suppressed because it is too large Load Diff