first commit
This commit is contained in:
473
calcom/packages/app-store/zohocalendar/lib/CalendarService.ts
Normal file
473
calcom/packages/app-store/zohocalendar/lib/CalendarService.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
EventBusyDate,
|
||||
IntegrationCalendar,
|
||||
NewCalendarEventType,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import type { ZohoAuthCredentials, FreeBusy, ZohoCalendarListResp } from "../types/ZohoCalendar";
|
||||
import { appKeysSchema as zohoKeysSchema } from "../zod";
|
||||
|
||||
export default class ZohoCalendarService implements Calendar {
|
||||
private integrationName = "";
|
||||
private log: typeof logger;
|
||||
auth: { getToken: () => Promise<ZohoAuthCredentials> };
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
this.integrationName = "zoho_calendar";
|
||||
this.auth = this.zohoAuth(credential);
|
||||
this.log = logger.getSubLogger({
|
||||
prefix: [`[[lib] ${this.integrationName}`],
|
||||
});
|
||||
}
|
||||
|
||||
private zohoAuth = (credential: CredentialPayload) => {
|
||||
let zohoCredentials = credential.key as ZohoAuthCredentials;
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
const appKeys = await getAppKeysFromSlug("zohocalendar");
|
||||
const { client_id, client_secret } = zohoKeysSchema.parse(appKeys);
|
||||
const server_location = zohoCredentials.server_location;
|
||||
const params = {
|
||||
client_id,
|
||||
grant_type: "refresh_token",
|
||||
client_secret,
|
||||
refresh_token: zohoCredentials.refresh_token,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
const res = await fetch(`https://accounts.zoho.${server_location}/oauth/v2/token?${query}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
expires_in: Math.round(+new Date() / 1000 + token.expires_in),
|
||||
server_location,
|
||||
};
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { key },
|
||||
});
|
||||
zohoCredentials = key;
|
||||
} catch (err) {
|
||||
this.log.error("Error refreshing zoho token", err);
|
||||
}
|
||||
return zohoCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
getToken: async () => {
|
||||
const isExpired = () => new Date(zohoCredentials.expires_in * 1000).getTime() <= new Date().getTime();
|
||||
return !isExpired() ? Promise.resolve(zohoCredentials) : refreshAccessToken();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
const credentials = await this.auth.getToken();
|
||||
return fetch(`https://calendar.zoho.${credentials.server_location}/api/v1${endpoint}`, {
|
||||
method: "GET",
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private getUserInfo = async () => {
|
||||
const credentials = await this.auth.getToken();
|
||||
const response = await fetch(`https://accounts.zoho.${credentials.server_location}/oauth/user/info`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return this.handleData(response, this.log);
|
||||
};
|
||||
|
||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
let eventId = "";
|
||||
let eventRespData;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
throw new Error("no calendar id");
|
||||
}
|
||||
|
||||
try {
|
||||
const query = stringify({
|
||||
eventdata: JSON.stringify(this.translateEvent(event)),
|
||||
});
|
||||
|
||||
const eventResponse = await this.fetcher(`/calendars/${calendarId}/events?${query}`, {
|
||||
method: "POST",
|
||||
});
|
||||
eventRespData = await this.handleData(eventResponse, this.log);
|
||||
eventId = eventRespData.events[0].uid as string;
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...eventRespData.events[0],
|
||||
uid: eventRespData.events[0].uid as string,
|
||||
id: eventRespData.events[0].uid as string,
|
||||
type: "zoho_calendar",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
await this.deleteEvent(eventId, event, calendarId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uid
|
||||
* @param event
|
||||
* @returns
|
||||
*/
|
||||
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
||||
const eventId = uid;
|
||||
let eventRespData;
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
this.log.error("no calendar id provided in updateEvent");
|
||||
throw new Error("no calendar id provided in updateEvent");
|
||||
}
|
||||
try {
|
||||
// needed to fetch etag
|
||||
const existingEventResponse = await this.fetcher(`/calendars/${calendarId}/events/${uid}`);
|
||||
const existingEventData = await this.handleData(existingEventResponse, this.log);
|
||||
|
||||
const query = stringify({
|
||||
eventdata: JSON.stringify({
|
||||
...this.translateEvent(event),
|
||||
etag: existingEventData.events[0].etag,
|
||||
}),
|
||||
});
|
||||
|
||||
const eventResponse = await this.fetcher(`/calendars/${calendarId}/events/${uid}?${query}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
eventRespData = await this.handleData(eventResponse, this.log);
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...eventRespData.events[0],
|
||||
uid: eventRespData.events[0].uid as string,
|
||||
id: eventRespData.events[0].uid as string,
|
||||
type: "zoho_calendar",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
await this.deleteEvent(eventId, event);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uid
|
||||
* @param event
|
||||
* @returns
|
||||
*/
|
||||
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
|
||||
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
|
||||
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
|
||||
if (!calendarId) {
|
||||
this.log.error("no calendar id provided in deleteEvent");
|
||||
throw new Error("no calendar id provided in deleteEvent");
|
||||
}
|
||||
try {
|
||||
// needed to fetch etag
|
||||
const existingEventResponse = await this.fetcher(`/calendars/${calendarId}/events/${uid}`);
|
||||
const existingEventData = await this.handleData(existingEventResponse, this.log);
|
||||
|
||||
const response = await this.fetcher(`/calendars/${calendarId}/events/${uid}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
etag: existingEventData.events[0].etag,
|
||||
},
|
||||
});
|
||||
await this.handleData(response, this.log);
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getBusyData(dateFrom: string, dateTo: string, userEmail: string) {
|
||||
const query = stringify({
|
||||
sdate: dateFrom,
|
||||
edate: dateTo,
|
||||
ftype: "eventbased",
|
||||
uemail: userEmail,
|
||||
});
|
||||
|
||||
const response = await this.fetcher(`/calendars/freebusy?${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const data = await this.handleData(response, this.log);
|
||||
|
||||
if (data.fb_not_enabled || data.NODATA) return [];
|
||||
|
||||
return (
|
||||
data.freebusy
|
||||
.filter((freebusy: FreeBusy) => freebusy.fbtype === "busy")
|
||||
.map((freebusy: FreeBusy) => ({
|
||||
// using dayjs utc plugin because by default, dayjs parses and displays in local time, which causes a mismatch
|
||||
start: dayjs.utc(freebusy.startTime, "YYYYMMDD[T]HHmmss[Z]").toISOString(),
|
||||
end: dayjs.utc(freebusy.endTime, "YYYYMMDD[T]HHmmss[Z]").toISOString(),
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
private async getUnavailability(
|
||||
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)
|
||||
.map((event: any) => {
|
||||
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,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<EventBusyDate[]> {
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === this.integrationName)
|
||||
.map((e) => e.externalId);
|
||||
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
try {
|
||||
let queryIds = selectedCalendarIds;
|
||||
|
||||
if (queryIds.length === 0) {
|
||||
queryIds = (await this.listCalendars()).map((e) => e.externalId) || [];
|
||||
if (queryIds.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedCalendars[0]) return [];
|
||||
|
||||
const userInfo = await this.getUserInfo();
|
||||
const originalStartDate = dayjs(dateFrom);
|
||||
const originalEndDate = dayjs(dateTo);
|
||||
const diff = originalEndDate.diff(originalStartDate, "days");
|
||||
|
||||
if (diff <= 30) {
|
||||
const busyData = await this.getBusyData(
|
||||
originalStartDate.format("YYYYMMDD[T]HHmmss[Z]"),
|
||||
originalEndDate.format("YYYYMMDD[T]HHmmss[Z]"),
|
||||
userInfo.Email
|
||||
);
|
||||
|
||||
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 = [];
|
||||
|
||||
const loopsNumber = Math.ceil(diff / 30);
|
||||
|
||||
let startDate = originalStartDate;
|
||||
let endDate = originalStartDate.add(30, "days");
|
||||
|
||||
for (let i = 0; i < loopsNumber; i++) {
|
||||
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;
|
||||
|
||||
busyData.push(
|
||||
...(await this.getBusyData(
|
||||
startDate.format("YYYYMMDD[T]HHmmss[Z]"),
|
||||
endDate.format("YYYYMMDD[T]HHmmss[Z]"),
|
||||
userInfo.Email
|
||||
))
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return busyData;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
try {
|
||||
const resp = await this.fetcher(`/calendars`);
|
||||
const data = (await this.handleData(resp, this.log)) as ZohoCalendarListResp;
|
||||
const result = data.calendars
|
||||
.filter((cal) => {
|
||||
if (cal.privilege === "owner") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((cal) => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.uid ?? "No Id",
|
||||
integration: this.integrationName,
|
||||
name: cal.name || "No calendar name",
|
||||
primary: cal.isdefault,
|
||||
email: cal.uid ?? "",
|
||||
};
|
||||
return calendar;
|
||||
});
|
||||
|
||||
if (result.some((cal) => !!cal.primary)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// No primary calendar found, get primary calendar directly
|
||||
const respPrimary = await this.fetcher(`/calendars?category=own`);
|
||||
const dataPrimary = (await this.handleData(respPrimary, this.log)) as ZohoCalendarListResp;
|
||||
return dataPrimary.calendars.map((cal) => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.uid ?? "No Id",
|
||||
integration: this.integrationName,
|
||||
name: cal.name || "No calendar name",
|
||||
primary: cal.isdefault,
|
||||
email: cal.uid ?? "",
|
||||
};
|
||||
return calendar;
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error("There was an error contacting zoho calendar service: ", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleData(response: Response, log: typeof logger) {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
log.debug("zoho request with data", data);
|
||||
throw data;
|
||||
}
|
||||
log.debug("zoho request with data", data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private translateEvent = (event: CalendarEvent) => {
|
||||
const zohoEvent = {
|
||||
title: event.title,
|
||||
description: getRichDescription(event),
|
||||
dateandtime: {
|
||||
start: dayjs(event.startTime).format("YYYYMMDDTHHmmssZZ"),
|
||||
end: dayjs(event.endTime).format("YYYYMMDDTHHmmssZZ"),
|
||||
timezone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees.map((attendee) => ({ email: attendee.email })),
|
||||
isprivate: event.seatsShowAttendees,
|
||||
reminders: [
|
||||
{
|
||||
minutes: "-15",
|
||||
action: "popup",
|
||||
},
|
||||
],
|
||||
location: event.location ? getLocation(event) : undefined,
|
||||
};
|
||||
|
||||
return zohoEvent;
|
||||
};
|
||||
}
|
||||
1
calcom/packages/app-store/zohocalendar/lib/index.ts
Normal file
1
calcom/packages/app-store/zohocalendar/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CalendarService } from "./CalendarService";
|
||||
Reference in New Issue
Block a user