feat: remove Revert.dev dependency from Pipedrive integration (#22492)
* feat: remove Revert.dev dependency from Pipedrive integration - Replace Revert API calls with direct Pipedrive OAuth2 and REST API - Update OAuth flow to use Pipedrive's authorization endpoint - Implement token exchange in callback handler - Map Revert's unified API format to Pipedrive's native API structure - Remove REVERT_* environment variables from configuration - Update app config to reflect direct Pipedrive integration - Follow patterns from other CRM integrations like HubSpot Breaking change: Requires PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET environment variables instead of REVERT_* variables Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * feat: implement proper token refresh logic for Pipedrive OAuth - Add token refresh implementation following Pipedrive OAuth2 specification - Use proper Basic Auth for token refresh requests - Handle token refresh errors with appropriate logging - Update access token and expiry date after successful refresh - Add note about database credential update requirement Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * feat: add database credential updates for Pipedrive token refresh - Import updateTokenObjectInDb utility for proper credential management - Store credentialId in constructor for database updates - Update token refresh logic to persist refreshed tokens to database - Follow Cal.com patterns for OAuth credential management - Ensure tokens are properly updated after successful refresh Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * refactor: use OAuthManager for Pipedrive token management - Refactor CrmService.ts to use OAuthManager instead of manual token refresh - Create getPipedriveAppKeys helper function for client credentials - Update callback.ts to use the new helper function - Add requestPipedrive helper method for all API calls - Implement proper token refresh callbacks (isTokenObjectUnusable, isAccessTokenUnusable) This addresses the review comments from hariom and cubic on PR #22492. Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: validate token endpoint response before storing Pipedrive credentials Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -129,14 +129,6 @@ 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/
|
||||
# *********************************************************************************************************
|
||||
|
||||
# - Huddle01
|
||||
|
||||
@@ -2,25 +2,28 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
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
|
||||
if (!client_id) return res.status(400).json({ message: "Pipedrive client id missing." });
|
||||
|
||||
req.session = await getServerSession({ req });
|
||||
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,
|
||||
@@ -29,9 +32,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
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,
|
||||
});
|
||||
const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/pipedrive-crm/callback`;
|
||||
const state = encodeOAuthState(req);
|
||||
const scopes = "deals:read,deals:write,persons:read,persons:write,activities:read,activities:write";
|
||||
|
||||
const url = `https://oauth.pipedrive.com/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(
|
||||
redirectUri
|
||||
)}&state=${encodeURIComponent(state || "")}&response_type=code&scope=${encodeURIComponent(scopes)}`;
|
||||
|
||||
res.status(200).json({ url, newTab: true });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
import appConfig from "../config.json";
|
||||
import { getPipedriveAppKeys } from "../lib/getPipedriveAppKeys";
|
||||
|
||||
export interface PipedriveToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
expires_in: number;
|
||||
api_domain: string;
|
||||
expiryDate?: number;
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { code } = req.query;
|
||||
const state = decodeOAuthState(req);
|
||||
|
||||
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 state = decodeOAuthState(req);
|
||||
const { client_id, client_secret } = await getPipedriveAppKeys();
|
||||
|
||||
const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/pipedrive-crm/callback`;
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
|
||||
const tokenResponse = await fetch("https://oauth.pipedrive.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code || "",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorBody = await tokenResponse.text();
|
||||
res.status(tokenResponse.status).json({ message: "Failed to exchange OAuth code", detail: errorBody });
|
||||
return;
|
||||
}
|
||||
|
||||
const pipedriveToken: PipedriveToken = await tokenResponse.json();
|
||||
|
||||
pipedriveToken.expiryDate = Math.round(Date.now() + pipedriveToken.expires_in * 1000);
|
||||
|
||||
await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, pipedriveToken, req);
|
||||
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ??
|
||||
getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
"slug": "pipedrive-crm",
|
||||
"type": "pipedrive-crm_crm",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://revert.dev",
|
||||
"url": "https://www.pipedrive.com",
|
||||
"variant": "crm",
|
||||
"categories": ["crm"],
|
||||
"extendsFeature": "EventType",
|
||||
"publisher": "Revert.dev ",
|
||||
"email": "jatin@revert.dev",
|
||||
"publisher": "Cal.com",
|
||||
"email": "support@cal.com",
|
||||
"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,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
EventBusyDate,
|
||||
@@ -9,149 +16,270 @@ import type {
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { ContactCreateInput, CRM, Contact } from "@calcom/types/CrmService";
|
||||
|
||||
import { invalidateCredential } from "../../_utils/invalidateCredential";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
|
||||
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
|
||||
import { updateTokenObjectInDb } from "../../_utils/oauth/updateTokenObject";
|
||||
import appConfig from "../config.json";
|
||||
import { getPipedriveAppKeys } from "./getPipedriveAppKeys";
|
||||
|
||||
type ContactSearchResult = {
|
||||
status: string;
|
||||
results: Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
name: string;
|
||||
}>;
|
||||
type PipedriveContact = {
|
||||
id: number;
|
||||
name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: Array<{ value: string; primary: boolean }>;
|
||||
};
|
||||
|
||||
type ContactCreateResult = {
|
||||
status: string;
|
||||
result: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
name: string;
|
||||
};
|
||||
type PipedriveActivity = {
|
||||
id: number;
|
||||
subject: string;
|
||||
due_date: string;
|
||||
due_time: string;
|
||||
duration: string;
|
||||
note: string;
|
||||
location: string;
|
||||
person_id: number;
|
||||
};
|
||||
|
||||
class PipedriveCrmService implements CRM {
|
||||
private credential: CredentialPayload;
|
||||
private log: typeof logger;
|
||||
private tenantId: string;
|
||||
private revertApiKey: string;
|
||||
private revertApiUrl: string;
|
||||
private auth: OAuthManager;
|
||||
private apiDomain: 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.credential = credential;
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${appConfig.slug}`] });
|
||||
|
||||
const tokenResponse = getTokenObjectFromCredential(credential);
|
||||
// Pipedrive stores api_domain in the token object
|
||||
const key = credential.key as { api_domain?: string };
|
||||
this.apiDomain = key.api_domain || "";
|
||||
|
||||
this.auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
},
|
||||
resourceOwner: {
|
||||
type: credential.teamId ? "team" : "user",
|
||||
id: credential.teamId ?? credential.userId,
|
||||
},
|
||||
appSlug: appConfig.slug,
|
||||
currentTokenObject: tokenResponse,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const { client_id, client_secret } = await getPipedriveAppKeys();
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
return fetch("https://oauth.pipedrive.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["pipedrive-crm:isTokenObjectUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.clone().json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
// Pipedrive returns "invalid_grant" when refresh token is invalid
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return { reason: responseBody.error };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["pipedrive-crm:isAccessTokenUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok && response.status === 401) {
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.clone().json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
// Pipedrive returns 401 with error when access token is invalid
|
||||
if (responseBody.error) {
|
||||
return { reason: responseBody.error };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => invalidateCredential(credential.id),
|
||||
expireAccessToken: () => markTokenAsExpired(credential),
|
||||
updateTokenObject: async (newTokenObject) => {
|
||||
// Update api_domain in instance if it changed
|
||||
if (newTokenObject.api_domain) {
|
||||
this.apiDomain = newTokenObject.api_domain as string;
|
||||
}
|
||||
await updateTokenObjectInDb({
|
||||
authStrategy: "oauth",
|
||||
credentialId: credential.id,
|
||||
tokenObject: newTokenObject,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async requestPipedrive<T = unknown>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
// Ensure we have a valid token and get the latest api_domain
|
||||
const { token } = await this.auth.getTokenObjectOrFetch();
|
||||
const apiDomain = (token as { api_domain?: string }).api_domain || this.apiDomain;
|
||||
|
||||
const { json } = await this.auth.request({
|
||||
url: `${apiDomain}/api/v1/${endpoint}`,
|
||||
options: {
|
||||
method: "GET",
|
||||
...options,
|
||||
},
|
||||
});
|
||||
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async createContacts(contactsToCreate: ContactCreateInput[]): Promise<Contact[]> {
|
||||
const result = contactsToCreate.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 [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,
|
||||
const bodyData = {
|
||||
name: attendee.name || attendee.email,
|
||||
first_name: firstName,
|
||||
last_name: lastName || "",
|
||||
email: [{ value: attendee.email, primary: true }],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.revertApiUrl}crm/contacts`, requestOptions);
|
||||
const result = (await response.json()) as ContactCreateResult;
|
||||
return result;
|
||||
const response = await this.requestPipedrive<{ success: boolean; data: PipedriveContact }>(
|
||||
"persons",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const contact = response.data;
|
||||
return {
|
||||
id: contact.id.toString(),
|
||||
email: contact.email[0]?.value || attendee.email,
|
||||
firstName: contact.first_name,
|
||||
lastName: contact.last_name,
|
||||
name: contact.name,
|
||||
};
|
||||
}
|
||||
throw new Error("Failed to create contact");
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
this.log.error("Error creating contact:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(result);
|
||||
return results.map((result) => result.result);
|
||||
return await Promise.all(result);
|
||||
}
|
||||
|
||||
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
|
||||
const emailArray = Array.isArray(emails) ? emails : [emails];
|
||||
|
||||
const result = emailArray.map(async (attendeeEmail) => {
|
||||
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: attendeeEmail });
|
||||
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: bodyRaw,
|
||||
};
|
||||
|
||||
const result = emailArray.map(async (email) => {
|
||||
try {
|
||||
const response = await fetch(`${this.revertApiUrl}crm/contacts/search`, requestOptions);
|
||||
const result = (await response.json()) as ContactSearchResult;
|
||||
return result;
|
||||
} catch {
|
||||
return { status: "error", results: [] };
|
||||
const response = await this.requestPipedrive<{
|
||||
success: boolean;
|
||||
data: { items: Array<{ item: PipedriveContact }> };
|
||||
}>(`persons/search?term=${encodeURIComponent(email)}&fields=email`);
|
||||
|
||||
if (response.success && response.data?.items) {
|
||||
return response.data.items.map((item) => {
|
||||
const contact = item.item;
|
||||
return {
|
||||
id: contact.id.toString(),
|
||||
email: contact.email[0]?.value || email,
|
||||
firstName: contact.first_name,
|
||||
lastName: contact.last_name,
|
||||
name: contact.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
this.log.error("Error searching contacts:", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(result);
|
||||
return results[0].results;
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
private getMeetingBody = (event: CalendarEvent): string => {
|
||||
return `<b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
|
||||
return `${event.organizer.language.translate("invitee_timezone")}: ${
|
||||
event.attendees[0].timeZone
|
||||
}<br><br><b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
|
||||
event.additionalNotes || "-"
|
||||
}`;
|
||||
}\n\n${event.organizer.language.translate("share_additional_notes")}\n${event.additionalNotes || "-"}`;
|
||||
};
|
||||
|
||||
private createPipedriveEvent = async (event: CalendarEvent, contacts: Contact[]) => {
|
||||
const eventPayload = {
|
||||
private createPipedriveActivity = async (event: CalendarEvent, contacts: Contact[]) => {
|
||||
const startDate = new Date(event.startTime);
|
||||
const endDate = new Date(event.endTime);
|
||||
const duration = Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
|
||||
|
||||
const activityPayload = {
|
||||
subject: event.title,
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
description: this.getMeetingBody(event),
|
||||
type: "meeting",
|
||||
due_date: startDate.toISOString().split("T")[0],
|
||||
due_time: startDate.toTimeString().split(" ")[0].substring(0, 5),
|
||||
duration: `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`,
|
||||
note: this.getMeetingBody(event),
|
||||
location: getLocation({
|
||||
videoCallData: event.videoCallData,
|
||||
additionalInformation: event.additionalInformation,
|
||||
location: event.location,
|
||||
uid: event.uid,
|
||||
}),
|
||||
associations: {
|
||||
contactId: String(contacts[0].id),
|
||||
},
|
||||
person_id: parseInt(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 = {
|
||||
return this.requestPipedrive<{ success: boolean; data: PipedriveActivity }>("activities", {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: eventBody,
|
||||
};
|
||||
|
||||
return await fetch(`${this.revertApiUrl}crm/events`, requestOptions);
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(activityPayload),
|
||||
});
|
||||
};
|
||||
|
||||
private updateMeeting = async (uid: string, event: CalendarEvent) => {
|
||||
const eventPayload = {
|
||||
private updateActivity = async (uid: string, event: CalendarEvent) => {
|
||||
const startDate = new Date(event.startTime);
|
||||
const endDate = new Date(event.endTime);
|
||||
const duration = Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
|
||||
|
||||
const activityPayload = {
|
||||
subject: event.title,
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
description: this.getMeetingBody(event),
|
||||
due_date: startDate.toISOString().split("T")[0],
|
||||
due_time: startDate.toTimeString().split(" ")[0].substring(0, 5),
|
||||
duration: `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`,
|
||||
note: this.getMeetingBody(event),
|
||||
location: getLocation({
|
||||
videoCallData: event.videoCallData,
|
||||
additionalInformation: event.additionalInformation,
|
||||
@@ -159,47 +287,37 @@ class PipedriveCrmService implements CRM {
|
||||
uid: event.uid,
|
||||
}),
|
||||
};
|
||||
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);
|
||||
return this.requestPipedrive<{ success: boolean; data: PipedriveActivity }>(`activities/${uid}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(activityPayload),
|
||||
});
|
||||
};
|
||||
|
||||
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 = {
|
||||
private deleteActivity = async (uid: string) => {
|
||||
return this.requestPipedrive<{ success: boolean }>(`activities/${uid}`, {
|
||||
method: "DELETE",
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions);
|
||||
});
|
||||
};
|
||||
|
||||
async handleEventCreation(event: CalendarEvent, contacts: Contact[]) {
|
||||
const meetingEvent = await (await this.createPipedriveEvent(event, contacts)).json();
|
||||
if (meetingEvent && meetingEvent.status === "ok") {
|
||||
const meetingEvent = await this.createPipedriveActivity(event, contacts);
|
||||
|
||||
if (meetingEvent.success && meetingEvent.data) {
|
||||
this.log.debug("event:creation:ok", { meetingEvent });
|
||||
return Promise.resolve({
|
||||
uid: meetingEvent.result.id,
|
||||
id: meetingEvent.result.id,
|
||||
uid: meetingEvent.data.id.toString(),
|
||||
id: meetingEvent.data.id.toString(),
|
||||
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");
|
||||
}
|
||||
@@ -209,24 +327,26 @@ class PipedriveCrmService implements CRM {
|
||||
}
|
||||
|
||||
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
const meetingEvent = await (await this.updateMeeting(uid, event)).json();
|
||||
if (meetingEvent && meetingEvent.status === "ok") {
|
||||
const meetingEvent = await this.updateActivity(uid, event);
|
||||
|
||||
if (meetingEvent.success && meetingEvent.data) {
|
||||
this.log.debug("event:updation:ok", { meetingEvent });
|
||||
return Promise.resolve({
|
||||
uid: meetingEvent.result.id,
|
||||
id: meetingEvent.result.id,
|
||||
uid: meetingEvent.data.id.toString(),
|
||||
id: meetingEvent.data.id.toString(),
|
||||
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);
|
||||
await this.deleteActivity(uid);
|
||||
}
|
||||
|
||||
async getAvailability(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
const pipedriveAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
export const getPipedriveAppKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("pipedrive-crm");
|
||||
return pipedriveAppKeysSchema.parse(appKeys);
|
||||
};
|
||||
@@ -221,9 +221,6 @@
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user