Skip to content

Commit

Permalink
App/exchange v2 (calcom#2493)
Browse files Browse the repository at this point in the history
* Create 2013 package

* Create 2016 package

* Add ews

* Update package.json

* Translate 2013 app to new structure

* Translate 2013 app to new structure

* Translate 2016 app to new structure

* Add appId

* Move setup to a seperate page

* RHF dependency version mismatch

* Move exchange 2016 setup to new page

* Add translations

* Relying on AppSetupMap not defined static pages

* Console build fixes

* Resolved node version to 16

* Prisma errors can't be handled on client

* Fixes node version mismatches

* Improvements

* Endpoint fixes

* Revert "Endpoint fixes"

This reverts commit c0320e3.

* Fixes

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
  • Loading branch information
5 people committed Jun 13, 2022
1 parent 3c9cf81 commit 1960046
Show file tree
Hide file tree
Showing 31 changed files with 975 additions and 14 deletions.
6 changes: 3 additions & 3 deletions apps/web/pages/apps/[slug]/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { InferGetStaticPropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";

import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { AppSetupPageMap, getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import { AppSetupPage, AppSetupMap } from "@calcom/app-store/_pages/setup";
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import prisma from "@calcom/prisma";
import Loader from "@calcom/ui/Loader";

Expand Down Expand Up @@ -34,7 +34,7 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g

export const getStaticPaths = async () => {
const appStore = await prisma.app.findMany({ select: { slug: true } });
const paths = appStore.filter((a) => a.slug in AppSetupPageMap).map((app) => app.slug);
const paths = appStore.filter((a) => a.slug in AppSetupMap).map((app) => app.slug);

return {
paths: paths.map((slug) => ({ params: { slug } })),
Expand Down
4 changes: 3 additions & 1 deletion apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -900,5 +900,7 @@
"yes_remove_app": "Yes, remove app",
"are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?",
"web_conference": "Web conference",
"requires_confirmation": "Requires confirmation"
"requires_confirmation": "Requires confirmation",
"add_exchange2013": "Connect Exchange 2013 Server",
"add_exchange2016": "Connect Exchange 2016 Server"
}
6 changes: 0 additions & 6 deletions packages/app-store/_pages/setup/_getStaticProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ import { GetStaticPropsContext } from "next";

export const AppSetupPageMap = {
zapier: import("../../zapier/pages/setup/_getStaticProps"),
"apple-calendar": {
getStaticProps: null,
},
"caldav-calendar": {
getStaticProps: null,
},
};

export const getStaticProps = async (ctx: GetStaticPropsContext) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/_pages/setup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { DynamicComponent } from "../../_components/DynamicComponent";

export const AppSetupMap = {
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
zapier: dynamic(() => import("../../zapier/pages/setup")),
};
Expand Down
26 changes: 26 additions & 0 deletions packages/app-store/exchange2013calendar/_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { App } from "@calcom/types/App";

import _package from "./package.json";

export const metadata = {
name: "Microsoft Exchange 2013 Calendar",
description: _package.description,
installed: true,
type: "exchange2013_calendar",
title: "Microsoft Exchange 2013 Calendar",
imageSrc: "/api/app-store/exchange2013calendar/icon.svg",
variant: "calendar",
category: "calendar",
label: "Exchange Calendar",
logo: "/api/app-store/exchange2013calendar/icon.svg",
publisher: "Cal.com",
rating: 5,
reviews: 69,
slug: "exchange2013-calendar",
trending: false,
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
} as App;

export default metadata;
63 changes: 63 additions & 0 deletions packages/app-store/exchange2013calendar/api/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";

import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";

import { CalendarService } from "../lib";

const bodySchema = z
.object({
username: z.string(),
password: z.string(),
url: z.string().url(),
})
.strict();

async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const body = bodySchema.parse(req.body);
// Get user
const user = await prisma.user.findFirst({
rejectOnNotFound: true,
where: {
id: req.session?.user?.id,
},
select: {
id: true,
},
});

const data = {
type: "exchange2013_calendar",
key: symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY!),
userId: user.id,
appId: "exchange2013_calendar",
};

try {
const dav = new CalendarService({
id: 0,
...data,
});
await dav?.listCalendars();
await prisma.credential.create({
data,
});
} catch (reason) {
logger.error("Could not add this exchange account", reason);
return res.status(500).json({ message: "Could not add this exchange account" });
}

return { url: "/apps/installed" };
}

async function getHandler() {
return { url: "/apps/exchange2013-calendar/setup" };
}

export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});
1 change: 1 addition & 0 deletions packages/app-store/exchange2013calendar/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as add } from "./add";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";

import useAddAppMutation from "../../_utils/useAddAppMutation";

export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("exchange2013_calendar");

return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";
3 changes: 3 additions & 0 deletions packages/app-store/exchange2013calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
218 changes: 218 additions & 0 deletions packages/app-store/exchange2013calendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Credential } from "@prisma/client";
import {
Appointment,
Attendee,
CalendarView,
ConflictResolutionMode,
DateTime,
DeleteMode,
ExchangeService,
ExchangeVersion,
FolderId,
FolderView,
ItemId,
LegacyFreeBusyStatus,
MessageBody,
PropertySet,
SendInvitationsMode,
SendInvitationsOrCancellationsMode,
Uri,
WebCredentials,
WellKnownFolderName,
} from "ews-javascript-api";

import { symmetricDecrypt } from "@calcom/lib/crypto";
// Probably don't need
// import { CALENDAR_INTEGRATIONS_TYPES } from "@calcom/lib/integrations/calendar/constants/generals";
import logger from "@calcom/lib/logger";
import { Calendar, CalendarEvent, EventBusyDate, IntegrationCalendar } from "@calcom/types/Calendar";

export default class ExchangeCalendarService implements Calendar {
private url = "";
private integrationName = "";
private log: typeof logger;
private readonly exchangeVersion: ExchangeVersion;
private credentials: Record<string, string>;

constructor(credential: Credential) {
this.integrationName = "exchange2013_calendar";

this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });

const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key?.toString() || "", process.env.CALENDSO_ENCRYPTION_KEY || "")
);
const username = decryptedCredential.username;
const url = decryptedCredential.url;
const password = decryptedCredential.password;

this.url = url;

this.credentials = {
username,
password,
};
this.exchangeVersion = ExchangeVersion.Exchange2013;
}

async createEvent(event: CalendarEvent) {
try {
const appointment = new Appointment(this.getExchangeService()); // service instance of ExchangeService
appointment.Subject = event.title;
appointment.Start = DateTime.Parse(event.startTime); // moment string
appointment.End = DateTime.Parse(event.endTime); // moment string
appointment.Location = event.location || "Location not defined!";
appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content

for (let i = 0; i < event.attendees.length; i++) {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
}

await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy);

return {
uid: appointment.Id.UniqueId,
id: appointment.Id.UniqueId,
password: "",
type: "",
url: "",
additionalInfo: [],
};
} catch (reason) {
this.log.error(reason);
throw reason;
}
}

async updateEvent(uid: string, event: CalendarEvent) {
try {
const appointment = await Appointment.Bind(
this.getExchangeService(),
new ItemId(uid),
new PropertySet()
);
appointment.Subject = event.title;
appointment.Start = DateTime.Parse(event.startTime); // moment string
appointment.End = DateTime.Parse(event.endTime); // moment string
appointment.Location = event.location || "Location not defined!";
appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content
for (let i = 0; i < event.attendees.length; i++) {
appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email));
}
appointment.Update(
ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy
);
} catch (reason) {
this.log.error(reason);
throw reason;
}
}

async deleteEvent(uid: string) {
try {
const appointment = await Appointment.Bind(
this.getExchangeService(),
new ItemId(uid),
new PropertySet()
);
// Delete the appointment. Note that the item ID will change when the item is moved to the Deleted Items folder.
appointment.Delete(DeleteMode.MoveToDeletedItems);
} catch (reason) {
this.log.error(reason);
throw reason;
}
}

async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) {
try {
const externalCalendars = await this.listCalendars();
const calendarsToGetAppointmentsFrom = [];
for (let i = 0; i < selectedCalendars.length; i++) {
//Only select vaild calendars! (We get all all active calendars on the instance! even from different users!)
for (let k = 0; k < externalCalendars.length; k++) {
if (selectedCalendars[i].externalId == externalCalendars[k].externalId) {
calendarsToGetAppointmentsFrom.push(selectedCalendars[i]);
}
}
}

const finaleRet = [];
for (let i = 0; i < calendarsToGetAppointmentsFrom.length; i++) {
const calendarFolderId = new FolderId(calendarsToGetAppointmentsFrom[i].externalId);
const localReturn = await this.getExchangeService()
.FindAppointments(
calendarFolderId,
new CalendarView(DateTime.Parse(dateFrom), DateTime.Parse(dateTo))
)
.then(function (params) {
const ret: EventBusyDate[] = [];

for (let k = 0; k < params.Items.length; k++) {
if (params.Items[k].LegacyFreeBusyStatus != LegacyFreeBusyStatus.Free) {
//Dont use this appointment if "ShowAs" was set to "free"
ret.push({
start: new Date(params.Items[k].Start.ToISOString()),
end: new Date(params.Items[k].End.ToISOString()),
});
}
}
return ret;
});
finaleRet.push(...localReturn);
}

return finaleRet;
} catch (reason) {
this.log.error(reason);
throw reason;
}
}

async listCalendars() {
try {
const allFolders: IntegrationCalendar[] = [];
return this.getExchangeService()
.FindFolders(WellKnownFolderName.MsgFolderRoot, new FolderView(1000))
.then(async (res) => {
for (const k in res.Folders) {
const f = res.Folders[k];
if (f.FolderClass == "IPF.Appointment") {
//Select parent folder for all calendars
allFolders.push({
externalId: f.Id.UniqueId,
name: f.DisplayName ?? "",
primary: true, //The first one is prime
integration: this.integrationName,
});
return await this.getExchangeService()
.FindFolders(f.Id, new FolderView(1000))
.then((res) => {
//Find all calendars inside calendar folder
res.Folders.forEach((fs) => {
allFolders.push({
externalId: fs.Id.UniqueId,
name: fs.DisplayName ?? "",
primary: false,
integration: this.integrationName,
});
});
return allFolders;
});
}
}
return allFolders;
});
} catch (reason) {
this.log.error(reason);
throw reason;
}
}

private getExchangeService(): ExchangeService {
const exch1 = new ExchangeService(this.exchangeVersion);
exch1.Credentials = new WebCredentials(this.credentials.username, this.credentials.password);
exch1.Url = new Uri(this.url);
return exch1;
}
}
1 change: 1 addition & 0 deletions packages/app-store/exchange2013calendar/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";

0 comments on commit 1960046

Please sign in to comment.