feat: pipedrive crm app on cal (#12316)

* add pipedrive crm app w/ revert api

* update lockfile

* fix issues highlighted by codacy

* get pipedrive `client_id` & `client_secret` from db

* update readme with instructions to add credentials

* Fix yarn.lock

* fix `turbo.json`

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
Jatin Sandilya 2023-12-29 15:25:26 +05:30 committed by GitHub
parent 5de77e386c
commit 1f036bf35e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 953 additions and 640 deletions

View File

@ -127,4 +127,12 @@ ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# - REVERT
# Used for the Pipedrive integration (via/ Revert (https://revert.dev))
# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys
REVERT_API_KEY=
REVERT_PUBLIC_TOKEN=
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
REVERT_API_URL=https://api.revert.dev/
# *********************************************************************************************************

View File

@ -554,6 +554,10 @@ following
[Follow these steps](./packages/app-store/zoho-bigin/)
### Obtaining Pipedrive Client ID and Secret
[Follow these steps](./packages/app-store/pipedrive-crm/)
## Workflows
### Setting up SendGrid for Email reminders

View File

@ -20,6 +20,7 @@ import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
import { appKeysSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod";
import { appKeysSchema as plausible_zod_ts } from "./plausible/zod";
import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod";
import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod";
@ -57,6 +58,7 @@ export const appKeysSchemas = {
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
"pipedrive-crm": pipedrive_crm_zod_ts,
plausible: plausible_zod_ts,
qr_code: qr_code_zod_ts,
"routing-forms": routing_forms_zod_ts,

View File

@ -42,6 +42,7 @@ import office365video_config_json from "./office365video/config.json";
import paypal_config_json from "./paypal/config.json";
import ping_config_json from "./ping/config.json";
import pipedream_config_json from "./pipedream/config.json";
import pipedrive_crm_config_json from "./pipedrive-crm/config.json";
import plausible_config_json from "./plausible/config.json";
import qr_code_config_json from "./qr_code/config.json";
import raycast_config_json from "./raycast/config.json";
@ -120,6 +121,7 @@ export const appStoreMetadata = {
paypal: paypal_config_json,
ping: ping_config_json,
pipedream: pipedream_config_json,
"pipedrive-crm": pipedrive_crm_config_json,
plausible: plausible_config_json,
qr_code: qr_code_config_json,
raycast: raycast_config_json,

View File

@ -20,6 +20,7 @@ import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
import { appDataSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod";
import { appDataSchema as plausible_zod_ts } from "./plausible/zod";
import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod";
import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod";
@ -57,6 +58,7 @@ export const appDataSchemas = {
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
"pipedrive-crm": pipedrive_crm_zod_ts,
plausible: plausible_zod_ts,
qr_code: qr_code_zod_ts,
"routing-forms": routing_forms_zod_ts,

View File

@ -42,6 +42,7 @@ export const apiHandlers = {
paypal: import("./paypal/api"),
ping: import("./ping/api"),
pipedream: import("./pipedream/api"),
"pipedrive-crm": import("./pipedrive-crm/api"),
plausible: import("./plausible/api"),
qr_code: import("./qr_code/api"),
raycast: import("./raycast/api"),

View File

@ -16,6 +16,7 @@ const appStore = {
office365video: () => import("./office365video"),
plausible: () => import("./plausible"),
paypal: () => import("./paypal"),
"pipedrive-crm": () => import("./pipedrive-crm"),
salesforce: () => import("./salesforce"),
zohocrm: () => import("./zohocrm"),
sendgrid: () => import("./sendgrid"),

View File

@ -0,0 +1,6 @@
---
items:
- pipedrive-banner.jpeg
---
{DESCRIPTION}

View File

@ -0,0 +1,19 @@
## Pipedrive Integration via Revert
#### Obtaining Pipedrive Client ID and Secret
* Open [Pipedrive Developers Corner](https://developers.pipedrive.com/) and sign in to your account, or create a new one
* Go to Settings > (company name) Developer Hub
* Create a Pipedrive app, using the steps mentioned [here](https://pipedrive.readme.io/docs/marketplace-creating-a-proper-app#create-an-app-in-5-simple-steps)
* You can skip this step and use the default revert Pipedrive app
* Set `https://app.revert.dev/oauth-callback/pipedrive` as a callback url for your app
* **Get your client\_id and client\_secret**:
* Go to the "OAuth & access scopes" tab of your app
* Copy your client\_id and client\_secret
#### Obtaining Revert API keys
* Create an account on Revert if you don't already have one. (https://app.revert.dev/sign-up)
* Login to your revert dashboard (https://app.revert.dev/sign-in) and click on `Customize your apps` - `Pipedrive`
* Enter the `client_id` and `client_secret` you copied in the previous step
* Enter the `client_id` and `client_secret` previously copied to `Settings > Admin > Apps > CRM > Pipedrive` by clicking the `Edit` button on the app settings.

View File

@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from "next";
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 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);
let client_id = "";
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "pipedrive client id missing." });
// Check that user is authenticated
req.session = await getServerSession({ req, res });
const { teamId } = req.query;
const userId = req.session?.user.id;
if (!userId) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
await createDefaultInstallation({
appType: `${appConfig.slug}_other_calendar`,
userId: userId,
slug: appConfig.slug,
key: {},
teamId: Number(teamId),
});
const tenantId = teamId ? teamId : userId;
res.status(200).json({
url: `https://oauth.pipedrive.com/oauth/authorize?client_id=${appKeys.client_id}&redirect_uri=https://app.revert.dev/oauth-callback/pipedrive&state={%22tenantId%22:%22${tenantId}%22,%22revertPublicToken%22:%22${process.env.REVERT_PUBLIC_TOKEN}%22}`,
newTab: true,
});
}

View File

@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const state = decodeOAuthState(req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })
);
}

View File

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

View File

@ -0,0 +1,17 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Pipedrive CRM",
"slug": "pipedrive-crm",
"type": "pipedrive-crm_other_calendar",
"logo": "icon.svg",
"url": "https://revert.dev",
"variant": "crm",
"categories": ["crm"],
"publisher": "Revert.dev ",
"email": "jatin@revert.dev",
"description": "Founded in 2010, Pipedrive is an easy and effective sales CRM that drives small business growth.\r\rToday, Pipedrive is used by revenue teams at more than 100,000 companies worldwide. Pipedrive is headquartered in New York and has offices across Europe and the US.\r\rThe company is backed by majority holder Vista Equity Partners, Bessemer Venture Partners, Insight Partners, Atomico, and DTCP.\r\rLearn more at www.pipedrive.com.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "pipedrive-crm"
}

View File

@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,313 @@
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";
import appConfig from "../config.json";
type ContactSearchResult = {
status: string;
results: Array<{
id: string;
email: string;
firstName: string;
lastName: string;
name: string;
}>;
};
type ContactCreateResult = {
status: string;
result: {
id: string;
email: string;
firstName: string;
lastName: string;
name: string;
};
};
export default class PipedriveCalendarService implements Calendar {
private log: typeof logger;
private tenantId: string;
private revertApiKey: string;
private revertApiUrl: 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); // Question: Is this a reasonable assumption to be made? Get confirmation on the exact field to be used here.
this.log = logger.getSubLogger({ prefix: [`[[lib] ${appConfig.slug}`] });
}
private 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);
};
private contactSearch = async (event: CalendarEvent) => {
const result = event.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 bodyRaw = JSON.stringify({ searchCriteria: attendee.email });
const requestOptions = {
method: "POST",
headers: headers,
body: bodyRaw,
};
try {
const response = await fetch(`${this.revertApiUrl}crm/contacts/search`, requestOptions);
const result = (await response.json()) as ContactSearchResult;
return result;
} catch (error) {
return { status: "error", results: [] };
}
});
return await Promise.all(result);
};
private 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 || "-"
}`;
};
private createPipedriveEvent = async (event: CalendarEvent, contacts: CalendarEvent["attendees"]) => {
const eventPayload = {
subject: event.title,
startDateTime: event.startTime,
endDateTime: event.endTime,
description: this.getMeetingBody(event),
location: getLocation(event),
associations: {
contactId: String(contacts[0].id),
},
};
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: "POST",
headers: headers,
body: eventBody,
};
return await fetch(`${this.revertApiUrl}crm/events`, requestOptions);
};
private 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);
};
private 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);
};
async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) {
const meetingEvent = await (await this.createPipedriveEvent(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: appConfig.slug,
password: "",
url: "",
additionalInfo: { contacts, meetingEvent },
});
}
this.log.debug("meeting:creation:notOk", { meetingEvent, event, contacts });
return Promise.reject("Something went wrong when creating a meeting in PipedriveCRM");
}
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
let contacts = await this.contactSearch(event);
contacts = contacts.filter((c) => c.results.length >= 1);
if (contacts && contacts.length) {
if (contacts.length === event.attendees.length) {
// all contacts are in Pipedrive CRM already.
this.log.debug("contact:search:all", { event, contacts: contacts });
const existingPeople = contacts.map((c) => {
return {
id: Number(c.results[0].id),
name: `${c.results[0].firstName} ${c.results[0].lastName}`,
email: c.results[0].email,
timeZone: event.attendees[0].timeZone,
language: event.attendees[0].language,
};
});
return await this.handleEventCreation(event, existingPeople);
} else {
// Some attendees don't exist in PipedriveCRM
// Get the existing contacts' email to filter out
this.log.debug("contact:search:notAll", { event, contacts });
const existingContacts = contacts.map((contact) => contact.results[0].email);
this.log.debug("contact:filter:existing", { existingContacts });
// Get non existing contacts filtering out existing from attendees
const nonExistingContacts: Person[] = event.attendees.filter(
(attendee) => !existingContacts.includes(attendee.email)
);
this.log.debug("contact:filter:nonExisting", { nonExistingContacts });
// Only create contacts in PipedriveCRM that were not present in the previous contact search
const createdContacts = await this.createContacts(nonExistingContacts);
this.log.debug("contact:created", { createdContacts });
// Continue with event creation and association only when all contacts are present in Pipedrive
if (createdContacts[0] && createdContacts[0].status === "ok") {
this.log.debug("contact:creation:ok");
const existingPeople = contacts.map((c) => {
return {
id: Number(c.results[0].id),
name: c.results[0].name,
email: c.results[0].email,
timeZone: nonExistingContacts[0].timeZone,
language: nonExistingContacts[0].language,
};
});
const newlyCreatedPeople = createdContacts.map((c) => {
return {
id: Number(c.result.id),
name: c.result.name,
email: c.result.email,
timeZone: nonExistingContacts[0].timeZone,
language: nonExistingContacts[0].language,
};
});
const allContacts = existingPeople.concat(newlyCreatedPeople);
// ensure the order of attendees is maintained.
allContacts.sort((a, b) => {
const indexA = event.attendees.findIndex((c) => c.email === a.email);
const indexB = event.attendees.findIndex((c) => c.email === b.email);
return indexA - indexB;
});
return await this.handleEventCreation(event, allContacts);
}
return Promise.reject({
calError: "Something went wrong when creating non-existing attendees in PipedriveCRM",
});
}
} else {
this.log.debug("contact:search:none", { event, contacts });
const createdContacts = await this.createContacts(event.attendees);
this.log.debug("contact:created", { createdContacts });
if (createdContacts[0] && createdContacts[0].status === "ok") {
this.log.debug("contact:creation:ok");
const newContacts = createdContacts.map((c) => {
return {
id: Number(c.result.id),
name: c.result.name,
email: c.result.email,
timeZone: event.attendees[0].timeZone,
language: event.attendees[0].language,
};
});
return await this.handleEventCreation(event, newContacts);
}
}
return Promise.reject({
calError: "Something went wrong when searching/creating the attendees in PipedriveCRM",
});
}
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
const meetingEvent = await (await this.updateMeeting(uid, event)).json();
if (meetingEvent && meetingEvent.status === "ok") {
this.log.debug("event:updation:ok", { meetingEvent });
return Promise.resolve({
uid: meetingEvent.result.id,
id: meetingEvent.result.id,
type: appConfig.slug,
password: "",
url: "",
additionalInfo: { meetingEvent },
});
}
this.log.debug("meeting:updation:notOk", { meetingEvent, event });
return Promise.reject("Something went wrong when updating a meeting in PipedriveCRM");
}
async deleteEvent(uid: string): Promise<void> {
await this.deleteMeeting(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,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/pipedrive-crm",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Founded in 2010, Pipedrive is an easy and effective sales CRM that drives small business growth.\r\rToday, Pipedrive is used by revenue teams at more than 100,000 companies worldwide. Pipedrive is headquartered in New York and has offices across Europe and the US.\r\rThe company is backed by majority holder Vista Equity Partners, Bessemer Venture Partners, Insight Partners, Atomico, and DTCP.\r\rLearn more at www.pipedrive.com."
}

View File

@ -0,0 +1,23 @@
<svg width="304px" height="304px" viewBox="0 0 304 304" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.4 (67378) - http://www.bohemiancoding.com/sketch -->
<title>
Pipedrive_letter_logo_light@1,5x
</title>
<desc>
Created with Sketch.
</desc>
<defs>
<path d="M59.6807,81.1772 C59.6807,101.5343 70.0078,123.4949 92.7336,123.4949 C109.5872,123.4949 126.6277,110.3374 126.6277,80.8785 C126.6277,55.0508 113.232,37.7119 93.2944,37.7119 C77.0483,37.7119 59.6807,49.1244 59.6807,81.1772 Z M101.3006,0 C142.0482,0 169.4469,32.2728 169.4469,80.3126 C169.4469,127.5978 140.584,160.60942 99.3224,160.60942 C79.6495,160.60942 67.0483,152.1836 60.4595,146.0843 C60.5063,147.5305 60.5374,149.1497 60.5374,150.8788 L60.5374,215 L18.32565,215 L18.32565,44.157 C18.32565,41.6732 17.53126,40.8873 15.07021,40.8873 L0.5531,40.8873 L0.5531,3.4741 L35.9736,3.4741 C52.282,3.4741 56.4564,11.7741 57.2508,18.1721 C63.8708,10.7524 77.5935,0 101.3006,0 Z" id="path-1">
</path>
</defs>
<g id="Pipedrive_letter_logo_light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Pipedrive_monogram_logo_light" transform="translate(67.000000, 44.000000)">
<mask id="mask-2" fill="white">
<use href="#path-1">
</use>
</mask>
<use id="Clip-5" fill="#26292C" xlink:href="#path-1">
</use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({});

View File

@ -353,6 +353,9 @@
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"REVERT_API_KEY",
"REVERT_API_URL",
"REVERT_PUBLIC_TOKEN",
"RESEND_API_KEY",
"LOCAL_TESTING_DOMAIN_VERCEL",
"AUTH_BEARER_TOKEN_CLOUDFLARE",

1111
yarn.lock

File diff suppressed because it is too large Load Diff