* 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>
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
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,
|
|
IntegrationCalendar,
|
|
NewCalendarEventType,
|
|
} from "@calcom/types/Calendar";
|
|
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 PipedriveContact = {
|
|
id: number;
|
|
name: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
email: Array<{ value: string; primary: boolean }>;
|
|
};
|
|
|
|
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 auth: OAuthManager;
|
|
private apiDomain: string;
|
|
|
|
constructor(credential: CredentialPayload) {
|
|
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 [firstName, lastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
|
|
|
|
const bodyData = {
|
|
name: attendee.name || attendee.email,
|
|
first_name: firstName,
|
|
last_name: lastName || "",
|
|
email: [{ value: attendee.email, primary: true }],
|
|
};
|
|
|
|
try {
|
|
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) {
|
|
this.log.error("Error creating contact:", error);
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
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 (email) => {
|
|
try {
|
|
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.flat();
|
|
}
|
|
|
|
private getMeetingBody = (event: CalendarEvent): string => {
|
|
return `${event.organizer.language.translate("invitee_timezone")}: ${
|
|
event.attendees[0].timeZone
|
|
}\n\n${event.organizer.language.translate("share_additional_notes")}\n${event.additionalNotes || "-"}`;
|
|
};
|
|
|
|
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,
|
|
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,
|
|
}),
|
|
person_id: parseInt(contacts[0].id),
|
|
};
|
|
|
|
return this.requestPipedrive<{ success: boolean; data: PipedriveActivity }>("activities", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(activityPayload),
|
|
});
|
|
};
|
|
|
|
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,
|
|
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,
|
|
}),
|
|
};
|
|
|
|
return this.requestPipedrive<{ success: boolean; data: PipedriveActivity }>(`activities/${uid}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(activityPayload),
|
|
});
|
|
};
|
|
|
|
private deleteActivity = async (uid: string) => {
|
|
return this.requestPipedrive<{ success: boolean }>(`activities/${uid}`, {
|
|
method: "DELETE",
|
|
});
|
|
};
|
|
|
|
async handleEventCreation(event: CalendarEvent, contacts: Contact[]) {
|
|
const meetingEvent = await this.createPipedriveActivity(event, contacts);
|
|
|
|
if (meetingEvent.success && meetingEvent.data) {
|
|
this.log.debug("event:creation:ok", { meetingEvent });
|
|
return Promise.resolve({
|
|
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");
|
|
}
|
|
|
|
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<NewCalendarEventType> {
|
|
return await this.handleEventCreation(event, contacts);
|
|
}
|
|
|
|
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
|
|
const meetingEvent = await this.updateActivity(uid, event);
|
|
|
|
if (meetingEvent.success && meetingEvent.data) {
|
|
this.log.debug("event:updation:ok", { meetingEvent });
|
|
return Promise.resolve({
|
|
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.deleteActivity(uid);
|
|
}
|
|
|
|
async getAvailability(
|
|
_dateFrom: string,
|
|
_dateTo: string,
|
|
_selectedCalendars: IntegrationCalendar[]
|
|
): Promise<EventBusyDate[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
getAppOptions() {
|
|
console.log("No options implemented");
|
|
}
|
|
|
|
async handleAttendeeNoShow() {
|
|
console.log("Not implemented");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function that creates a Pipedrive CRM service instance.
|
|
* This is exported instead of the class to prevent internal types
|
|
* from leaking into the emitted .d.ts file.
|
|
*/
|
|
export default function BuildCrmService(
|
|
credential: CredentialPayload,
|
|
_appOptions?: Record<string, unknown>
|
|
): CRM {
|
|
return new PipedriveCrmService(credential);
|
|
}
|