feat(app-store): add zohocrm app to app-store (#7182)

* Add zoho-app to the app-store

* Update packages/app-store/zohocrm/api/_getAdd.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* 1. Remove redundant check for `defaultHandler` 2. Restore yarn.lock`

* update images

* update README with zoho integration

* Fix dirname

* Fix types

* Fix lastname bug

* Fix timezone issue

* Fix eslint warning for unused args

* Revert yarn.lock

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
pull/7745/head
Jatin Sandilya 2023-03-15 13:20:03 +05:30 committed by GitHub
parent 19b1f8a05f
commit ec4228ab6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 501 additions and 1 deletions

View File

@ -15,6 +15,7 @@
# - LARK # - LARK
# - WEB3 # - WEB3
# - SALESFORCE # - SALESFORCE
# - ZOHOCRM
# - APP STORE ********************************************************************************************** # - APP STORE **********************************************************************************************
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ # ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
@ -109,4 +110,10 @@ INFURA_API_KEY=""
# Used for the Salesforce (Sales Cloud) app # Used for the Salesforce (Sales Cloud) app
SALESFORCE_CONSUMER_KEY="" SALESFORCE_CONSUMER_KEY=""
SALESFORCE_CONSUMER_SECRET="" SALESFORCE_CONSUMER_SECRET=""
# - ZOHOCRM
# Used for the Zoho CRM integration
ZOHOCRM_CLIENT_ID=""
ZOHOCRM_CLIENT_SECRET=""
# ********************************************************************************************************* # *********************************************************************************************************

View File

@ -451,6 +451,19 @@ following
9. Click the "Save" button at the bottom footer. 9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts. 10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
### Obtaining ZohoCRM Client ID and Secret
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
2. From within the API console page, go to "Applications".
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
4. Fill in any information you want in the "Client Details" tab
5. Go to tab "Client Secret" tab.
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
9. Click the "Save"/ "UPDATE" button at the bottom footer.
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
## Workflows ## Workflows
### Setting up SendGrid for Email reminders ### Setting up SendGrid for Email reminders

View File

@ -17,6 +17,7 @@ export const getCalendar = (credential: CredentialPayload | null): Calendar | nu
log.warn(`calendar of type ${calendarType} is not implemented`); log.warn(`calendar of type ${calendarType} is not implemented`);
return null; return null;
} }
log.info("calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService; const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential); return new CalendarService(credential);
}; };

View File

@ -56,6 +56,7 @@ import whereby_config_json from "./whereby/config.json";
import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata"; import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
import wordpress_config_json from "./wordpress/config.json"; import wordpress_config_json from "./wordpress/config.json";
import { metadata as zapier__metadata_ts } from "./zapier/_metadata"; import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata"; import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = { export const appStoreMetadata = {
@ -113,5 +114,6 @@ export const appStoreMetadata = {
wipemycalother: wipemycalother__metadata_ts, wipemycalother: wipemycalother__metadata_ts,
wordpress: wordpress_config_json, wordpress: wordpress_config_json,
zapier: zapier__metadata_ts, zapier: zapier__metadata_ts,
zohocrm: zohocrm_config_json,
zoomvideo: zoomvideo__metadata_ts, zoomvideo: zoomvideo__metadata_ts,
}; };

View File

@ -56,5 +56,6 @@ export const apiHandlers = {
wipemycalother: import("./wipemycalother/api"), wipemycalother: import("./wipemycalother/api"),
wordpress: import("./wordpress/api"), wordpress: import("./wordpress/api"),
zapier: import("./zapier/api"), zapier: import("./zapier/api"),
zohocrm: import("./zohocrm/api"),
zoomvideo: import("./zoomvideo/api"), zoomvideo: import("./zoomvideo/api"),
}; };

View File

@ -24,6 +24,7 @@ import * as tandemvideo from "./tandemvideo";
import * as vital from "./vital"; import * as vital from "./vital";
import * as wipemycalother from "./wipemycalother"; import * as wipemycalother from "./wipemycalother";
import * as zapier from "./zapier"; import * as zapier from "./zapier";
import * as zohocrm from "./zohocrm";
import * as zoomvideo from "./zoomvideo"; import * as zoomvideo from "./zoomvideo";
const appStore = { const appStore = {
@ -42,6 +43,7 @@ const appStore = {
office365video, office365video,
plausible, plausible,
salesforce, salesforce,
zohocrm,
sendgrid, sendgrid,
stripepayment, stripepayment,
tandemvideo, tandemvideo,

View File

@ -0,0 +1,6 @@
---
items:
- 1.png
---
{DESCRIPTION}

View File

@ -0,0 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
let client_id = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const appKeys = await getAppKeysFromSlug("zohocrm");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "zohocrm client id missing." });
const redirectUri = WEBAPP_URL + "/api/integrations/zohocrm/callback";
const url = `https://accounts.zoho.com/oauth/v2/auth?scope=ZohoCRM.modules.ALL,ZohoCRM.users.READ,AaaServer.profile.READ&client_id=${client_id}&response_type=code&access_type=offline&redirect_uri=${redirectUri}`;
res.status(200).json({ url });
}

View File

@ -0,0 +1,5 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getAdd"),
});

View File

@ -0,0 +1,67 @@
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
import qs from "qs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
let client_id = "";
let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
if (code === undefined && 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 appKeys = await getAppKeysFromSlug("zohocrm");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Zoho Crm consumer key missing." });
if (!client_secret) return res.status(400).json({ message: "Zoho Crm consumer secret missing." });
const url = `${req.query["accounts-server"]}/oauth/v2/token`;
const redirectUri = WEBAPP_URL + "/api/integrations/zohocrm/callback";
const formData = {
grant_type: "authorization_code",
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirectUri,
code: code,
};
const zohoCrmTokenInfo = await axios({
method: "post",
url: url,
data: qs.stringify(formData),
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
});
// set expiry date as offset from current time.
zohoCrmTokenInfo.data.expiryDate = Math.round(Date.now() + 60 * 60);
zohoCrmTokenInfo.data.accountServer = req.query["accounts-server"];
await prisma.credential.create({
data: {
type: "zohocrm_other_calendar",
key: zohoCrmTokenInfo.data as any,
userId: req.session.user.id,
appId: "zohocrm",
},
});
const state = decodeOAuthState(req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "zohocrm" })
);
}

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": "ZohoCRM",
"slug": "zohocrm",
"type": "zohocrm_other_calendar",
"imageSrc": "icon.png",
"logo": "icon.png",
"url": "https://cal.com/apps/zohocrm",
"variant": "other",
"categories": ["other"],
"publisher": "Cal.com ",
"email": "help@cal.com",
"description": "Zoho CRM is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic"
}

View File

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

View File

@ -0,0 +1,329 @@
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 {
Calendar,
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
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 = {
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 ZohoCrmCalendarService implements Calendar {
private integrationName = "";
private auth: Promise<{ getToken: () => Promise<void> }>;
private log: typeof logger;
private client_id = "";
private client_secret = "";
private accessToken = "";
constructor(credential: CredentialPayload) {
this.integrationName = "zohocrm_other_calendar";
this.auth = this.zohoCrmAuth(credential).then((r) => r);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private createContacts = async (attendees: Person[]) => {
const contacts = attendees.map((attendee) => {
const [firstname, lastname] = !!attendee.name ? attendee.name.split(" ") : [attendee.email, "-"];
return {
First_Name: firstname,
Last_Name: lastname || "-",
Email: attendee.email,
};
});
return 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 }),
});
};
private contactSearch = async (event: CalendarEvent) => {
const searchCriteria =
"(" + event.attendees.map((attendee) => `(Email:equals:${encodeURI(attendee.email)})`).join("or") + ")";
return 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));
};
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 createZohoEvent = async (event: CalendarEvent) => {
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),
};
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 axios({
method: "post",
url: url,
data: qs.stringify(formData),
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
});
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: CalendarEvent["attendees"]) {
const meetingEvent = await this.createZohoEvent(event);
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): Promise<NewCalendarEventType> {
const auth = await this.auth;
await auth.getToken();
const contacts = await this.contactSearch(event);
if (contacts.data && contacts.data.length) {
if (contacts.data.length === event.attendees.length) {
// all contacts are in Zoho CRM already.
this.log.debug("contact:search:all", { event, contacts: contacts.data });
return await this.handleEventCreation(event, event.attendees);
} else {
// Some attendees don't exist in ZohoCRM
// Get the existing contacts' email to filter out
this.log.debug("contact:search:notAll", { event, contacts });
const existingContacts = contacts.data.map((contact: ZohoContact) => contact.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 ZohoCRM that were not present in the previous contact search
const createContacts = await this.createContacts(nonExistingContacts);
this.log.debug("contact:created", { createContacts });
// Continue with event creation and association only when all contacts are present in Zoho
if (createContacts.data?.data[0].status === "success") {
this.log.debug("contact:creation:ok");
return await this.handleEventCreation(event, nonExistingContacts.concat(contacts));
}
return Promise.reject({
calError: "Something went wrong when creating non-existing attendees in ZohoCRM",
});
}
} else {
this.log.debug("contact:search:none", { event, contacts });
const createContacts = await this.createContacts(event.attendees);
this.log.debug("contact:created", { createContacts });
if (createContacts.data?.data[0].status === "success") {
this.log.debug("contact:creation:ok");
return await this.handleEventCreation(event, event.attendees);
}
}
return Promise.reject({
calError: "Something went wrong when searching/creating the attendees in ZohoCRM",
});
}
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
const auth = await this.auth;
await auth.getToken();
return await this.updateMeeting(uid, event);
}
async deleteEvent(uid: string): Promise<void> {
const auth = await this.auth;
await auth.getToken();
return 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 @@
{
"private": true,
"name": "@calcom/zohocrm",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Zoho CRM is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -203,6 +203,12 @@
"categories": ["video"], "categories": ["video"],
"slug": "facetime", "slug": "facetime",
"type": "facetime_video", "type": "facetime_video",
"isTemplate": false},
{
"dirName": "zohocrm",
"categories": ["other"],
"slug": "zohocrm",
"type": "zohocrm_other_calendar",
"isTemplate": false "isTemplate": false
}, },
{ {

View File

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import dotEnv from "dotenv"; import dotEnv from "dotenv";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -245,6 +245,12 @@ export default async function main() {
consumer_secret: process.env.SALESFORCE_CONSUMER_SECRET, consumer_secret: process.env.SALESFORCE_CONSUMER_SECRET,
}); });
} }
if (process.env.ZOHOCRM_CLIENT_ID && process.env.ZOHOCRM_CLIENT_SECRET) {
await createApp("zohocrm", "zohocrm", ["other"], "zohocrm_other_calendar", {
client_id: process.env.ZOHOCRM_CLIENT_ID,
client_secret: process.env.ZOHOCRM_CLIENT_SECRET,
});
}
await createApp("wipe-my-cal", "wipemycalother", ["other"], "wipemycal_other"); await createApp("wipe-my-cal", "wipemycalother", ["other"], "wipemycal_other");
if (process.env.GIPHY_API_KEY) { if (process.env.GIPHY_API_KEY) {
await createApp("giphy", "giphy", ["other"], "giphy_other", { await createApp("giphy", "giphy", ["other"], "giphy_other", {

View File

@ -246,6 +246,8 @@
"$RAILWAY_STATIC_URL", "$RAILWAY_STATIC_URL",
"$SALESFORCE_CONSUMER_KEY", "$SALESFORCE_CONSUMER_KEY",
"$SALESFORCE_CONSUMER_SECRET", "$SALESFORCE_CONSUMER_SECRET",
"$ZOHOCRM_CLIENT_ID",
"$ZOHOCRM_CLIENT_SECRET",
"$SAML_ADMINS", "$SAML_ADMINS",
"$SAML_DATABASE_URL", "$SAML_DATABASE_URL",
"$SAML_CLIENT_SECRET_VERIFIER", "$SAML_CLIENT_SECRET_VERIFIER",