import axios from "axios"; import qs from "qs"; import { getLocation } from "@calcom/lib/CalEventParser"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { CRM, Contact, ContactCreateInput } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; export type ZohoToken = { scope: string; api_domain: string; expires_in: number; expiryDate: number; token_type: string; access_token: string; accountServer: string; refresh_token: string; }; export type ZohoContact = { id: string; email: string; }; /** * Converts to the date Format as required by zoho: 2020-08-02T15:30:00+05:30 * https://www.zoho.com/crm/developer/docs/api/v2/events-response.html */ const toISO8601String = (date: Date) => { const tzo = -date.getTimezoneOffset(), dif = tzo >= 0 ? "+" : "-", pad = function (num: number) { return (num < 10 ? "0" : "") + num; }; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( date.getHours() )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${dif}${pad(Math.floor(Math.abs(tzo) / 60))}:${pad( Math.abs(tzo) % 60 )}`; }; export default class ZohoCrmCrmService implements CRM { private integrationName = ""; private auth: Promise<{ getToken: () => Promise }>; private log: typeof logger; private client_id = ""; private client_secret = ""; private accessToken = ""; constructor(credential: CredentialPayload) { this.integrationName = "zohocrm_crm"; this.auth = this.zohoCrmAuth(credential).then((r) => r); this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } async createContacts(contactsToCreate: ContactCreateInput[]) { const auth = await this.auth; await auth.getToken(); const contacts = contactsToCreate.map((contactToCreate) => { const [firstname, lastname] = !!contactToCreate.name ? contactToCreate.name.split(" ") : [contactToCreate.email, "-"]; return { First_Name: firstname, Last_Name: lastname || "-", Email: contactToCreate.email, }; }); const response = await axios({ method: "post", url: `https://www.zohoapis.com/crm/v3/Contacts`, headers: { "content-type": "application/json", authorization: `Zoho-oauthtoken ${this.accessToken}`, }, data: JSON.stringify({ data: contacts }), }); const { data } = response; return data.data.map((contact: ZohoContact) => { return { id: contact.id, email: contact.email, }; }); } async getContacts(emails: string | string[]) { const auth = await this.auth; await auth.getToken(); const emailsArray = Array.isArray(emails) ? emails : [emails]; const searchCriteria = `(${emailsArray.map((email) => `(Email:equals:${encodeURI(email)})`).join("or")})`; const response = await axios({ method: "get", url: `https://www.zohoapis.com/crm/v3/Contacts/search?criteria=${searchCriteria}`, headers: { authorization: `Zoho-oauthtoken ${this.accessToken}`, }, }) .then((data) => data.data) .catch((e) => { this.log.error(e, e.response?.data); }); return response ? response.data.map((contact: ZohoContact) => { return { id: contact.id, email: contact.email, }; }) : []; } private getMeetingBody = (event: CalendarEvent): string => { return `${event.organizer.language.translate("invitee_timezone")}: ${ event.attendees[0].timeZone }

${event.organizer.language.translate("share_additional_notes")}
${ event.additionalNotes || "-" }`; }; private createZohoEvent = async (event: CalendarEvent, contacts: Contact[]) => { const zohoEvent = { Event_Title: event.title, Start_DateTime: toISO8601String(new Date(event.startTime)), End_DateTime: toISO8601String(new Date(event.endTime)), Description: this.getMeetingBody(event), Venue: getLocation(event), Who_Id: contacts[0].id, // Link the first attendee as the primary Who_Id }; return axios({ method: "post", url: `https://www.zohoapis.com/crm/v3/Events`, headers: { "content-type": "application/json", authorization: `Zoho-oauthtoken ${this.accessToken}`, }, data: JSON.stringify({ data: [zohoEvent] }), }) .then((data) => data.data) .catch((e) => this.log.error(e, e.response?.data)); }; private updateMeeting = async (uid: string, event: CalendarEvent) => { const zohoEvent = { id: uid, Event_Title: event.title, Start_DateTime: toISO8601String(new Date(event.startTime)), End_DateTime: toISO8601String(new Date(event.endTime)), Description: this.getMeetingBody(event), Venue: getLocation(event), }; return axios({ method: "put", url: `https://www.zohoapis.com/crm/v3/Events`, headers: { "content-type": "application/json", authorization: `Zoho-oauthtoken ${this.accessToken}`, }, data: JSON.stringify({ data: [zohoEvent] }), }) .then((data) => data.data) .catch((e) => this.log.error(e, e.response?.data)); }; private deleteMeeting = async (uid: string) => { return axios({ method: "delete", url: `https://www.zohoapis.com/crm/v3/Events?ids=${uid}`, headers: { "content-type": "application/json", authorization: `Zoho-oauthtoken ${this.accessToken}`, }, }) .then((data) => data.data) .catch((e) => this.log.error(e, e.response?.data)); }; private zohoCrmAuth = async (credential: CredentialPayload) => { const appKeys = await getAppKeysFromSlug("zohocrm"); if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id; if (typeof appKeys.client_secret === "string") this.client_secret = appKeys.client_secret; if (!this.client_id) throw new HttpError({ statusCode: 400, message: "Zoho CRM client_id missing." }); if (!this.client_secret) throw new HttpError({ statusCode: 400, message: "Zoho CRM client_secret missing." }); const credentialKey = credential.key as unknown as ZohoToken; const isTokenValid = (token: ZohoToken) => { const isValid = token && token.access_token && token.expiryDate && token.expiryDate > Date.now(); if (isValid) { this.accessToken = token.access_token; } return isValid; }; const refreshAccessToken = async (credentialKey: ZohoToken) => { try { const url = `${credentialKey.accountServer}/oauth/v2/token`; const formData = { grant_type: "refresh_token", client_id: this.client_id, client_secret: this.client_secret, refresh_token: credentialKey.refresh_token, }; const zohoCrmTokenInfo = await refreshOAuthTokens( async () => await axios({ method: "post", url: url, data: qs.stringify(formData), headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", }, }), "zohocrm", credential.userId ); if (!zohoCrmTokenInfo.data.error) { // set expiry date as offset from current time. zohoCrmTokenInfo.data.expiryDate = Math.round(Date.now() + 60 * 60); await prisma.credential.update({ where: { id: credential.id, }, data: { key: { ...(zohoCrmTokenInfo.data as ZohoToken), refresh_token: credentialKey.refresh_token, accountServer: credentialKey.accountServer, }, }, }); this.accessToken = zohoCrmTokenInfo.data.access_token; this.log.debug("Fetched token", this.accessToken); } else { this.log.error(zohoCrmTokenInfo.data); } } catch (e: unknown) { this.log.error(e); } }; return { getToken: () => (isTokenValid(credentialKey) ? Promise.resolve() : refreshAccessToken(credentialKey)), }; }; async handleEventCreation(event: CalendarEvent, contacts: Contact[]) { const meetingEvent = await this.createZohoEvent(event, contacts); if (meetingEvent.data && meetingEvent.data.length && meetingEvent.data[0].status === "success") { this.log.debug("event:creation:ok", { meetingEvent }); return Promise.resolve({ uid: meetingEvent.data[0].details.id, id: meetingEvent.data[0].details.id, type: this.integrationName, 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 ZohoCRM"); } async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { const auth = await this.auth; await auth.getToken(); return await this.handleEventCreation(event, contacts); } async updateEvent(uid: string, event: CalendarEvent): Promise { const auth = await this.auth; await auth.getToken(); return await this.updateMeeting(uid, event); } async deleteEvent(uid: string): Promise { const auth = await this.auth; await auth.getToken(); return await this.deleteMeeting(uid); } }