Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: crm apps with revert APIs #1

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.appStore.example
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ ZOHOCRM_CLIENT_SECRET=""


# - REVERT
# Used for the Pipedrive integration (via/ Revert (https://revert.dev))
# Used for the CRM integrations (via/ Revert (https://revert.dev))
# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys
REVERT_API_KEY=
REVERT_PUBLIC_TOKEN=
Expand Down
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,9 @@ UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=

# - REVERT
# Used for the CRM integrations (via/ Revert (https://revert.dev))
# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys
REVERT_API_KEY=
REVERT_PUBLIC_TOKEN=
171 changes: 171 additions & 0 deletions packages/app-store/_utils/crms/RevertCRMAppService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { getLocation } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type {
Calendar,
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";

export type ContactCreateResult = {
status: string;
result: {
id: string;
email: string;
firstName: string;
lastName: string;
name: string;
};
};

export type ContactSearchResult = {
status: string;
results: Array<{
id: string;
email: string;
firstName: string;
lastName: string;
name: string;
}>;
};

export default abstract class RevertCRMAppService implements Calendar {
protected log: typeof logger;
protected tenantId: string;
protected revertApiKey: string;
protected revertApiUrl: string;
protected appSlug: string;

constructor(credential: CredentialPayload) {
this.revertApiKey = process.env.REVERT_API_KEY || "";
this.revertApiUrl = process.env.REVERT_API_URL || "https://api.revert.dev/";
this.tenantId = String(credential.teamId ? credential.teamId : credential.userId);
this.log = logger.getSubLogger({ prefix: [`[[lib]`] });
this.appSlug = "";
}

protected createContacts = async (attendees: Person[]) => {
const result = attendees.map(async (attendee) => {
const headers = new Headers();
headers.append("x-revert-api-token", this.revertApiKey);
headers.append("x-revert-t-id", this.tenantId);
headers.append("Content-Type", "application/json");

const [firstname, lastname] = !!attendee.name ? attendee.name.split(" ") : [attendee.email, "-"];
const bodyRaw = JSON.stringify({
firstName: firstname,
lastName: lastname || "-",
email: attendee.email,
});

const requestOptions = {
method: "POST",
headers: headers,
body: bodyRaw,
};

try {
const response = await fetch(`${this.revertApiUrl}crm/contacts`, requestOptions);
const result = (await response.json()) as ContactCreateResult;
return result;
} catch (error) {
return Promise.reject(error);
}
});
return await Promise.all(result);
};

protected getMeetingBody = (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 || "-"
}`;
};

protected abstract contactSearch(
event: CalendarEvent
): Promise<ContactSearchResult | ContactSearchResult[]>;

protected abstract createCRMEvent(
event: CalendarEvent,
contacts: CalendarEvent["attendees"]
): Promise<Response>;

protected updateMeeting = async (uid: string, event: CalendarEvent) => {
const eventPayload = {
subject: event.title,
startDateTime: event.startTime,
endDateTime: event.endTime,
description: this.getMeetingBody(event),
location: getLocation(event),
};
const headers = new Headers();
headers.append("x-revert-api-token", this.revertApiKey);
headers.append("x-revert-t-id", this.tenantId);
headers.append("Content-Type", "application/json");

const eventBody = JSON.stringify(eventPayload);
const requestOptions = {
method: "PATCH",
headers: headers,
body: eventBody,
};

return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions);
};

protected deleteMeeting = async (uid: string) => {
const headers = new Headers();
headers.append("x-revert-api-token", this.revertApiKey);
headers.append("x-revert-t-id", this.tenantId);

const requestOptions = {
method: "DELETE",
headers: headers,
};

return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions);
};

protected async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) {
const meetingEvent = await (await this.createCRMEvent(event, contacts)).json();
if (meetingEvent && meetingEvent.status === "ok") {
this.log.debug("event:creation:ok", { meetingEvent });

return Promise.resolve({
uid: meetingEvent.result.id,
id: meetingEvent.result.id,
type: this.appSlug,
password: "",
url: "",
additionalInfo: { contacts, meetingEvent },
});
}
this.log.debug("meeting:creation:notOk", { meetingEvent, event, contacts });
return Promise.reject("Something went wrong when creating a meeting");
}

async getAvailability(
_dateFrom: string,
_dateTo: string,
_selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return Promise.resolve([]);
}

async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}

abstract createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;

abstract updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType>;

public async deleteEvent(uid: string): Promise<void> {
await this.deleteMeeting(uid);
}
}
4 changes: 2 additions & 2 deletions packages/app-store/apps.metadata.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { metadata as giphy__metadata_ts } from "./giphy/_metadata";
import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata";
import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import gtm_config_json from "./gtm/config.json";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
import hubspot_config_json from "./hubspot/config.json";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json";
import intercom_config_json from "./intercom/config.json";
Expand Down Expand Up @@ -118,7 +118,7 @@ export const appStoreMetadata = {
googlecalendar: googlecalendar__metadata_ts,
googlevideo: googlevideo__metadata_ts,
gtm: gtm_config_json,
hubspot: hubspot__metadata_ts,
hubspot: hubspot_config_json,
huddle01video: huddle01video__metadata_ts,
"ics-feedcalendar": ics_feedcalendar_config_json,
intercom: intercom_config_json,
Expand Down
22 changes: 0 additions & 22 deletions packages/app-store/hubspot/_metadata.ts

This file was deleted.

60 changes: 40 additions & 20 deletions packages/app-store/hubspot/api/add.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,50 @@
import * as hubspot from "@hubspot/api-client";
import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

const scopes = ["crm.objects.contacts.read", "crm.objects.contacts.write"];

let client_id = "";
const hubspotClient = new hubspot.Client();
import appConfig from "../config.json";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
const appKeys = await getAppKeysFromSlug(appConfig.slug);

const appKeys = await getAppKeysFromSlug("hubspot");
let client_id = "";
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "HubSpot client id missing." });

const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/hubspot/callback`;
const url = hubspotClient.oauth.getAuthorizationUrl(
client_id,
redirectUri,
scopes.join(" "),
undefined,
encodeOAuthState(req)
);
res.status(200).json({ url });
if (!client_id) return res.status(400).json({ message: "hubspot client id missing." });
let client_secret = "";
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_secret) return res.status(400).json({ message: "hubspot client secret missing." });
// Check that user is authenticated
req.session = await getServerSession({ req, res });
const { teamId } = req.query;
const user = req.session?.user;
if (!user) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const userId = user.id;
await createDefaultInstallation({
appType: `${appConfig.slug}_other_calendar`,
user,
slug: appConfig.slug,
key: {},
teamId: Number(teamId),
});
const tenantId = teamId ? teamId : userId;
const scopes = [
"crm.objects.contacts.read",
"crm.objects.contacts.write",
"crm.objects.marketing_events.read",
"crm.objects.marketing_events.write",
];
res.status(200).json({
url: `https://app.hubspot.com/oauth/authorize?client_id=${
appKeys.client_id
}&redirect_uri=https://app.revert.dev/oauth-callback/hubspot&state={%22tenantId%22:%22${tenantId}%22,%22revertPublicToken%22:%22${
process.env.REVERT_PUBLIC_TOKEN
}%22}&scope=${scopes.join("%20")}`,
newTab: true,
});
}
44 changes: 3 additions & 41 deletions packages/app-store/hubspot/api/callback.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,19 @@
import * as hubspot from "@hubspot/api-client";
import type { TokenResponseIF } from "@hubspot/api-client/lib/codegen/oauth/models/TokenResponseIF";
import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import metadata from "../_metadata";

let client_id = "";
let client_secret = "";
const hubspotClient = new hubspot.Client();

export interface HubspotToken extends TokenResponseIF {
expiryDate?: number;
}
import appConfig from "../config.json";

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 (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}

const appKeys = await getAppKeysFromSlug("hubspot");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "HubSpot client id missing." });
if (!client_secret) return res.status(400).json({ message: "HubSpot client secret missing." });

const hubspotToken: HubspotToken = await hubspotClient.oauth.tokensApi.createToken(
"authorization_code",
code,
`${WEBAPP_URL_FOR_OAUTH}/api/integrations/hubspot/callback`,
client_id,
client_secret
);

// set expiry date as offset from current time.
hubspotToken.expiryDate = Math.round(Date.now() + hubspotToken.expiresIn * 1000);

await createOAuthAppCredential({ appId: metadata.slug, type: metadata.type }, hubspotToken, req);

const state = decodeOAuthState(req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "hubspot" })
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })
);
}
17 changes: 17 additions & 0 deletions packages/app-store/hubspot/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Hubspot CRM",
"slug": "hubspot",
"type": "hubspot_other_calendar",
"logo": "icon.svg",
"url": "https://revert.dev",
"variant": "crm",
"categories": ["crm"],
"publisher": "Revert.dev ",
"email": "[email protected]",
"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.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "hubspot"
}
1 change: 0 additions & 1 deletion packages/app-store/hubspot/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
Loading
Loading