feat: Add Zoho Bigin app to appstore (#8158)

* feat: add zoho bigin app to appstore

* Handle appKeys save and read correctly

* Fixes

* Remove env variables and seeding

* Create README for zoho-bigin and link to that in main README

* Remove imageSrc, thats deprecated

* Improved logs

* Remove stray beecepter fetch

* Add Zoho Bigin package.json description

* Load constants from config.json

* Refactor bigin auth tokens management

* Remove try catch

* fix refresh token not getting stored properly

* load api domain dynamically

* Fix reschedule not updating zoho bigin event

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
Shane Maglangit 2023-05-05 04:19:48 +08:00 committed by GitHub
parent 20f7a5841e
commit 05b42c001f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 506 additions and 2 deletions

View File

@ -16,11 +16,12 @@
# - WEB3
# - SALESFORCE
# - ZOHOCRM
# - ZOHO_BIGIN
# - APP STORE **********************************************************************************************
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
# - DAILY.CO VIDEO
# Enables Cal Video. to get your key
# Enables Cal Video. to get your key
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials

View File

@ -464,6 +464,8 @@ following
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.
### Obtaining Zoho Bigin Client ID and Secret
[Follow these steps](./packages/app-store/zoho-bigin/)
## Workflows
### Setting up SendGrid for Email reminders

View File

@ -25,6 +25,7 @@ import { appKeysSchema as event_type_app_card_zod_ts } from "./templates/event-t
import { appKeysSchema as vital_zod_ts } from "./vital/zod";
import { appKeysSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appKeysSchema as zapier_zod_ts } from "./zapier/zod";
import { appKeysSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
@ -52,6 +53,7 @@ export const appKeysSchemas = {
vital: vital_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
"zoho-bigin": zoho_bigin_zod_ts,
zohocrm: zohocrm_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};

View File

@ -60,6 +60,7 @@ import whereby_config_json from "./whereby/config.json";
import { metadata as wipemycalother__metadata_ts } from "./wipemycalother/_metadata";
import wordpress_config_json from "./wordpress/config.json";
import { metadata as zapier__metadata_ts } from "./zapier/_metadata";
import zoho_bigin_config_json from "./zoho-bigin/config.json";
import zohocrm_config_json from "./zohocrm/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
@ -122,6 +123,7 @@ export const appStoreMetadata = {
wipemycalother: wipemycalother__metadata_ts,
wordpress: wordpress_config_json,
zapier: zapier__metadata_ts,
"zoho-bigin": zoho_bigin_config_json,
zohocrm: zohocrm_config_json,
zoomvideo: zoomvideo__metadata_ts,
};

View File

@ -25,6 +25,7 @@ import { appDataSchema as event_type_app_card_zod_ts } from "./templates/event-t
import { appDataSchema as vital_zod_ts } from "./vital/zod";
import { appDataSchema as wordpress_zod_ts } from "./wordpress/zod";
import { appDataSchema as zapier_zod_ts } from "./zapier/zod";
import { appDataSchema as zoho_bigin_zod_ts } from "./zoho-bigin/zod";
import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
@ -52,6 +53,7 @@ export const appDataSchemas = {
vital: vital_zod_ts,
wordpress: wordpress_zod_ts,
zapier: zapier_zod_ts,
"zoho-bigin": zoho_bigin_zod_ts,
zohocrm: zohocrm_zod_ts,
zoomvideo: zoomvideo_zod_ts,
};

View File

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

View File

@ -15,6 +15,7 @@ const appStore = {
plausible: import("./plausible"),
salesforce: import("./salesforce"),
zohocrm: import("./zohocrm"),
"zoho-bigin": import("./zoho-bigin"),
sendgrid: import("./sendgrid"),
stripepayment: import("./stripepayment"),
tandemvideo: import("./tandemvideo"),

View File

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

View File

@ -0,0 +1,9 @@
### Obtaining Zoho Bigin 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. Click "ADD CLIENT" button top right and select "Server-based Applications".
3. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoho-bigin/callback` replacing Cal.com URL with the URI at which your application runs.
4. Go to tab "Client Secret" tab.
5. Now copy the Client ID and Client Secret to your .env.appStore file into the `ZOHO_BIGIN_CLIENT_ID` and `ZOHO_BIGIN_CLIENT_SECRET` fields.
6. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
7. You're good to go. Now you can easily add Zoho Bigin from the Cal.com app store.

View File

@ -0,0 +1,32 @@
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
const appKeys = await getAppKeysFromSlug(appConfig.slug);
const clientId = typeof appKeys.client_id === "string" ? appKeys.client_id : "";
if (!clientId) return res.status(400).json({ message: "Zoho Bigin client_id missing." });
const redirectUri = WEBAPP_URL + `/api/integrations/${appConfig.slug}/callback`;
const authUrl = axios.getUri({
url: "https://accounts.zoho.com/oauth/v2/auth",
params: {
scope: appConfig.scope,
client_id: clientId,
response_type: "code",
redirect_uri: redirectUri,
access_type: "offline",
},
});
res.status(200).json({ url: authUrl });
return;
}
res.status(400).json({ message: "Invalid request method." });
}

View File

@ -0,0 +1,69 @@
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";
import appConfig from "../config.json";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code, "accounts-server": accountsServer } = req.query;
if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
if (!req.session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const appKeys = await getAppKeysFromSlug(appConfig.slug);
const clientId = typeof appKeys.client_id === "string" ? appKeys.client_id : "";
const clientSecret = typeof appKeys.client_secret === "string" ? appKeys.client_secret : "";
if (!clientId) return res.status(400).json({ message: "Zoho Bigin client_id missing." });
if (!clientSecret) return res.status(400).json({ message: "Zoho Bigin client_secret missing." });
const accountsUrl = `${accountsServer}/oauth/v2/token`;
const redirectUri = WEBAPP_URL + `/api/integrations/${appConfig.slug}/callback`;
const formData = {
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: redirectUri,
grant_type: "authorization_code",
};
const tokenInfo = await axios.post(accountsUrl, qs.stringify(formData), {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
});
tokenInfo.data.expiryDate = Math.round(Date.now() + tokenInfo.data.expires_in);
tokenInfo.data.accountServer = accountsServer;
await prisma.credential.create({
data: {
type: appConfig.type,
key: tokenInfo.data,
userId: req.session.user.id,
appId: appConfig.slug,
},
});
const state = decodeOAuthState(req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })
);
}

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": "Zoho Bigin",
"slug": "zoho-bigin",
"type": "zoho-bigin_other_calendar",
"logo": "zohobigin.svg",
"url": "https://cal.com/apps/zoho-bigin",
"variant": "other",
"categories": ["other"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Bigin easily transforms your day-to-day customer processes into actionable pipelines. From qualifying leads to closing deals to managing important after-sales operations—Bigin connects your different teams to work together so that you can offer the best possible experience to your customers. Say goodbye to missing follow-ups, manual data entry, lack of team communication, and information silos.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"scope": "ZohoBigin.modules.events.ALL,ZohoBigin.modules.contacts.ALL"
}

View File

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

View File

@ -0,0 +1,312 @@
import axios from "axios";
import qs from "qs";
import { getLocation } from "@calcom/lib/CalEventParser";
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";
import { appKeysSchema } from "../zod";
export type BiginToken = {
scope: string;
api_domain: string;
expires_in: number;
expiryDate: number;
token_type: string;
access_token: string;
accountServer: string;
refresh_token: string;
};
export type BiginContact = {
email: string;
};
export default class BiginCalendarService implements Calendar {
private readonly integrationName = "zoho-bigin";
private readonly auth: { getToken: () => Promise<BiginToken> };
private log: typeof logger;
private eventsSlug = "/bigin/v1/Events";
private contactsSlug = "/bigin/v1/Contacts";
constructor(credential: CredentialPayload) {
this.auth = this.biginAuth(credential);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
/***
* Authenticate calendar service with Zoho Bigin provided credentials.
*/
private biginAuth(credential: CredentialPayload) {
const credentialKey = credential.key as unknown as BiginToken;
const credentialId = credential.id;
const isTokenValid = (token: BiginToken) =>
token.access_token && token.expiryDate && token.expiryDate > Date.now();
return {
getToken: () =>
isTokenValid(credentialKey)
? Promise.resolve(credentialKey)
: this.refreshAccessToken(credentialId, credentialKey),
};
}
/***
* Fetches a new access token if stored token is expired.
*/
private async refreshAccessToken(credentialId: number, credentialKey: BiginToken) {
this.log.debug("Refreshing token as it's invalid");
const grantType = "refresh_token";
const accountsUrl = `${credentialKey.accountServer}/oauth/v2/token`;
const appKeys = await getAppKeysFromSlug(this.integrationName);
const { client_id: clientId, client_secret: clientSecret } = appKeysSchema.parse(appKeys);
const formData = {
grant_type: grantType,
client_id: clientId,
client_secret: clientSecret,
refresh_token: credentialKey.refresh_token,
};
const tokenInfo = await axios.post(accountsUrl, qs.stringify(formData), {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
});
if (!tokenInfo.data.error) {
// set expiry date as offset from current time.
tokenInfo.data.expiryDate = Math.round(Date.now() + tokenInfo.data.expires_in);
tokenInfo.data.accountServer = credentialKey.accountServer;
tokenInfo.data.refresh_token = credentialKey.refresh_token;
await prisma.credential.update({
where: {
id: credentialId,
},
data: {
key: tokenInfo.data as BiginToken,
},
});
this.log.debug("Fetched token", tokenInfo.data.access_token);
} else {
this.log.error(tokenInfo.data);
}
return tokenInfo.data as BiginToken;
}
/***
* Creates Zoho Bigin Contact records for every attendee added in event bookings.
* Returns the results of all contact creation operations.
*/
private async createContacts(attendees: Person[]) {
const token = await this.auth.getToken();
const contacts = attendees.map((attendee) => {
const nameParts = attendee.name.split(" ");
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(" ") : "-";
return {
First_Name: firstName,
Last_Name: lastName,
Email: attendee.email,
};
});
return axios({
method: "post",
url: token.api_domain + this.contactsSlug,
headers: {
"content-type": "application/json",
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
data: JSON.stringify({ data: contacts }),
});
}
/***
* Finds existing Zoho Bigin Contact record based on email address. Returns a list of contacts objects that matched.
*/
private async contactSearch(event: CalendarEvent) {
const token = await this.auth.getToken();
const searchCriteria =
"(" + event.attendees.map((attendee) => `(Email:equals:${encodeURI(attendee.email)})`).join("or") + ")";
return await axios({
method: "get",
url: `${token.api_domain}${this.contactsSlug}/search?criteria=${searchCriteria}`,
headers: {
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
})
.then((data) => data.data)
.catch((e) => this.log.error("Error searching contact:", JSON.stringify(e), e.response?.data));
}
/***
* Sends request to Zoho Bigin API to add new Events.
*/
private async createBiginEvent(event: CalendarEvent) {
const token = await this.auth.getToken();
const biginEvent = {
Event_Title: event.title,
Start_DateTime: toISO8601String(new Date(event.startTime)),
End_DateTime: toISO8601String(new Date(event.endTime)),
Description: event.additionalNotes,
Location: getLocation(event),
};
return axios({
method: "post",
url: token.api_domain + this.eventsSlug,
headers: {
"content-type": "application/json",
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
data: JSON.stringify({ data: [biginEvent] }),
})
.then((data) => data.data)
.catch((e) => this.log.error("Error creating bigin event", JSON.stringify(e), e.response?.data));
}
/***
* Handles orchestrating the creation of new events in Zoho Bigin.
*/
async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) {
const meetingEvent = await this.createBiginEvent(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,
//FIXME: `externalCalendarId` is required by the `updateAllCalendarEvents` method, but is not used by zoho-bigin App. Not setting this property actually skips calling updateEvent..
// Here the value doesn't matter. We just need to set it to something.
externalCalendarId: "NO_CALENDAR_ID_NEEDED",
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 Zoho Bigin");
}
/***
* Creates contacts and event records for new bookings.
* Initially creates all new attendees as contacts, then creates the event.
*/
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
const contacts = (await this.contactSearch(event))?.data || [];
const existingContacts = contacts.map((contact: BiginContact) => contact.email);
const newContacts: Person[] = event.attendees.filter(
(attendee) => !existingContacts.includes(attendee.email)
);
if (newContacts.length === 0) {
return await this.handleEventCreation(event, event.attendees);
}
const createContacts = await this.createContacts(newContacts);
if (createContacts.data?.data[0].status === "success") {
return await this.handleEventCreation(event, event.attendees);
}
return Promise.reject({
calError: "Something went wrong when creating non-existing attendees in Zoho Bigin",
});
}
/***
* Updates an existing event in Zoho Bigin.
*/
async updateEvent(uid: string, event: CalendarEvent): Promise<NewCalendarEventType> {
this.log.debug(`Updating Event with uid ${uid}`);
const token = await this.auth.getToken();
const biginEvent = {
id: uid,
Event_Title: event.title,
Start_DateTime: toISO8601String(new Date(event.startTime)),
End_DateTime: toISO8601String(new Date(event.endTime)),
Description: event.additionalNotes,
Location: getLocation(event),
};
return axios
.put(token.api_domain + this.eventsSlug, JSON.stringify({ data: [biginEvent] }), {
headers: {
"content-type": "application/json",
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
})
.then((data) => data.data)
.catch((e) => {
this.log.error("Error in updating bigin event", JSON.stringify(e), e.response?.data);
});
}
async deleteEvent(uid: string): Promise<void> {
const token = await this.auth.getToken();
return axios
.delete(`${token.api_domain}${this.eventsSlug}?ids=${uid}`, {
headers: {
"content-type": "application/json",
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
})
.then((data) => data.data)
.catch((e) => this.log.error("Error deleting bigin event", JSON.stringify(e), e.response?.data));
}
async getAvailability(
_dateFrom: string,
_dateTo: string,
_selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return Promise.resolve([]);
}
async listCalendars(_event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}
}
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)
);
};

View File

@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/zoho-bigin",
"version": "1.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Bigin easily transforms your day-to-day customer processes into actionable pipelines. From qualifying leads to closing deals to managing important after-sales operations—Bigin connects your different teams to work together so that you can offer the best possible experience to your customers. Say goodbye to missing follow-ups, manual data entry, lack of team communication, and information silos."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="b" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" width="39.629" height="44" viewBox="0 0 39.629 44">
<defs>
<style>
.d {
fill: #039649;
}
</style>
</defs>
<g id="c" data-name="Layer 1">
<path class="d" d="m19.21,44c-.66,0-1.32-.22-1.87-.55-.77-.55-1.32-1.54-1.32-2.53v-9.02L.4,4.73C-.15,3.63-.15,2.42.51,1.54c.55-.99,1.65-1.54,2.75-1.54h28.6c1.099,0,2.089.55,2.639,1.54.66.991.66,2.31.11,3.301l-1.87,3.3h3.74c1.1,0,2.089.55,2.639,1.54.66.99.66,2.31.11,3.3l-10.89,18.81v6.82c0,1.43-.88,2.53-2.2,2.97l-5.94,2.31c-.22.11-.66.11-.99.11M3.26,3.08q-.11.11-.11.221l15.51,27.059c.44.77.44,1.21.44,1.65v8.799s.11.11.22,0l5.94-2.31h.22v-6.6c0-.44,0-1.21.441-1.76l10.78-18.7c0-.11,0-.22-.11-.22l-19.69-.11,5.28,9.13,3.74-6.49c.44-.77,1.32-.99,2.09-.55.77.44.99,1.32.549,2.09l-4.399,7.59c-.991,1.54-3.19,1.43-4.071.11l-6.38-11.33c-.44-.66-.44-1.54,0-2.31.441-.77,1.211-1.32,2.09-1.32h13.42l2.75-4.73s0-.11-.11-.221H3.26Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1021 B

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({});

View File

@ -280,7 +280,7 @@ export const createEvent = async (
if (error?.calError) {
calError = error.calError;
}
log.error("createEvent failed", error, calEvent);
log.error("createEvent failed", JSON.stringify(error), calEvent);
// @TODO: This code will be off till we can investigate an error with it
//https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");

View File

@ -5165,6 +5165,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/zoho-bigin@workspace:packages/app-store/zoho-bigin":
version: 0.0.0-use.local
resolution: "@calcom/zoho-bigin@workspace:packages/app-store/zoho-bigin"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/zohocrm@workspace:packages/app-store/zohocrm":
version: 0.0.0-use.local
resolution: "@calcom/zohocrm@workspace:packages/app-store/zohocrm"