From 1960046994a1184fcf045a48414843a248ce5d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Mon, 13 Jun 2022 16:12:27 -0600 Subject: [PATCH] App/exchange v2 (#2493) * 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 c0320e3f3d320091ee9a049e3abaf285169ed555. * Fixes Co-authored-by: Joe Au-Yeung Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Leo Giovanetti --- apps/web/pages/apps/[slug]/setup.tsx | 6 +- apps/web/public/static/locales/en/common.json | 4 +- .../_pages/setup/_getStaticProps.tsx | 6 - packages/app-store/_pages/setup/index.tsx | 2 + .../exchange2013calendar/_metadata.ts | 26 ++ .../app-store/exchange2013calendar/api/add.ts | 63 +++++ .../exchange2013calendar/api/index.ts | 1 + .../components/InstallAppButton.tsx | 18 ++ .../exchange2013calendar/components/index.ts | 1 + .../app-store/exchange2013calendar/index.ts | 3 + .../lib/CalendarService.ts | 218 +++++++++++++++++ .../exchange2013calendar/lib/index.ts | 1 + .../exchange2013calendar/package.json | 18 ++ .../pages/setup/index.tsx | 97 ++++++++ .../exchange2013calendar/static/icon.svg | 1 + .../exchange2016calendar/_metadata.ts | 26 ++ .../app-store/exchange2016calendar/api/add.ts | 63 +++++ .../exchange2016calendar/api/index.ts | 1 + .../components/InstallAppButton.tsx | 16 ++ .../exchange2016calendar/components/index.ts | 1 + .../app-store/exchange2016calendar/index.ts | 3 + .../lib/CalendarService.ts | 230 ++++++++++++++++++ .../exchange2016calendar/lib/index.ts | 1 + .../exchange2016calendar/package.json | 18 ++ .../pages/setup/index.tsx | 97 ++++++++ .../exchange2016calendar/static/icon.svg | 1 + packages/app-store/index.ts | 4 + packages/lib/server/defaultResponder.ts | 2 +- packages/prisma/seed-app-store.config.json | 2 +- packages/prisma/seed-app-store.ts | 2 + yarn.lock | 57 ++++- 31 files changed, 975 insertions(+), 14 deletions(-) create mode 100644 packages/app-store/exchange2013calendar/_metadata.ts create mode 100644 packages/app-store/exchange2013calendar/api/add.ts create mode 100644 packages/app-store/exchange2013calendar/api/index.ts create mode 100644 packages/app-store/exchange2013calendar/components/InstallAppButton.tsx create mode 100644 packages/app-store/exchange2013calendar/components/index.ts create mode 100644 packages/app-store/exchange2013calendar/index.ts create mode 100644 packages/app-store/exchange2013calendar/lib/CalendarService.ts create mode 100644 packages/app-store/exchange2013calendar/lib/index.ts create mode 100644 packages/app-store/exchange2013calendar/package.json create mode 100644 packages/app-store/exchange2013calendar/pages/setup/index.tsx create mode 100644 packages/app-store/exchange2013calendar/static/icon.svg create mode 100644 packages/app-store/exchange2016calendar/_metadata.ts create mode 100644 packages/app-store/exchange2016calendar/api/add.ts create mode 100644 packages/app-store/exchange2016calendar/api/index.ts create mode 100644 packages/app-store/exchange2016calendar/components/InstallAppButton.tsx create mode 100644 packages/app-store/exchange2016calendar/components/index.ts create mode 100644 packages/app-store/exchange2016calendar/index.ts create mode 100644 packages/app-store/exchange2016calendar/lib/CalendarService.ts create mode 100644 packages/app-store/exchange2016calendar/lib/index.ts create mode 100644 packages/app-store/exchange2016calendar/package.json create mode 100644 packages/app-store/exchange2016calendar/pages/setup/index.tsx create mode 100644 packages/app-store/exchange2016calendar/static/icon.svg diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx index f621802ee6..7a8f768afd 100644 --- a/apps/web/pages/apps/[slug]/setup.tsx +++ b/apps/web/pages/apps/[slug]/setup.tsx @@ -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"; @@ -34,7 +34,7 @@ export default function SetupInformation(props: InferGetStaticPropsType { 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 } })), diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1935ad8d03..abbc370366 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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" } diff --git a/packages/app-store/_pages/setup/_getStaticProps.tsx b/packages/app-store/_pages/setup/_getStaticProps.tsx index c16f1537bf..0e8b631199 100644 --- a/packages/app-store/_pages/setup/_getStaticProps.tsx +++ b/packages/app-store/_pages/setup/_getStaticProps.tsx @@ -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) => { diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index d5abf0dc12..edc69d4576 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -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")), }; diff --git a/packages/app-store/exchange2013calendar/_metadata.ts b/packages/app-store/exchange2013calendar/_metadata.ts new file mode 100644 index 0000000000..40d5eab689 --- /dev/null +++ b/packages/app-store/exchange2013calendar/_metadata.ts @@ -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; diff --git a/packages/app-store/exchange2013calendar/api/add.ts b/packages/app-store/exchange2013calendar/api/add.ts new file mode 100644 index 0000000000..dd296eaca8 --- /dev/null +++ b/packages/app-store/exchange2013calendar/api/add.ts @@ -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) }), +}); diff --git a/packages/app-store/exchange2013calendar/api/index.ts b/packages/app-store/exchange2013calendar/api/index.ts new file mode 100644 index 0000000000..4c0d2ead01 --- /dev/null +++ b/packages/app-store/exchange2013calendar/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/exchange2013calendar/components/InstallAppButton.tsx b/packages/app-store/exchange2013calendar/components/InstallAppButton.tsx new file mode 100644 index 0000000000..da7aacfbb7 --- /dev/null +++ b/packages/app-store/exchange2013calendar/components/InstallAppButton.tsx @@ -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, + })} + + ); +} diff --git a/packages/app-store/exchange2013calendar/components/index.ts b/packages/app-store/exchange2013calendar/components/index.ts new file mode 100644 index 0000000000..0d6008d4ca --- /dev/null +++ b/packages/app-store/exchange2013calendar/components/index.ts @@ -0,0 +1 @@ +export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/exchange2013calendar/index.ts b/packages/app-store/exchange2013calendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/exchange2013calendar/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/exchange2013calendar/lib/CalendarService.ts b/packages/app-store/exchange2013calendar/lib/CalendarService.ts new file mode 100644 index 0000000000..19a1554088 --- /dev/null +++ b/packages/app-store/exchange2013calendar/lib/CalendarService.ts @@ -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; + + 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; + } +} diff --git a/packages/app-store/exchange2013calendar/lib/index.ts b/packages/app-store/exchange2013calendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/exchange2013calendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/exchange2013calendar/package.json b/packages/app-store/exchange2013calendar/package.json new file mode 100644 index 0000000000..0d299adf34 --- /dev/null +++ b/packages/app-store/exchange2013calendar/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "@calcom/exchange2013calendar", + "version": "0.0.0", + "main": "./index.ts", + "description": "For calendars hosted on on-premises Microsoft Exchange 2013 servers", + "dependencies": { + "@calcom/core": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/ui": "*", + "react-hook-form": "^7.31.1", + "ews-javascript-api": "^0.11.0" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/exchange2013calendar/pages/setup/index.tsx b/packages/app-store/exchange2013calendar/pages/setup/index.tsx new file mode 100644 index 0000000000..4231fb93e2 --- /dev/null +++ b/packages/app-store/exchange2013calendar/pages/setup/index.tsx @@ -0,0 +1,97 @@ +import { useRouter } from "next/router"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert } from "@calcom/ui/Alert"; +import Button from "@calcom/ui/Button"; +import { Form, TextField } from "@calcom/ui/form/fields"; + +export default function Exchange2013CalendarSetup() { + const { t } = useLocale(); + const router = useRouter(); + const form = useForm({ + defaultValues: { + username: "", + password: "", + url: process.env.EXCHANGE_DEFAULT_EWS_URL || "", + }, + }); + + const [errorMessage, setErrorMessage] = useState(""); + + return ( +
+
+
+ Exchange 2013 Calendar +
+
+

{t("add_exchange2013")}

+
{t("credentials_stored_encrypted")}
+
+
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/exchange2013calendar/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || t("something_went_wrong")); + } else { + router.push(json.url); + } + }}> +
+ + + +
+ + {errorMessage && } +
+ + +
+ +
+
+
+ +
+ ); +} diff --git a/packages/app-store/exchange2013calendar/static/icon.svg b/packages/app-store/exchange2013calendar/static/icon.svg new file mode 100644 index 0000000000..82d06188fa --- /dev/null +++ b/packages/app-store/exchange2013calendar/static/icon.svg @@ -0,0 +1 @@ +Exchange_64x \ No newline at end of file diff --git a/packages/app-store/exchange2016calendar/_metadata.ts b/packages/app-store/exchange2016calendar/_metadata.ts new file mode 100644 index 0000000000..2235f2f415 --- /dev/null +++ b/packages/app-store/exchange2016calendar/_metadata.ts @@ -0,0 +1,26 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Microsoft Exchange 2016 Calendar", + description: _package.description, + installed: true, + type: "exchange2016_calendar", + title: "Microsoft Exchange 2016 Calendar", + imageSrc: "/api/app-store/exchange2016calendar/icon.svg", + variant: "calendar", + category: "calendar", + label: "Exchange Calendar", + logo: "/api/app-store/exchange2016calendar/icon.svg", + publisher: "Cal.com", + rating: 5, + reviews: 69, + slug: "exchange2016-calendar", + trending: false, + url: "https://cal.com/", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/exchange2016calendar/api/add.ts b/packages/app-store/exchange2016calendar/api/add.ts new file mode 100644 index 0000000000..1582c44213 --- /dev/null +++ b/packages/app-store/exchange2016calendar/api/add.ts @@ -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: "exchange2016_calendar", + key: symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY!), + userId: user.id, + appId: "exchange2016_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/exchange2016-calendar/setup" }; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); diff --git a/packages/app-store/exchange2016calendar/api/index.ts b/packages/app-store/exchange2016calendar/api/index.ts new file mode 100644 index 0000000000..4c0d2ead01 --- /dev/null +++ b/packages/app-store/exchange2016calendar/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/exchange2016calendar/components/InstallAppButton.tsx b/packages/app-store/exchange2016calendar/components/InstallAppButton.tsx new file mode 100644 index 0000000000..1f31c13b77 --- /dev/null +++ b/packages/app-store/exchange2016calendar/components/InstallAppButton.tsx @@ -0,0 +1,16 @@ +import useAddAppMutation from "../../_utils/useAddAppMutation"; +import { InstallAppButtonProps } from "../../types"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("exchange2016_calendar"); + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/exchange2016calendar/components/index.ts b/packages/app-store/exchange2016calendar/components/index.ts new file mode 100644 index 0000000000..0d6008d4ca --- /dev/null +++ b/packages/app-store/exchange2016calendar/components/index.ts @@ -0,0 +1 @@ +export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/exchange2016calendar/index.ts b/packages/app-store/exchange2016calendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/exchange2016calendar/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/exchange2016calendar/lib/CalendarService.ts b/packages/app-store/exchange2016calendar/lib/CalendarService.ts new file mode 100644 index 0000000000..93652a4358 --- /dev/null +++ b/packages/app-store/exchange2016calendar/lib/CalendarService.ts @@ -0,0 +1,230 @@ +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, + NewCalendarEventType, +} 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; + + constructor(credential: Credential) { + // this.integrationName = CALENDAR_INTEGRATIONS_TYPES.exchange; + this.integrationName = "exchange2016_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.Exchange2016; + } + + async createEvent(event: CalendarEvent): Promise { + 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): Promise { + 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): Promise { + 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[] + ): Promise { + 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(): Promise { + console.log("This triggers"); + 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; + } +} diff --git a/packages/app-store/exchange2016calendar/lib/index.ts b/packages/app-store/exchange2016calendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/exchange2016calendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/exchange2016calendar/package.json b/packages/app-store/exchange2016calendar/package.json new file mode 100644 index 0000000000..be3b18ee89 --- /dev/null +++ b/packages/app-store/exchange2016calendar/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "@calcom/exchange2016calendar", + "version": "0.0.0", + "main": "./index.ts", + "description": "For calendars hosted on on-premises Microsoft Exchange 2016 servers", + "dependencies": { + "@calcom/core": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/ui": "*", + "react-hook-form": "^7.31.1", + "ews-javascript-api": "^0.11.0" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/exchange2016calendar/pages/setup/index.tsx b/packages/app-store/exchange2016calendar/pages/setup/index.tsx new file mode 100644 index 0000000000..63955daa7f --- /dev/null +++ b/packages/app-store/exchange2016calendar/pages/setup/index.tsx @@ -0,0 +1,97 @@ +import { useRouter } from "next/router"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert } from "@calcom/ui/Alert"; +import Button from "@calcom/ui/Button"; +import { Form, TextField } from "@calcom/ui/form/fields"; + +export default function Exchange2016CalendarSetup() { + const { t } = useLocale(); + const router = useRouter(); + const form = useForm({ + defaultValues: { + username: "", + password: "", + url: process.env.EXCHANGE_DEFAULT_EWS_URL || "", + }, + }); + + const [errorMessage, setErrorMessage] = useState(""); + + return ( +
+
+
+ Exchange 2016 Calendar +
+
+

{t("add_exchange2016")}

+
{t("credentials_stored_encrypted")}
+
+
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/exchange2016calendar/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || t("something_went_wrong")); + } else { + router.push(json.url); + } + }}> +
+ + + +
+ + {errorMessage && } +
+ + +
+ +
+
+
+ +
+ ); +} diff --git a/packages/app-store/exchange2016calendar/static/icon.svg b/packages/app-store/exchange2016calendar/static/icon.svg new file mode 100644 index 0000000000..82d06188fa --- /dev/null +++ b/packages/app-store/exchange2016calendar/static/icon.svg @@ -0,0 +1 @@ +Exchange_64x \ No newline at end of file diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 24c94dd6db..230ffc3ae7 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -2,6 +2,8 @@ import * as applecalendar from "./applecalendar"; import * as caldavcalendar from "./caldavcalendar"; import * as dailyvideo from "./dailyvideo"; +import * as exchange2013calendar from "./exchange2013calendar"; +import * as exchange2016calendar from "./exchange2016calendar"; import * as giphy from "./giphy"; import * as googlecalendar from "./googlecalendar"; import * as googlevideo from "./googlevideo"; @@ -42,6 +44,8 @@ const appStore = { metamask, giphy, zapier, + exchange2013calendar, + exchange2016calendar, }; export default appStore; diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts index 80cb6c99c2..10a9f7bdca 100644 --- a/packages/lib/server/defaultResponder.ts +++ b/packages/lib/server/defaultResponder.ts @@ -14,7 +14,7 @@ function defaultResponder(f: Handle) { try { performance.mark("Start"); const result = await f(req, res); - res.json(result); + if (result) res.json(result); } catch (err) { const error = getServerErrorFromUnkown(err); res.statusCode = error.statusCode; diff --git a/packages/prisma/seed-app-store.config.json b/packages/prisma/seed-app-store.config.json index 0637a088a0..fe51488c70 100644 --- a/packages/prisma/seed-app-store.config.json +++ b/packages/prisma/seed-app-store.config.json @@ -1 +1 @@ -[] \ No newline at end of file +[] diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index d2dc16ed3e..a727922963 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -32,6 +32,8 @@ async function main() { // Calendar apps await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar"); await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar"); + await createApp("exchange2013-calendar", "exchange2013calendar", ["calendar"], "exchange2013_calendar"); + await createApp("exchange2016-calendar", "exchange2016calendar", ["calendar"], "exchange2016_calendar"); try { const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; await createApp("google-calendar", "googlecalendar", ["calendar"], "google_calendar", { diff --git a/yarn.lock b/yarn.lock index b4d3e72a02..5d9d545f89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3972,6 +3972,11 @@ resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d" integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A== +"@xmldom/xmldom@^0.8.1": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.2.tgz#b695ff674e8216efa632a3d36ad51ae9843380c0" + integrity sha512-+R0juSseERyoPvnBQ/cZih6bpF7IpCXlWbHRoCRzYzqpz6gWHOgf8o4MOEf6KBVuOyqU+gCNLkCWVIJAro8XyQ== + abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -4688,6 +4693,13 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +biskviit@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7" + integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w== + dependencies: + psl "^1.1.7" + bl@^1.0.0: version "1.2.3" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" @@ -6464,6 +6476,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encoding@0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha512-bl1LAgiQc4ZWr++pNYUdRe/alecaHFeHxIJ/pNciqGdKXghaTCOwKkbKp6ye7pKZGu/GcaSXFk8PBVhgs+dJdA== + dependencies: + iconv-lite "~0.4.13" + end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -7231,6 +7250,20 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" +ews-javascript-api@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/ews-javascript-api/-/ews-javascript-api-0.11.0.tgz#06d420040aedab6ba3e6e06448528ee8731c3e5e" + integrity sha512-qSPYhR0cVeBuYsQGPwVUlv80KrcWtqqR5Li7oms1eZ2+EXa7nKsDdeiM/dcUysRxnZOB0g/n7jFoIlldtK4zKw== + dependencies: + "@xmldom/xmldom" "^0.8.1" + base64-js "^1.5.1" + moment "^2.29.1" + moment-timezone "^0.5.34" + uuid "^8.3.2" + optionalDependencies: + bluebird "^3.7.2" + fetch "^1.1.0" + exec-sh@^0.3.2: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -7586,6 +7619,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e" + integrity sha512-5O8TwrGzoNblBG/jtK4NFuZwNCkZX6s5GfRNOaGtm+QGJEuNakSC/i2RW0R93KX6E0jVjNXm6O3CRN4Ql3K+yA== + dependencies: + biskviit "1.0.1" + encoding "0.1.12" + figures@^3.0.0, figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -8731,7 +8772,7 @@ iconv-lite@0.4.19: resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ== -iconv-lite@0.4.24, iconv-lite@^0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -11947,6 +11988,18 @@ module-alias@^2.2.2: resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.2.tgz#151cdcecc24e25739ff0aa6e51e1c5716974c0e0" integrity sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q== +moment-timezone@^0.5.34: + version "0.5.34" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" + integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.29.1: + version "2.29.3" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3" + integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw== + mongodb-connection-string-url@^2.3.2: version "2.5.2" resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.2.tgz#f075c8d529e8d3916386018b8a396aed4f16e5ed" @@ -13516,7 +13569,7 @@ pseudomap@^1.0.1, pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.28, psl@^1.1.33: +psl@^1.1.28, psl@^1.1.33, psl@^1.1.7: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==