diff --git a/.env.appStore.example b/.env.appStore.example index cec38afb9a..4d41267d73 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -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 diff --git a/packages/app-store/pipedrive-crm/api/add.ts b/packages/app-store/pipedrive-crm/api/add.ts index 8392cbc5b8..17b07a061b 100644 --- a/packages/app-store/pipedrive-crm/api/add.ts +++ b/packages/app-store/pipedrive-crm/api/add.ts @@ -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 }); } diff --git a/packages/app-store/pipedrive-crm/api/callback.ts b/packages/app-store/pipedrive-crm/api/callback.ts index 46e1433328..ebbbb64bab 100644 --- a/packages/app-store/pipedrive-crm/api/callback.ts +++ b/packages/app-store/pipedrive-crm/api/callback.ts @@ -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 }) diff --git a/packages/app-store/pipedrive-crm/config.json b/packages/app-store/pipedrive-crm/config.json index e4832b8b9e..baf7de7e09 100644 --- a/packages/app-store/pipedrive-crm/config.json +++ b/packages/app-store/pipedrive-crm/config.json @@ -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, diff --git a/packages/app-store/pipedrive-crm/lib/CrmService.ts b/packages/app-store/pipedrive-crm/lib/CrmService.ts index cc83f6e379..a95403bbb8 100644 --- a/packages/app-store/pipedrive-crm/lib/CrmService.ts +++ b/packages/app-store/pipedrive-crm/lib/CrmService.ts @@ -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(endpoint: string, options?: RequestInit): Promise { + // 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 { 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 { 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 `${event.organizer.language.translate("invitee_timezone")}: ${ + return `${event.organizer.language.translate("invitee_timezone")}: ${ event.attendees[0].timeZone - }

${event.organizer.language.translate("share_additional_notes")}
${ - 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 { - 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 { - await this.deleteMeeting(uid); + await this.deleteActivity(uid); } async getAvailability( diff --git a/packages/app-store/pipedrive-crm/lib/getPipedriveAppKeys.ts b/packages/app-store/pipedrive-crm/lib/getPipedriveAppKeys.ts new file mode 100644 index 0000000000..74d0c4219e --- /dev/null +++ b/packages/app-store/pipedrive-crm/lib/getPipedriveAppKeys.ts @@ -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); +}; diff --git a/turbo.json b/turbo.json index 0dd5bc68c3..762f9c8e43 100644 --- a/turbo.json +++ b/turbo.json @@ -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",