Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [CAL-3578] [CAL-2733] Zoho calendar issues #14905

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/app-store/_utils/updateAppServerLocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import prisma from "@calcom/prisma";

import { appKeysSchema as zohoKeysSchema } from "../zohocalendar/zod";

async function updateAppServerLocation(slug: string, serverUrl: string) {
const app = await prisma.app.findUnique({ where: { slug } });
const { client_id, client_secret } = zohoKeysSchema.parse(app?.keys) || {};
const updatedKeys = { client_id, client_secret, server_location: serverUrl };
await prisma.app.update({
vachmara marked this conversation as resolved.
Show resolved Hide resolved
where: { slug },
data: { keys: updatedKeys },
});
}

export default updateAppServerLocation;
24 changes: 18 additions & 6 deletions packages/app-store/zohocalendar/api/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@ import { Prisma } from "@calcom/prisma/client";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import updateAppServerLocation from "../../_utils/updateAppServerLocation";
import config from "../config.json";
import type { ZohoAuthCredentials } from "../types/ZohoCalendar";
import { appKeysSchema as zohoKeysSchema } from "../zod";

const log = logger.getSubLogger({ prefix: [`[[zohocalendar/api/callback]`] });

const OAUTH_BASE_URL = "https://accounts.zoho.com/oauth/v2";
function getOAuthBaseUrl(domain: string): string {
return `https://accounts.zoho.${domain}/oauth/v2`;
}

async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const { code, location } = req.query;

const state = decodeOAuthState(req);

if (code && typeof code !== "string") {
Expand All @@ -33,8 +37,12 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ message: "You must be logged in to do this" });
}

if (location && typeof location === "string") {
updateAppServerLocation(config.slug, location);
}

const appKeys = await getAppKeysFromSlug(config.slug);
const { client_id, client_secret } = zohoKeysSchema.parse(appKeys);
const { client_id, client_secret, server_location } = zohoKeysSchema.parse(appKeys);

const params = {
client_id,
Expand All @@ -46,14 +54,14 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {

const query = stringify(params);

const response = await fetch(`${OAUTH_BASE_URL}/token?${query}`, {
const response = await fetch(`${getOAuthBaseUrl(server_location || "com")}/token?${query}`, {
vachmara marked this conversation as resolved.
Show resolved Hide resolved
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});

const responseBody = await response.json();
const responseBody = await JSON.parse(await response.text());

if (!response.ok || responseBody.error) {
log.error("get access_token failed", responseBody);
Expand All @@ -66,7 +74,11 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
expires_in: Math.round(+new Date() / 1000 + responseBody.expires_in),
};

const calendarResponse = await fetch("https://calendar.zoho.com/api/v1/calendars", {
function getCalenderUri(domain: string): string {
return `https://calendar.zoho.${domain}/api/v1/calendars`;
}

const calendarResponse = await fetch(getCalenderUri(server_location || "com"), {
method: "GET",
headers: {
Authorization: `Bearer ${key.access_token}`,
Expand Down
90 changes: 77 additions & 13 deletions packages/app-store/zohocalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { stringify } from "querystring";
import { z } from "zod";

import dayjs from "@calcom/dayjs";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
Expand All @@ -16,11 +15,7 @@

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import type { ZohoAuthCredentials, FreeBusy, ZohoCalendarListResp } from "../types/ZohoCalendar";

const zohoKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
import { appKeysSchema as zohoKeysSchema } from "../zod";

export default class ZohoCalendarService implements Calendar {
private integrationName = "";
Expand All @@ -41,7 +36,7 @@
const refreshAccessToken = async () => {
try {
const appKeys = await getAppKeysFromSlug("zohocalendar");
const { client_id, client_secret } = zohoKeysSchema.parse(appKeys);
const { client_id, client_secret, server_location } = zohoKeysSchema.parse(appKeys);

const params = {
client_id,
Expand All @@ -52,7 +47,7 @@

const query = stringify(params);

const res = await fetch(`https://accounts.zoho.com/oauth/v2/token?${query}`, {
const res = await fetch(`https://accounts.zoho.${server_location}/oauth/v2/token?${query}`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
Expand All @@ -61,6 +56,11 @@

const token = await res.json();

// Revert if access_token is not present
if (!token.access_token) {
throw new Error("Invalid token response");
}

const key: ZohoAuthCredentials = {
access_token: token.access_token,
refresh_token: zohoCredentials.refresh_token,
Expand All @@ -87,8 +87,9 @@

private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
const credentials = await this.auth.getToken();

return fetch(`https://calendar.zoho.com/api/v1${endpoint}`, {
const appKeys = await getAppKeysFromSlug("zohocalendar");
const { server_location } = zohoKeysSchema.parse(appKeys);
return fetch(`https://calendar.zoho.${server_location}/api/v1${endpoint}`, {
method: "GET",
...init,
headers: {
Expand All @@ -101,8 +102,9 @@

private getUserInfo = async () => {
const credentials = await this.auth.getToken();

const response = await fetch(`https://accounts.zoho.com/oauth/user/info`, {
const appKeys = await getAppKeysFromSlug("zohocalendar");
const { server_location } = zohoKeysSchema.parse(appKeys);
const response = await fetch(`https://accounts.zoho.${server_location}/oauth/user/info`, {
method: "GET",
headers: {
Authorization: `Bearer ${credentials.access_token}`,
Expand Down Expand Up @@ -263,6 +265,37 @@
);
}

private async getUnavailability(
vachmara marked this conversation as resolved.
Show resolved Hide resolved
range: { start: string; end: string },
calendarId: string
): Promise<Array<{ start: string; end: string }>> {
const query = stringify({
range: JSON.stringify(range),
});
this.log.debug("getUnavailability query", query);
try {
// List all events within the range
const response = await this.fetcher(`/calendars/${calendarId}/events?${query}`);
const data = await this.handleData(response, this.log);

// Check for no data scenario
if (!data.events || data.events.length === 0) return [];

return (
data.events
.filter((event: any) => event.isprivate === false)

Check warning on line 286 in packages/app-store/zohocalendar/lib/CalendarService.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app-store/zohocalendar/lib/CalendarService.ts#L286

[@typescript-eslint/no-explicit-any] Unexpected any. Specify a different type.
vachmara marked this conversation as resolved.
Show resolved Hide resolved
.map((event: any) => {

Check warning on line 287 in packages/app-store/zohocalendar/lib/CalendarService.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app-store/zohocalendar/lib/CalendarService.ts#L287

[@typescript-eslint/no-explicit-any] Unexpected any. Specify a different type.
const start = dayjs(event.dateandtime.start, "YYYYMMDD[T]HHmmssZ").utc().toISOString();
const end = dayjs(event.dateandtime.end, "YYYYMMDD[T]HHmmssZ").utc().toISOString();
return { start, end };
}) || []
);
} catch (error) {
this.log.error(error);
return [];
}
}

async getAvailability(
dateFrom: string,
dateTo: string,
Expand Down Expand Up @@ -299,7 +332,22 @@
originalEndDate.format("YYYYMMDD[T]HHmmss[Z]"),
userInfo.Email
);
return busyData;

const unavailabilityData = await Promise.all(
queryIds.map((calendarId) =>
this.getUnavailability(
{
start: originalStartDate.format("YYYYMMDD[T]HHmmss[Z]"),
end: originalEndDate.format("YYYYMMDD[T]HHmmss[Z]"),
},
calendarId
)
)
);

const unavailability = unavailabilityData.flat();

return busyData.concat(unavailability);
} else {
// Zoho only supports 31 days of freebusy data
const busyData = [];
Expand All @@ -320,6 +368,22 @@
))
);

const unavailabilityData = await Promise.all(
queryIds.map((calendarId) =>
this.getUnavailability(
{
start: startDate.format("YYYYMMDD[T]HHmmss[Z]"),
end: endDate.format("YYYYMMDD[T]HHmmss[Z]"),
},
calendarId
)
)
);

const unavailability = unavailabilityData.flat();

busyData.push(...unavailability);

startDate = endDate.add(1, "minutes");
endDate = startDate.add(30, "days");
}
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/zohocalendar/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
server_location: z.string().optional(),
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type SaveKeysOptions = {
};

export const saveKeysHandler = async ({ ctx, input }: SaveKeysOptions) => {
console.log(ctx);
vachmara marked this conversation as resolved.
Show resolved Hide resolved
const keysSchema = appKeysSchemas[input.dirName as keyof typeof appKeysSchemas];
const keys = keysSchema.parse(input.keys);

Expand Down