2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

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,35 @@
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
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") {
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/zoho-bigin/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",
state: encodeOAuthState(req),
},
});
res.status(200).json({ url: authUrl });
return;
}
res.status(400).json({ message: "Invalid request method." });
}

View File

@ -0,0 +1,86 @@
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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";
function isAuthorizedAccountsServerUrl(accountsServer: string) {
// As per https://www.zoho.com/crm/developer/docs/api/v6/multi-dc.html#:~:text=US:%20https://accounts.zoho,https://accounts.zohocloud.ca&text=The%20%22location=us%22%20parameter,domain%20in%20all%20API%20endpoints.&text=You%20must%20make%20the%20authorization,.zoho.com.cn.
const authorizedAccountServers = [
"https://accounts.zoho.com",
"https://accounts.zoho.eu",
"https://accounts.zoho.in",
"https://accounts.zoho.com.cn",
"https://accounts.zoho.jp",
"https://accounts.zohocloud.ca",
"https://accounts.zoho.com.au",
];
return authorizedAccountServers.includes(accountsServer);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code, "accounts-server": accountsServer } = req.query;
const state = decodeOAuthState(req);
if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
if (!accountsServer || typeof accountsServer !== "string") {
res.status(400).json({ message: "`accounts-server` is required and must be a string" });
return;
}
if (!isAuthorizedAccountsServerUrl(accountsServer)) {
res.status(400).json({ message: "`accounts-server` is not authorized" });
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 createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, tokenInfo.data, 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,27 @@
import { usePathname } from "next/navigation";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const pathname = usePathname();
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
app={app}
teamId={eventType.team?.id || undefined}
switchOnClick={(e) => {
updateEnabled(e);
}}
switchChecked={enabled}
hideAppCardOptions
/>
);
};
export default EventTypeAppCard;

View File

@ -0,0 +1,19 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Zoho Bigin",
"slug": "zoho-bigin",
"type": "zoho-bigin_crm",
"logo": "zohobigin.svg",
"url": "https://github.com/ShaneMaglangit",
"variant": "crm",
"categories": ["crm"],
"extendsFeature": "EventType",
"publisher": "Shane Maglangit",
"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",
"isOAuth": true
}

View File

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

View File

@ -0,0 +1,305 @@
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 {
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Contact, ContactCreateInput, CRM } from "@calcom/types/CrmService";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
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 = {
id: string;
email: string;
};
export default class BiginCrmService implements CRM {
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.getSubLogger({ 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 refreshOAuthTokens(
async () =>
await axios.post(accountsUrl, qs.stringify(formData), {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
}),
"zoho-bigin",
credentialId
);
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.
*/
async createContacts(contactsToCreate: ContactCreateInput[]) {
const token = await this.auth.getToken();
const contacts = contactsToCreate.map((contact) => {
const nameParts = contact.name.split(" ");
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(" ") : "-";
return {
First_Name: firstName,
Last_Name: lastName,
Email: contact.email,
};
});
const response = await 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 }),
});
return response
? response.data.map((contact: BiginContact) => {
return {
id: contact.id,
email: contact.email,
};
})
: [];
}
/***
* Finds existing Zoho Bigin Contact record based on email address. Returns a list of contacts objects that matched.
*/
async getContacts(emails: string | string[]) {
const token = await this.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: `${token.api_domain}${this.contactsSlug}/search?criteria=${searchCriteria}`,
headers: {
authorization: `Zoho-oauthtoken ${token.access_token}`,
},
}).catch((e) => this.log.error("Error searching contact:", JSON.stringify(e), e.response?.data));
return response
? response.data.map((contact: BiginContact) => {
return {
id: contact.id,
email: contact.email,
};
})
: [];
}
/***
* 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: Contact[]) {
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, contacts: Contact[]): Promise<NewCalendarEventType> {
return await this.handleEventCreation(event, contacts);
}
/***
* 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 CrmService } from "./CrmService";

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,10 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = eventTypeAppCardZod;