From bd2a795d7a20b3bbc6c5f9095b5807873ddcec60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edward=20Fern=C3=A1ndez?= <40343753+edanfesi@users.noreply.github.com> Date: Thu, 6 Jan 2022 12:28:31 -0500 Subject: [PATCH] [CAL-770] add new integration architecture revamp (#1369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CAL-770] add new integration architecture revamp * Type fixes * Type fixes * [CAL-770] Remove tsconfig.tsbuildinfo * [CAL-770] add integration test * Improve google calendar test integration * Remove console.log * Change response any to void in the deleteEvent method * Remove unnecesary const * Add tsconfig.tsbuildinfo to the .gitignore * Remove process env variables as const Co-authored-by: Edward Fernández Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars Co-authored-by: Edward Fernandez --- .gitignore | 2 + README.md | 2 +- .../integrations/ConnectIntegrations.tsx | 4 +- ee/lib/stripe/server.ts | 4 +- .../api/integrations/stripepayment/webhook.ts | 2 +- lib/CalEventParser.ts | 3 +- lib/calendarClient.ts | 172 ---------- lib/emails/email-manager.ts | 2 +- .../templates/attendee-scheduled-email.ts | 4 +- .../templates/organizer-scheduled-email.ts | 4 +- lib/events/EventManager.ts | 3 +- .../Apple/AppleCalendarAdapter.ts | 10 - .../CalDav/CalDavCalendarAdapter.ts | 10 - .../Daily/DailyVideoApiAdapter.ts | 3 +- .../GoogleCalendarApiAdapter.ts | 290 ---------------- .../Office365CalendarApiAdapter.ts | 220 ------------ lib/integrations/Zoom/ZoomVideoApiAdapter.ts | 3 +- lib/integrations/calendar/CalendarManager.ts | 177 ++++++++++ .../components/AddAppleIntegration.tsx | 4 +- .../components/AddCalDavIntegration.tsx | 12 +- .../calendar/constants/formats.ts | 1 + .../calendar/constants/generals.ts | 10 + lib/integrations/calendar/constants/types.ts | 56 +++ .../calendar/interfaces/Calendar.ts | 78 +++++ .../calendar/interfaces/GoogleCalendar.ts | 5 + .../calendar/interfaces/Office365Calendar.ts | 10 + .../calendar/services/AppleCalendarService.ts | 10 + .../calendar/services/BaseCalendarService.ts} | 210 +++++------- .../services/CalDavCalendarService.ts | 10 + .../services/GoogleCalendarService.ts | 324 ++++++++++++++++++ .../services/Office365CalendarService.ts | 250 ++++++++++++++ .../calendar/utils/CalendarUtils.ts | 16 + lib/queries/availability/index.ts | 2 +- lib/videoClient.ts | 2 +- lib/webhooks/sendPayload.tsx | 2 +- pages/api/availability/[user].ts | 2 +- pages/api/availability/calendar.ts | 4 +- pages/api/book/confirm.ts | 2 +- pages/api/book/event.ts | 5 +- pages/api/cancel.ts | 9 +- pages/api/cron/bookingReminder.ts | 2 +- pages/api/integrations/apple/add.ts | 6 +- pages/api/integrations/caldav/add.ts | 6 +- pages/getting-started.tsx | 4 +- playwright/lib/globalSetup.ts | 3 + server/integrations/getCalendarCredentials.ts | 25 -- server/integrations/getConnectedCalendars.ts | 50 --- server/routers/viewer.tsx | 8 +- 48 files changed, 1098 insertions(+), 945 deletions(-) delete mode 100644 lib/calendarClient.ts delete mode 100644 lib/integrations/Apple/AppleCalendarAdapter.ts delete mode 100644 lib/integrations/CalDav/CalDavCalendarAdapter.ts delete mode 100644 lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts delete mode 100644 lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts create mode 100644 lib/integrations/calendar/CalendarManager.ts rename lib/integrations/{Apple => calendar}/components/AddAppleIntegration.tsx (97%) rename lib/integrations/{CalDav => calendar}/components/AddCalDavIntegration.tsx (89%) create mode 100644 lib/integrations/calendar/constants/formats.ts create mode 100644 lib/integrations/calendar/constants/generals.ts create mode 100644 lib/integrations/calendar/constants/types.ts create mode 100644 lib/integrations/calendar/interfaces/Calendar.ts create mode 100644 lib/integrations/calendar/interfaces/GoogleCalendar.ts create mode 100644 lib/integrations/calendar/interfaces/Office365Calendar.ts create mode 100644 lib/integrations/calendar/services/AppleCalendarService.ts rename lib/{BaseCalendarApiAdapter.ts => integrations/calendar/services/BaseCalendarService.ts} (60%) create mode 100644 lib/integrations/calendar/services/CalDavCalendarService.ts create mode 100644 lib/integrations/calendar/services/GoogleCalendarService.ts create mode 100644 lib/integrations/calendar/services/Office365CalendarService.ts create mode 100644 lib/integrations/calendar/utils/CalendarUtils.ts delete mode 100644 server/integrations/getCalendarCredentials.ts delete mode 100644 server/integrations/getConnectedCalendars.ts diff --git a/.gitignore b/.gitignore index 055a53d1e8..fbbd07c1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ yarn-error.log* # Local History for Visual Studio Code .history/ +# Typescript +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index 56caa815a0..4338d172ce 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Contributions are what make the open source community such an amazing place to b 3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)** 4. Set the **Web** redirect URI to `/api/integrations/office365calendar/callback` replacing Cal.com URL with the URI at which your application runs. 5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env -6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte +6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute ## Obtaining Zoom Client ID and Secret diff --git a/components/integrations/ConnectIntegrations.tsx b/components/integrations/ConnectIntegrations.tsx index 8534963cf1..3a30c9a52f 100644 --- a/components/integrations/ConnectIntegrations.tsx +++ b/components/integrations/ConnectIntegrations.tsx @@ -2,8 +2,8 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types import { useState } from "react"; import { useMutation } from "react-query"; -import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration"; -import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; +import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration"; +import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration"; import { ButtonBaseProps } from "@components/ui/Button"; diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts index e39b9dd285..eb762be1de 100644 --- a/ee/lib/stripe/server.ts +++ b/ee/lib/stripe/server.ts @@ -2,9 +2,9 @@ import { PaymentType, Prisma } from "@prisma/client"; import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; -import { CalendarEvent } from "@lib/calendarClient"; import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; import { getErrorFromUnknown } from "@lib/errors"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import prisma from "@lib/prisma"; import { createPaymentLink } from "./client"; @@ -77,7 +77,7 @@ export async function handlePayment( data: Object.assign({}, paymentIntent, { stripe_publishable_key, stripeAccount: stripe_user_id, - }) as PaymentData as unknown as Prisma.JsonValue, + }) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue, externalId: paymentIntent.id, }, }); diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index c5f9162d1a..2cb67b2255 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -4,11 +4,11 @@ import Stripe from "stripe"; import stripe from "@ee/lib/stripe/server"; -import { CalendarEvent } from "@lib/calendarClient"; import { IS_PRODUCTION } from "@lib/config/constants"; import { HttpError as HttpCode } from "@lib/core/http/error"; import { getErrorFromUnknown } from "@lib/errors"; import EventManager from "@lib/events/EventManager"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import prisma from "@lib/prisma"; import { Ensure } from "@lib/types/utils"; diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index b32aae6876..c525502d4d 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -1,10 +1,11 @@ +import { Person } from "ics"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import { getIntegrationName } from "@lib/integrations"; -import { CalendarEvent, Person } from "./calendarClient"; import { BASE_URL } from "./config/constants"; +import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar"; const translator = short(); diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts deleted file mode 100644 index 89914a007c..0000000000 --- a/lib/calendarClient.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Credential, DestinationCalendar, SelectedCalendar } from "@prisma/client"; -import { TFunction } from "next-i18next"; - -import { PaymentInfo } from "@ee/lib/stripe/server"; - -import { getUid } from "@lib/CalEventParser"; -import { Event, EventResult } from "@lib/events/EventManager"; -import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter"; -import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter"; -import { - ConferenceData, - GoogleCalendarApiAdapter, -} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter"; -import { Office365CalendarApiAdapter } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; -import logger from "@lib/logger"; -import { VideoCallData } from "@lib/videoClient"; - -import notEmpty from "./notEmpty"; -import { Ensure } from "./types/utils"; - -const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] }); - -export type Person = { name: string; email: string; timeZone: string }; - -export interface EntryPoint { - entryPointType?: string; - uri?: string; - label?: string; - pin?: string; - accessCode?: string; - meetingCode?: string; - passcode?: string; - password?: string; -} - -export interface AdditionInformation { - conferenceData?: ConferenceData; - entryPoints?: EntryPoint[]; - hangoutLink?: string; -} - -export interface CalendarEvent { - type: string; - title: string; - startTime: string; - endTime: string; - description?: string | null; - team?: { - name: string; - members: string[]; - }; - location?: string | null; - organizer: Person; - attendees: Person[]; - conferenceData?: ConferenceData; - language: TFunction; - additionInformation?: AdditionInformation; - uid?: string | null; - videoCallData?: VideoCallData; - paymentInfo?: PaymentInfo | null; - destinationCalendar?: DestinationCalendar | null; -} - -export interface IntegrationCalendar extends Ensure, "externalId"> { - primary?: boolean; - name?: string; -} - -type EventBusyDate = Record<"start" | "end", Date | string>; - -export interface CalendarApiAdapter { - createEvent(event: CalendarEvent): Promise; - - updateEvent(uid: string, event: CalendarEvent): Promise; - - deleteEvent(uid: string): Promise; - - getAvailability( - dateFrom: string, - dateTo: string, - selectedCalendars: IntegrationCalendar[] - ): Promise; - - listCalendars(): Promise; -} - -function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null { - switch (credential.type) { - case "google_calendar": - return GoogleCalendarApiAdapter(credential); - case "office365_calendar": - return Office365CalendarApiAdapter(credential); - case "caldav_calendar": - return new CalDavCalendar(credential); - case "apple_calendar": - return new AppleCalendar(credential); - } - return null; -} - -const getBusyCalendarTimes = async ( - withCredentials: Credential[], - dateFrom: string, - dateTo: string, - selectedCalendars: SelectedCalendar[] -) => { - const adapters = withCredentials.map(getCalendarAdapterOrNull).filter(notEmpty); - const results = await Promise.all( - adapters.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) - ); - return results.reduce((acc, availability) => acc.concat(availability), []); -}; - -const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { - const uid: string = getUid(calEvent); - const adapter = getCalendarAdapterOrNull(credential); - let success = true; - - const creationResult = adapter - ? await adapter.createEvent(calEvent).catch((e) => { - log.error("createEvent failed", e, calEvent); - success = false; - return undefined; - }) - : undefined; - - return { - type: credential.type, - success, - uid, - createdEvent: creationResult, - originalEvent: calEvent, - }; -}; - -const updateEvent = async ( - credential: Credential, - calEvent: CalendarEvent, - bookingRefUid: string | null -): Promise => { - const uid = getUid(calEvent); - const adapter = getCalendarAdapterOrNull(credential); - let success = true; - - const updatedResult = - adapter && bookingRefUid - ? await adapter.updateEvent(bookingRefUid, calEvent).catch((e) => { - log.error("updateEvent failed", e, calEvent); - success = false; - return undefined; - }) - : undefined; - - return { - type: credential.type, - success, - uid, - updatedEvent: updatedResult, - originalEvent: calEvent, - }; -}; - -const deleteEvent = (credential: Credential, uid: string): Promise => { - const adapter = getCalendarAdapterOrNull(credential); - if (adapter) { - return adapter.deleteEvent(uid); - } - - return Promise.resolve({}); -}; - -export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, getCalendarAdapterOrNull }; diff --git a/lib/emails/email-manager.ts b/lib/emails/email-manager.ts index 185d83056a..f2fef7b6a0 100644 --- a/lib/emails/email-manager.ts +++ b/lib/emails/email-manager.ts @@ -1,4 +1,3 @@ -import { CalendarEvent } from "@lib/calendarClient"; import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email"; import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email"; import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email"; @@ -12,6 +11,7 @@ import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-reque import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email"; import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email"; import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; export const sendScheduledEmails = async (calEvent: CalendarEvent) => { const emailsToSend = []; diff --git a/lib/emails/templates/attendee-scheduled-email.ts b/lib/emails/templates/attendee-scheduled-email.ts index 7a656291b2..a3f16d42ad 100644 --- a/lib/emails/templates/attendee-scheduled-email.ts +++ b/lib/emails/templates/attendee-scheduled-email.ts @@ -3,13 +3,13 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import timezone from "dayjs/plugin/timezone"; import toArray from "dayjs/plugin/toArray"; import utc from "dayjs/plugin/utc"; -import { createEvent, DateArray } from "ics"; +import { createEvent, DateArray, Person } from "ics"; import nodemailer from "nodemailer"; import { getCancelLink, getRichDescription } from "@lib/CalEventParser"; -import { CalendarEvent, Person } from "@lib/calendarClient"; import { getErrorFromUnknown } from "@lib/errors"; import { getIntegrationName } from "@lib/integrations"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import { serverConfig } from "@lib/serverConfig"; import { diff --git a/lib/emails/templates/organizer-scheduled-email.ts b/lib/emails/templates/organizer-scheduled-email.ts index 6b28180917..81b412c5a8 100644 --- a/lib/emails/templates/organizer-scheduled-email.ts +++ b/lib/emails/templates/organizer-scheduled-email.ts @@ -3,13 +3,13 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import timezone from "dayjs/plugin/timezone"; import toArray from "dayjs/plugin/toArray"; import utc from "dayjs/plugin/utc"; -import { createEvent, DateArray } from "ics"; +import { createEvent, DateArray, Person } from "ics"; import nodemailer from "nodemailer"; import { getCancelLink, getRichDescription } from "@lib/CalEventParser"; -import { CalendarEvent, Person } from "@lib/calendarClient"; import { getErrorFromUnknown } from "@lib/errors"; import { getIntegrationName } from "@lib/integrations"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import { serverConfig } from "@lib/serverConfig"; import { diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index f6bf72066a..c7605eace9 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -3,8 +3,9 @@ import async from "async"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; -import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; +import { createEvent, updateEvent } from "@lib/integrations/calendar/CalendarManager"; +import { AdditionInformation, CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import { LocationType } from "@lib/location"; import prisma from "@lib/prisma"; import { Ensure } from "@lib/types/utils"; diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts deleted file mode 100644 index 61d258394b..0000000000 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Credential } from "@prisma/client"; - -import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter"; -import { CalendarApiAdapter } from "@lib/calendarClient"; - -export class AppleCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter { - constructor(credential: Credential) { - super(credential, "apple_calendar", "https://caldav.icloud.com"); - } -} diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts deleted file mode 100644 index e7361bf867..0000000000 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Credential } from "@prisma/client"; - -import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter"; -import { CalendarApiAdapter } from "@lib/calendarClient"; - -export class CalDavCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter { - constructor(credential: Credential) { - super(credential, "caldav_calendar"); - } -} diff --git a/lib/integrations/Daily/DailyVideoApiAdapter.ts b/lib/integrations/Daily/DailyVideoApiAdapter.ts index 3b603a2fa7..3172cc3c39 100644 --- a/lib/integrations/Daily/DailyVideoApiAdapter.ts +++ b/lib/integrations/Daily/DailyVideoApiAdapter.ts @@ -1,12 +1,13 @@ import { Credential } from "@prisma/client"; -import { CalendarEvent } from "@lib/calendarClient"; import { BASE_URL } from "@lib/config/constants"; import { handleErrorsJson } from "@lib/errors"; import { PartialReference } from "@lib/events/EventManager"; import prisma from "@lib/prisma"; import { VideoApiAdapter, VideoCallData } from "@lib/videoClient"; +import { CalendarEvent } from "../calendar/interfaces/Calendar"; + export interface DailyReturnType { /** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */ id: string; diff --git a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts deleted file mode 100644 index 3a89a8644c..0000000000 --- a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { Credential, Prisma } from "@prisma/client"; -import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; -import { Auth, calendar_v3, google } from "googleapis"; - -import { getLocation, getRichDescription } from "@lib/CalEventParser"; -import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient"; -import prisma from "@lib/prisma"; - -export interface ConferenceData { - createRequest?: calendar_v3.Schema$CreateConferenceRequest; -} - -class MyGoogleAuth extends google.auth.OAuth2 { - constructor(client_id: string, client_secret: string, redirect_uri: string) { - super(client_id, client_secret, redirect_uri); - } - - isTokenExpiring() { - return super.isTokenExpiring(); - } - - async refreshToken(token: string | null | undefined) { - return super.refreshToken(token); - } -} - -const googleAuth = (credential: Credential) => { - const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web; - const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); - const googleCredentials = credential.key as Auth.Credentials; - myGoogleAuth.setCredentials(googleCredentials); - - const isExpired = () => myGoogleAuth.isTokenExpiring(); - - const refreshAccessToken = () => - myGoogleAuth - .refreshToken(googleCredentials.refresh_token) - .then((res: GetTokenResponse) => { - const token = res.res?.data; - googleCredentials.access_token = token.access_token; - googleCredentials.expiry_date = token.expiry_date; - return prisma.credential - .update({ - where: { - id: credential.id, - }, - data: { - key: googleCredentials as Prisma.InputJsonValue, - }, - }) - .then(() => { - myGoogleAuth.setCredentials(googleCredentials); - return myGoogleAuth; - }); - }) - .catch((err) => { - console.error("Error refreshing google token", err); - return myGoogleAuth; - }); - - return { - getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), - }; -}; - -export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAdapter => { - const auth = googleAuth(credential); - const integrationType = "google_calendar"; - - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === integrationType) - .map((e) => e.externalId); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - resolve([]); - return; - } - - (selectedCalendarIds.length === 0 - ? calendar.calendarList - .list() - .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds) - ) - .then((calsIds) => { - calendar.freebusy.query( - { - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map((id) => ({ id: id })), - }, - }, - (err, apires) => { - if (err) { - reject(err); - } - let result: Prisma.PromiseReturnType = []; - if (apires?.data.calendars) { - result = Object.values(apires.data.calendars).reduce((c, i) => { - i.busy?.forEach((busyTime) => { - c.push({ - start: busyTime.start || "", - end: busyTime.end || "", - }); - }); - return c; - }, [] as typeof result); - } - resolve(result); - } - ); - }) - .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); - reject(err); - }); - }) - ), - createEvent: (event: CalendarEvent) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: getRichDescription(event), - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: false, - overrides: [{ method: "email", minutes: 10 }], - }, - }; - - if (event.location) { - payload["location"] = getLocation(event); - } - - if (event.conferenceData && event.location === "integrations:google:meet") { - payload["conferenceData"] = event.conferenceData; - } - - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.insert( - { - auth: myGoogleAuth, - calendarId: event.destinationCalendar?.externalId - ? event.destinationCalendar.externalId - : "primary", - requestBody: payload, - conferenceDataVersion: 1, - }, - function (err, event) { - if (err || !event?.data) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - return resolve({ - ...event.data, - id: event.data.id || "", - hangoutLink: event.data.hangoutLink || "", - type: "google_calendar", - password: "", - url: "", - }); - } - ); - }) - ), - updateEvent: (uid: string, event: CalendarEvent) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const payload: calendar_v3.Schema$Event = { - summary: event.title, - description: getRichDescription(event), - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees, - reminders: { - useDefault: true, - }, - }; - - if (event.location) { - payload["location"] = getLocation(event); - } - - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.update( - { - auth: myGoogleAuth, - calendarId: event.destinationCalendar?.externalId - ? event.destinationCalendar.externalId - : "primary", - eventId: uid, - sendNotifications: true, - sendUpdates: "all", - requestBody: payload, - }, - function (err, event) { - if (err) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - return resolve(event?.data); - } - ); - }) - ), - deleteEvent: (uid: string) => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.events.delete( - { - auth: myGoogleAuth, - calendarId: "primary", - eventId: uid, - sendNotifications: true, - sendUpdates: "all", - }, - function (err, event) { - if (err) { - console.error("There was an error contacting google calendar service: ", err); - return reject(err); - } - return resolve(event?.data); - } - ); - }) - ), - listCalendars: () => - new Promise((resolve, reject) => - auth.getToken().then((myGoogleAuth) => { - const calendar = google.calendar({ - version: "v3", - auth: myGoogleAuth, - }); - calendar.calendarList - .list() - .then((cals) => { - resolve( - cals.data.items?.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No id", - integration: integrationType, - name: cal.summary ?? "No name", - primary: cal.primary ?? false, - }; - return calendar; - }) || [] - ); - }) - .catch((err) => { - console.error("There was an error contacting google calendar service: ", err); - reject(err); - }); - }) - ), - }; -}; diff --git a/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts b/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts deleted file mode 100644 index 21ad4ddc5d..0000000000 --- a/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; -import { Credential } from "@prisma/client"; - -import { getLocation, getRichDescription } from "@lib/CalEventParser"; -import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient"; -import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; -import prisma from "@lib/prisma"; - -export type BufferedBusyTime = { - start: string; - end: string; -}; - -type O365AuthCredentials = { - expiry_date: number; - access_token: string; - refresh_token: string; -}; - -const o365Auth = (credential: Credential) => { - const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); - const o365AuthCredentials = credential.key as O365AuthCredentials; - - const refreshAccessToken = (refreshToken: string) => { - return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - scope: "User.Read Calendars.Read Calendars.ReadWrite", - client_id: process.env.MS_GRAPH_CLIENT_ID!, - refresh_token: refreshToken, - grant_type: "refresh_token", - client_secret: process.env.MS_GRAPH_CLIENT_SECRET!, - }), - }) - .then(handleErrorsJson) - .then((responseBody) => { - o365AuthCredentials.access_token = responseBody.access_token; - o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); - return prisma.credential - .update({ - where: { - id: credential.id, - }, - data: { - key: o365AuthCredentials, - }, - }) - .then(() => o365AuthCredentials.access_token); - }); - }; - - return { - getToken: () => - !isExpired(o365AuthCredentials.expiry_date) - ? Promise.resolve(o365AuthCredentials.access_token) - : refreshAccessToken(o365AuthCredentials.refresh_token), - }; -}; - -export const Office365CalendarApiAdapter = (credential: Credential): CalendarApiAdapter => { - const auth = o365Auth(credential); - - const translateEvent = (event: CalendarEvent) => { - return { - subject: event.title, - body: { - contentType: "HTML", - content: getRichDescription(event), - }, - start: { - dateTime: event.startTime, - timeZone: event.organizer.timeZone, - }, - end: { - dateTime: event.endTime, - timeZone: event.organizer.timeZone, - }, - attendees: event.attendees.map((attendee) => ({ - emailAddress: { - address: attendee.email, - name: attendee.name, - }, - type: "required", - })), - location: event.location ? { displayName: getLocation(event) } : undefined, - }; - }; - - const integrationType = "office365_calendar"; - - function listCalendars(): Promise { - return auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendars", { - method: "get", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - }) - .then(handleErrorsJson) - .then((responseBody: { value: OfficeCalendar[] }) => { - return responseBody.value.map((cal) => { - const calendar: IntegrationCalendar = { - externalId: cal.id ?? "No Id", - integration: integrationType, - name: cal.name ?? "No calendar name", - primary: cal.isDefaultCalendar ?? false, - }; - return calendar; - }); - }) - ); - } - - return { - getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const dateFromParsed = new Date(dateFrom); - const dateToParsed = new Date(dateTo); - - const filter = `?startdatetime=${encodeURIComponent( - dateFromParsed.toISOString() - )}&enddatetime=${encodeURIComponent(dateToParsed.toISOString())}`; - return auth - .getToken() - .then((accessToken) => { - const selectedCalendarIds = selectedCalendars - .filter((e) => e.integration === integrationType) - .map((e) => e.externalId) - .filter(Boolean); - if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - return Promise.resolve([]); - } - - return ( - selectedCalendarIds.length === 0 - ? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || []) - : Promise.resolve(selectedCalendarIds) - ).then((ids) => { - const requests = ids.map((calendarId, id) => ({ - id, - method: "GET", - url: `/me/calendars/${calendarId}/calendarView${filter}`, - })); - - type BatchResponse = { - responses: SubResponse[]; - }; - type SubResponse = { - body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] }; - }; - - return fetch("https://graph.microsoft.com/v1.0/$batch", { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify({ requests }), - }) - .then(handleErrorsJson) - .then((responseBody: BatchResponse) => - responseBody.responses.reduce( - (acc: BufferedBusyTime[], subResponse) => - acc.concat( - subResponse.body.value.map((evt) => { - return { - start: evt.start.dateTime + "Z", - end: evt.end.dateTime + "Z", - }; - }) - ), - [] - ) - ); - }); - }) - .catch((err) => { - console.log(err); - return Promise.reject([]); - }); - }, - createEvent: (event: CalendarEvent) => - auth.getToken().then((accessToken) => { - const calendarId = event.destinationCalendar?.externalId - ? `${event.destinationCalendar.externalId}/` - : ""; - return fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsJson); - }), - deleteEvent: (uid: string) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { - method: "DELETE", - headers: { - Authorization: "Bearer " + accessToken, - }, - }).then(handleErrorsRaw) - ), - updateEvent: (uid: string, event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { - method: "PATCH", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw) - ), - listCalendars, - }; -}; diff --git a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts index ea48ddad6b..ed5146120a 100644 --- a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts +++ b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts @@ -1,11 +1,12 @@ import { Credential } from "@prisma/client"; -import { CalendarEvent } from "@lib/calendarClient"; import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; import { PartialReference } from "@lib/events/EventManager"; import prisma from "@lib/prisma"; import { VideoApiAdapter, VideoCallData } from "@lib/videoClient"; +import { CalendarEvent } from "../calendar/interfaces/Calendar"; + /** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ export interface ZoomEventResult { password: string; diff --git a/lib/integrations/calendar/CalendarManager.ts b/lib/integrations/calendar/CalendarManager.ts new file mode 100644 index 0000000000..a8dd52ba50 --- /dev/null +++ b/lib/integrations/calendar/CalendarManager.ts @@ -0,0 +1,177 @@ +import { Credential, SelectedCalendar } from "@prisma/client"; +import _ from "lodash"; + +import { getUid } from "@lib/CalEventParser"; +import { getErrorFromUnknown } from "@lib/errors"; +import { EventResult } from "@lib/events/EventManager"; +import logger from "@lib/logger"; +import notEmpty from "@lib/notEmpty"; + +import { ALL_INTEGRATIONS } from "../getIntegrations"; +import { CALENDAR_INTEGRATIONS_TYPES } from "./constants/generals"; +import { CalendarServiceType } from "./constants/types"; +import { Calendar, CalendarEvent } from "./interfaces/Calendar"; +import AppleCalendarService from "./services/AppleCalendarService"; +import CalDavCalendarService from "./services/CalDavCalendarService"; +import GoogleCalendarService from "./services/GoogleCalendarService"; +import Office365CalendarService from "./services/Office365CalendarService"; + +const CALENDARS: Record = { + [CALENDAR_INTEGRATIONS_TYPES.apple]: AppleCalendarService, + [CALENDAR_INTEGRATIONS_TYPES.caldav]: CalDavCalendarService, + [CALENDAR_INTEGRATIONS_TYPES.google]: GoogleCalendarService, + [CALENDAR_INTEGRATIONS_TYPES.office365]: Office365CalendarService, +}; + +const log = logger.getChildLogger({ prefix: ["CalendarManager"] }); + +export const getCalendar = (credential: Credential): Calendar | null => { + const { type: calendarType } = credential; + + const calendar = CALENDARS[calendarType]; + if (!calendar) { + log.warn(`calendar of type ${calendarType} does not implemented`); + return null; + } + + return new calendar(credential); +}; + +export const getCalendarCredentials = (credentials: Array>, userId: number) => { + const calendarCredentials = credentials + .filter((credential) => credential.type.endsWith("_calendar")) + .flatMap((credential) => { + const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type); + + const calendar = getCalendar({ + ...credential, + userId, + }); + return integration && calendar && integration.variant === "calendar" + ? [{ integration, credential, calendar }] + : []; + }); + + return calendarCredentials; +}; + +export const getConnectedCalendars = async ( + calendarCredentials: ReturnType, + selectedCalendars: { externalId: string }[] +) => { + const connectedCalendars = await Promise.all( + calendarCredentials.map(async (item) => { + const { calendar, integration, credential } = item; + + const credentialId = credential.id; + try { + const cals = await calendar.listCalendars(); + const calendars = _(cals) + .map((cal) => ({ + ...cal, + primary: cal.primary || null, + isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId), + })) + .sortBy(["primary"]) + .value(); + const primary = calendars.find((item) => item.primary) ?? calendars[0]; + if (!primary) { + throw new Error("No primary calendar found"); + } + return { + integration, + credentialId, + primary, + calendars, + }; + } catch (_error) { + const error = getErrorFromUnknown(_error); + return { + integration, + credentialId, + error: { + message: error.message, + }, + }; + } + }) + ); + + return connectedCalendars; +}; + +export const getBusyCalendarTimes = async ( + withCredentials: Credential[], + dateFrom: string, + dateTo: string, + selectedCalendars: SelectedCalendar[] +) => { + const calendars = withCredentials + .filter((credential) => credential.type.endsWith("_calendar")) + .map((credential) => getCalendar(credential)) + .filter(notEmpty); + + const results = await Promise.all( + calendars.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) + ); + + return results.reduce((acc, availability) => acc.concat(availability), []); +}; + +export const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { + const uid: string = getUid(calEvent); + const calendar = getCalendar(credential); + let success = true; + + const creationResult = calendar + ? await calendar.createEvent(calEvent).catch((e) => { + log.error("createEvent failed", e, calEvent); + success = false; + return undefined; + }) + : undefined; + + return { + type: credential.type, + success, + uid, + createdEvent: creationResult, + originalEvent: calEvent, + }; +}; + +export const updateEvent = async ( + credential: Credential, + calEvent: CalendarEvent, + bookingRefUid: string | null +): Promise => { + const uid = getUid(calEvent); + const calendar = getCalendar(credential); + let success = true; + + const updatedResult = + calendar && bookingRefUid + ? await calendar.updateEvent(bookingRefUid, calEvent).catch((e) => { + log.error("updateEvent failed", e, calEvent); + success = false; + return undefined; + }) + : undefined; + + return { + type: credential.type, + success, + uid, + updatedEvent: updatedResult, + originalEvent: calEvent, + }; +}; + +export const deleteEvent = (credential: Credential, uid: string): Promise => { + const calendar = getCalendar(credential); + if (calendar) { + return calendar.deleteEvent(uid); + } + + return Promise.resolve({}); +}; diff --git a/lib/integrations/Apple/components/AddAppleIntegration.tsx b/lib/integrations/calendar/components/AddAppleIntegration.tsx similarity index 97% rename from lib/integrations/Apple/components/AddAppleIntegration.tsx rename to lib/integrations/calendar/components/AddAppleIntegration.tsx index ab901d223b..8cdcbd78d1 100644 --- a/lib/integrations/Apple/components/AddAppleIntegration.tsx +++ b/lib/integrations/calendar/components/AddAppleIntegration.tsx @@ -45,7 +45,7 @@ export function AddAppleIntegrationModal(props: DialogProps) {
{ + handleSubmit={async (values) => { setErrorMessage(""); const res = await fetch("/api/integrations/apple/add", { method: "POST", @@ -60,7 +60,7 @@ export function AddAppleIntegrationModal(props: DialogProps) { } else { props.onOpenChange?.(false); } - })}> + }}>
{ + handleSubmit={async (values) => { setErrorMessage(""); const res = await fetch("/api/integrations/caldav/add", { method: "POST", @@ -58,7 +58,7 @@ export function AddCalDavIntegrationModal(props: DialogProps) { } else { props.onOpenChange?.(false); } - })}> + }}>
((props, re -
+
@@ -144,7 +144,7 @@ const AddCalDavIntegration = React.forwardRef((props, re name="username" id="username" placeholder="rickroll" - className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" + className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" />
@@ -157,7 +157,7 @@ const AddCalDavIntegration = React.forwardRef((props, re name="password" id="password" placeholder="•••••••••••••" - className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" + className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" />
diff --git a/lib/integrations/calendar/constants/formats.ts b/lib/integrations/calendar/constants/formats.ts new file mode 100644 index 0000000000..0d5ca6feec --- /dev/null +++ b/lib/integrations/calendar/constants/formats.ts @@ -0,0 +1 @@ +export const TIMEZONE_FORMAT = "YYYY-MM-DDTHH:mm:ss[Z]"; diff --git a/lib/integrations/calendar/constants/generals.ts b/lib/integrations/calendar/constants/generals.ts new file mode 100644 index 0000000000..079c86b2e7 --- /dev/null +++ b/lib/integrations/calendar/constants/generals.ts @@ -0,0 +1,10 @@ +export const CALDAV_CALENDAR_TYPE = "caldav"; + +export const APPLE_CALENDAR_URL = "https://caldav.icloud.com"; + +export const CALENDAR_INTEGRATIONS_TYPES = { + apple: "apple_calendar", + caldav: "caldav_calendar", + google: "google_calendar", + office365: "office365_calendar", +}; diff --git a/lib/integrations/calendar/constants/types.ts b/lib/integrations/calendar/constants/types.ts new file mode 100644 index 0000000000..979c4ae5d1 --- /dev/null +++ b/lib/integrations/calendar/constants/types.ts @@ -0,0 +1,56 @@ +import dayjs from "dayjs"; +import ICAL from "ical.js"; + +import AppleCalendarService from "../services/AppleCalendarService"; +import CalDavCalendarService from "../services/CalDavCalendarService"; +import GoogleCalendarService from "../services/GoogleCalendarService"; +import Office365CalendarService from "../services/Office365CalendarService"; + +export type EventBusyDate = Record<"start" | "end", Date | string>; + +export type CalendarServiceType = + | typeof AppleCalendarService + | typeof CalDavCalendarService + | typeof GoogleCalendarService + | typeof Office365CalendarService; + +export type NewCalendarEventType = { + uid: string; + id: string; + type: string; + password: string; + url: string; + additionalInfo: Record; +}; + +export type CalendarEventType = { + uid: string; + etag: string; + url: string; + summary: string; + description: string; + location: string; + sequence: number; + startDate: Date | dayjs.Dayjs; + endDate: Date | dayjs.Dayjs; + duration: { + weeks: number; + days: number; + hours: number; + minutes: number; + seconds: number; + isNegative: boolean; + }; + organizer: string; + attendees: any[][]; + recurrenceId: ICAL.Time; + timezone: any; +}; + +export type BatchResponse = { + responses: SubResponse[]; +}; + +export type SubResponse = { + body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] }; +}; diff --git a/lib/integrations/calendar/interfaces/Calendar.ts b/lib/integrations/calendar/interfaces/Calendar.ts new file mode 100644 index 0000000000..f9358e6a3b --- /dev/null +++ b/lib/integrations/calendar/interfaces/Calendar.ts @@ -0,0 +1,78 @@ +import { DestinationCalendar, SelectedCalendar } from "@prisma/client"; +import { TFunction } from "next-i18next"; + +import { PaymentInfo } from "@ee/lib/stripe/server"; + +import { Ensure } from "@lib/types/utils"; +import { VideoCallData } from "@lib/videoClient"; + +import { NewCalendarEventType } from "../constants/types"; +import { ConferenceData } from "./GoogleCalendar"; + +export type Person = { + name: string; + email: string; + timeZone: string; +}; + +export interface EntryPoint { + entryPointType?: string; + uri?: string; + label?: string; + pin?: string; + accessCode?: string; + meetingCode?: string; + passcode?: string; + password?: string; +} + +export interface AdditionInformation { + conferenceData?: ConferenceData; + entryPoints?: EntryPoint[]; + hangoutLink?: string; +} + +export interface CalendarEvent { + type: string; + title: string; + startTime: string; + endTime: string; + description?: string | null; + team?: { + name: string; + members: string[]; + }; + location?: string | null; + organizer: Person; + attendees: Person[]; + conferenceData?: ConferenceData; + language: TFunction; + additionInformation?: AdditionInformation; + uid?: string | null; + videoCallData?: VideoCallData; + paymentInfo?: PaymentInfo | null; + destinationCalendar?: DestinationCalendar | null; +} + +export interface IntegrationCalendar extends Ensure, "externalId"> { + primary?: boolean; + name?: string; +} + +type EventBusyDate = Record<"start" | "end", Date | string>; + +export interface Calendar { + createEvent(event: CalendarEvent): Promise; + + updateEvent(uid: string, event: CalendarEvent): Promise; + + deleteEvent(uid: string): Promise; + + getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise; + + listCalendars(event?: CalendarEvent): Promise; +} diff --git a/lib/integrations/calendar/interfaces/GoogleCalendar.ts b/lib/integrations/calendar/interfaces/GoogleCalendar.ts new file mode 100644 index 0000000000..a76d26a58b --- /dev/null +++ b/lib/integrations/calendar/interfaces/GoogleCalendar.ts @@ -0,0 +1,5 @@ +import { calendar_v3 } from "googleapis"; + +export interface ConferenceData { + createRequest?: calendar_v3.Schema$CreateConferenceRequest; +} diff --git a/lib/integrations/calendar/interfaces/Office365Calendar.ts b/lib/integrations/calendar/interfaces/Office365Calendar.ts new file mode 100644 index 0000000000..599d2b9a67 --- /dev/null +++ b/lib/integrations/calendar/interfaces/Office365Calendar.ts @@ -0,0 +1,10 @@ +export type BufferedBusyTime = { + start: string; + end: string; +}; + +export type O365AuthCredentials = { + expiry_date: number; + access_token: string; + refresh_token: string; +}; diff --git a/lib/integrations/calendar/services/AppleCalendarService.ts b/lib/integrations/calendar/services/AppleCalendarService.ts new file mode 100644 index 0000000000..1ba55ca280 --- /dev/null +++ b/lib/integrations/calendar/services/AppleCalendarService.ts @@ -0,0 +1,10 @@ +import { Credential } from "@prisma/client"; + +import { APPLE_CALENDAR_URL, CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals"; +import CalendarService from "./BaseCalendarService"; + +export default class AppleCalendarService extends CalendarService { + constructor(credential: Credential) { + super(credential, CALENDAR_INTEGRATIONS_TYPES.apple, APPLE_CALENDAR_URL); + } +} diff --git a/lib/BaseCalendarApiAdapter.ts b/lib/integrations/calendar/services/BaseCalendarService.ts similarity index 60% rename from lib/BaseCalendarApiAdapter.ts rename to lib/integrations/calendar/services/BaseCalendarService.ts index 6534d9ab2f..7c400605da 100644 --- a/lib/BaseCalendarApiAdapter.ts +++ b/lib/integrations/calendar/services/BaseCalendarService.ts @@ -1,11 +1,11 @@ import { Credential } from "@prisma/client"; import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; import ICAL from "ical.js"; -import { Attendee, createEvent, DateArray, DurationObject } from "ics"; +import { createEvent } from "ics"; import { createAccount, createCalendarObject, + DAVAccount, deleteCalendarObject, fetchCalendarObjects, fetchCalendars, @@ -18,73 +18,62 @@ import { getLocation, getRichDescription } from "@lib/CalEventParser"; import { symmetricDecrypt } from "@lib/crypto"; import logger from "@lib/logger"; -import { CalendarEvent, IntegrationCalendar } from "./calendarClient"; +import { TIMEZONE_FORMAT } from "../constants/formats"; +import { CALDAV_CALENDAR_TYPE } from "../constants/generals"; +import { CalendarEventType, EventBusyDate, NewCalendarEventType } from "../constants/types"; +import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar"; +import { convertDate, getAttendees, getDuration } from "../utils/CalendarUtils"; -dayjs.extend(utc); +const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; -export type Person = { name: string; email: string; timeZone: string }; +export default abstract class BaseCalendarService implements Calendar { + private url = ""; + private credentials: Record = {}; + private headers: Record = {}; + protected integrationName = ""; -export class BaseCalendarApiAdapter { - private url: string; - private credentials: Record; - private headers: Record; - private integrationName = ""; + log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); constructor(credential: Credential, integrationName: string, url?: string) { - const decryptedCredential = JSON.parse( - symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!) - ); - const username = decryptedCredential.username; - const password = decryptedCredential.password; - this.url = url || decryptedCredential.url; this.integrationName = integrationName; + + const { + username, + password, + url: credentialURL, + } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY)); + + this.url = url || credentialURL; + this.credentials = { username, password }; this.headers = getBasicAuthHeaders({ username, password }); } - log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); - - convertDate(date: string): DateArray { - return dayjs(date) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray; - } - - getDuration(start: string, end: string): DurationObject { - return { - minutes: dayjs(end).diff(dayjs(start), "minute"), - }; - } - - getAttendees(attendees: Person[]): Attendee[] { - return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); - } - - async createEvent(event: CalendarEvent) { + async createEvent(event: CalendarEvent): Promise { try { const calendars = await this.listCalendars(event); + const uid = uuidv4(); - /** We create local ICS files */ + + // We create local ICS files const { error, value: iCalString } = createEvent({ uid, startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.getDuration(event.startTime, event.endTime), + start: convertDate(event.startTime), + duration: getDuration(event.startTime, event.endTime), title: event.title, description: getRichDescription(event), location: getLocation(event), organizer: { email: event.organizer.email, name: event.organizer.name }, - // according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component. "Attendees" MUST NOT be present - // attendees: this.getAttendees(event.attendees), + /** according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component. + * "Attendees" MUST NOT be present + * `attendees: this.getAttendees(event.attendees),` + */ }); - if (error) throw new Error("Error creating iCalString"); + if (error || !iCalString) throw new Error("Error creating iCalString"); - if (!iCalString) throw new Error("Error creating iCalString"); - - /** We create the event directly on iCal */ + // We create the event directly on iCal const responses = await Promise.all( calendars .filter((c) => @@ -117,46 +106,40 @@ export class BaseCalendarApiAdapter { type: this.integrationName, password: "", url: "", + additionalInfo: {}, }; } catch (reason) { - console.error(reason); + logger.error(reason); + throw reason; } } - async updateEvent(uid: string, event: CalendarEvent) { + async updateEvent(uid: string, event: CalendarEvent): Promise { try { - const calendars = await this.listCalendars(); - const events = []; - - for (const cal of calendars) { - const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]); - - for (const ev of calEvents) { - events.push(ev); - } - } + const events = await this.getEventsByUID(uid); const { error, value: iCalString } = createEvent({ uid, startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.getDuration(event.startTime, event.endTime), + start: convertDate(event.startTime), + duration: getDuration(event.startTime, event.endTime), title: event.title, description: getRichDescription(event), location: getLocation(event), organizer: { email: event.organizer.email, name: event.organizer.name }, - attendees: this.getAttendees(event.attendees), + attendees: getAttendees(event.attendees), }); if (error) { this.log.debug("Error creating iCalString"); + return {}; } const eventsToUpdate = events.filter((event) => event.uid === uid); - return await Promise.all( + return Promise.all( eventsToUpdate.map((event) => { return updateCalendarObject({ calendarObject: { @@ -169,28 +152,20 @@ export class BaseCalendarApiAdapter { }) ); } catch (reason) { - console.error(reason); + this.log.error(reason); + throw reason; } } async deleteEvent(uid: string): Promise { try { - const calendars = await this.listCalendars(); - const events = []; + const events = await this.getEventsByUID(uid); - for (const cal of calendars) { - const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]); - - for (const ev of calEvents) { - events.push(ev); - } - } - - const eventsToUpdate = events.filter((event) => event.uid === uid); + const eventsToDelete = events.filter((event) => event.uid === uid); await Promise.all( - eventsToUpdate.map((event) => { + eventsToDelete.map((event) => { return deleteCalendarObject({ calendarObject: { url: event.url, @@ -201,52 +176,30 @@ export class BaseCalendarApiAdapter { }) ); } catch (reason) { - console.error(reason); + this.log.error(reason); + throw reason; } } - async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) { - try { - 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([]); - } + getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + this.log.warn( + `Method not implemented. dateFrom: ${dateFrom}, dateTo: ${dateTo}, selectedCalendars: ${selectedCalendars}` + ); - return ( - selectedCalendarIds.length === 0 - ? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId)) - : Promise.resolve(selectedCalendarIds) - ).then(async (ids: string[]) => { - if (ids.length === 0) { - return Promise.resolve([]); - } + const eventsBusyDate: EventBusyDate[] = []; - return ( - await Promise.all( - ids.map(async (calId) => { - return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => { - return { - start: event.startDate.toISOString(), - end: event.endDate.toISOString(), - }; - }); - }) - ) - ).flatMap((event) => event); - }); - } catch (reason) { - this.log.error(reason); - throw reason; - } + return Promise.resolve(eventsBusyDate); } async listCalendars(event?: CalendarEvent): Promise { try { const account = await this.getAccount(); + const calendars = await fetchCalendars({ account, headers: this.headers, @@ -254,6 +207,7 @@ export class BaseCalendarApiAdapter { return calendars.reduce((newCalendars, calendar) => { if (!calendar.components?.includes("VEVENT")) return newCalendars; + newCalendars.push({ externalId: calendar.url, name: calendar.displayName ?? "", @@ -265,12 +219,13 @@ export class BaseCalendarApiAdapter { return newCalendars; }, []); } catch (reason) { - console.error(reason); + logger.error(reason); + throw reason; } } - async getEvents( + private async getEvents( calId: string, dateFrom: string | null, dateTo: string | null, @@ -285,8 +240,8 @@ export class BaseCalendarApiAdapter { timeRange: dateFrom && dateTo ? { - start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), - end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), + start: dayjs(dateFrom).utc().format(TIMEZONE_FORMAT), + end: dayjs(dateTo).utc().format(TIMEZONE_FORMAT), } : undefined, headers: this.headers, @@ -296,7 +251,9 @@ export class BaseCalendarApiAdapter { .filter((e) => !!e.data) .map((object) => { const jcalData = ICAL.parse(object.data); + const vcalendar = new ICAL.Component(jcalData); + const vevent = vcalendar.getFirstSubcomponent("vevent"); const event = new ICAL.Event(vevent); @@ -306,6 +263,7 @@ export class BaseCalendarApiAdapter { const startDate = calendarTimezone ? dayjs(event.startDate.toJSDate()).tz(calendarTimezone) : new Date(event.startDate.toUnixTime() * 1000); + const endDate = calendarTimezone ? dayjs(event.endDate.toJSDate()).tz(calendarTimezone) : new Date(event.endDate.toUnixTime() * 1000); @@ -342,16 +300,30 @@ export class BaseCalendarApiAdapter { } } - private async getAccount() { - const account = await createAccount({ + private async getEventsByUID(uid: string): Promise { + const events = []; + + const calendars = await this.listCalendars(); + + for (const cal of calendars) { + const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]); + + for (const ev of calEvents) { + events.push(ev); + } + } + + return events; + } + + private async getAccount(): Promise { + return createAccount({ account: { serverUrl: this.url, - accountType: "caldav", + accountType: CALDAV_CALENDAR_TYPE, credentials: this.credentials, }, headers: this.headers, }); - - return account; } } diff --git a/lib/integrations/calendar/services/CalDavCalendarService.ts b/lib/integrations/calendar/services/CalDavCalendarService.ts new file mode 100644 index 0000000000..f6bc2f726f --- /dev/null +++ b/lib/integrations/calendar/services/CalDavCalendarService.ts @@ -0,0 +1,10 @@ +import { Credential } from "@prisma/client"; + +import { CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals"; +import CalendarService from "./BaseCalendarService"; + +export default class CalDavCalendarService extends CalendarService { + constructor(credential: Credential) { + super(credential, CALENDAR_INTEGRATIONS_TYPES.caldav); + } +} diff --git a/lib/integrations/calendar/services/GoogleCalendarService.ts b/lib/integrations/calendar/services/GoogleCalendarService.ts new file mode 100644 index 0000000000..a97b57f8db --- /dev/null +++ b/lib/integrations/calendar/services/GoogleCalendarService.ts @@ -0,0 +1,324 @@ +import { Credential, Prisma } from "@prisma/client"; +import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; +import { Auth, calendar_v3, google } from "googleapis"; + +import { getLocation, getRichDescription } from "@lib/CalEventParser"; +import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals"; +import logger from "@lib/logger"; +import prisma from "@lib/prisma"; + +import { EventBusyDate, NewCalendarEventType } from "../constants/types"; +import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar"; +import CalendarService from "./BaseCalendarService"; + +const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || ""; + +export default class GoogleCalendarService implements Calendar { + private url = ""; + private integrationName = ""; + private auth: { getToken: () => Promise }; + + log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + + constructor(credential: Credential) { + this.integrationName = CALENDAR_INTEGRATIONS_TYPES.google; + + this.auth = this.googleAuth(credential); + } + + private googleAuth = (credential: Credential) => { + const { client_secret, client_id, redirect_uris } = JSON.parse(GOOGLE_API_CREDENTIALS).web; + + const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); + + const googleCredentials = credential.key as Auth.Credentials; + myGoogleAuth.setCredentials(googleCredentials); + + const isExpired = () => myGoogleAuth.isTokenExpiring(); + + const refreshAccessToken = () => + myGoogleAuth + .refreshToken(googleCredentials.refresh_token) + .then((res: GetTokenResponse) => { + const token = res.res?.data; + googleCredentials.access_token = token.access_token; + googleCredentials.expiry_date = token.expiry_date; + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: googleCredentials as Prisma.InputJsonValue, + }, + }) + .then(() => { + myGoogleAuth.setCredentials(googleCredentials); + return myGoogleAuth; + }); + }) + .catch((err) => { + this.log.error("Error refreshing google token", err); + + return myGoogleAuth; + }); + + return { + getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), + }; + }; + + async createEvent(event: CalendarEvent): Promise { + return new Promise((resolve, reject) => + this.auth.getToken().then((myGoogleAuth) => { + const payload: calendar_v3.Schema$Event = { + summary: event.title, + description: getRichDescription(event), + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [{ method: "email", minutes: 10 }], + }, + }; + + if (event.location) { + payload["location"] = getLocation(event); + } + + if (event.conferenceData && event.location === "integrations:google:meet") { + payload["conferenceData"] = event.conferenceData; + } + + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.insert( + { + auth: myGoogleAuth, + calendarId: event.destinationCalendar?.externalId + ? event.destinationCalendar.externalId + : "primary", + requestBody: payload, + conferenceDataVersion: 1, + }, + function (err, event) { + if (err || !event?.data) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve({ + uid: "", + ...event.data, + id: event.data.id || "", + additionalInfo: { + hangoutLink: event.data.hangoutLink || "", + }, + type: "google_calendar", + password: "", + url: "", + }); + } + ); + }) + ); + } + + async updateEvent(uid: string, event: CalendarEvent): Promise { + return new Promise((resolve, reject) => + this.auth.getToken().then((myGoogleAuth) => { + const payload: calendar_v3.Schema$Event = { + summary: event.title, + description: getRichDescription(event), + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: true, + }, + }; + + if (event.location) { + payload["location"] = getLocation(event); + } + + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.update( + { + auth: myGoogleAuth, + calendarId: event.destinationCalendar?.externalId + ? event.destinationCalendar.externalId + : "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + requestBody: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + + return reject(err); + } + return resolve(event?.data); + } + ); + }) + ); + } + + async deleteEvent(uid: string): Promise { + return new Promise((resolve, reject) => + this.auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + calendar.events.delete( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event?.data); + } + ); + }) + ); + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + return new Promise((resolve, reject) => + this.auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + 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 + resolve([]); + return; + } + + (selectedCalendarIds.length === 0 + ? calendar.calendarList + .list() + .then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds) + ) + .then((calsIds) => { + calendar.freebusy.query( + { + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: calsIds.map((id) => ({ id: id })), + }, + }, + (err, apires) => { + if (err) { + reject(err); + } + let result: Prisma.PromiseReturnType = []; + + if (apires?.data.calendars) { + result = Object.values(apires.data.calendars).reduce((c, i) => { + i.busy?.forEach((busyTime) => { + c.push({ + start: busyTime.start || "", + end: busyTime.end || "", + }); + }); + return c; + }, [] as typeof result); + } + resolve(result); + } + ); + }) + .catch((err) => { + this.log.error("There was an error contacting google calendar service: ", err); + + reject(err); + }); + }) + ); + } + + async listCalendars(): Promise { + return new Promise((resolve, reject) => + this.auth.getToken().then((myGoogleAuth) => { + const calendar = google.calendar({ + version: "v3", + auth: myGoogleAuth, + }); + + calendar.calendarList + .list() + .then((cals) => { + resolve( + cals.data.items?.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id ?? "No id", + integration: this.integrationName, + name: cal.summary ?? "No name", + primary: cal.primary ?? false, + }; + return calendar; + }) || [] + ); + }) + .catch((err) => { + this.log.error("There was an error contacting google calendar service: ", err); + + reject(err); + }); + }) + ); + } +} + +class MyGoogleAuth extends google.auth.OAuth2 { + constructor(client_id: string, client_secret: string, redirect_uri: string) { + super(client_id, client_secret, redirect_uri); + } + + isTokenExpiring() { + return super.isTokenExpiring(); + } + + async refreshToken(token: string | null | undefined) { + return super.refreshToken(token); + } +} diff --git a/lib/integrations/calendar/services/Office365CalendarService.ts b/lib/integrations/calendar/services/Office365CalendarService.ts new file mode 100644 index 0000000000..ed53062f2a --- /dev/null +++ b/lib/integrations/calendar/services/Office365CalendarService.ts @@ -0,0 +1,250 @@ +import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; +import { Credential } from "@prisma/client"; + +import { getLocation, getRichDescription } from "@lib/CalEventParser"; +import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; +import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals"; +import logger from "@lib/logger"; +import prisma from "@lib/prisma"; + +import { BatchResponse, EventBusyDate, NewCalendarEventType } from "../constants/types"; +import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar"; +import { BufferedBusyTime, O365AuthCredentials } from "../interfaces/Office365Calendar"; + +const MS_GRAPH_CLIENT_ID = process.env.MS_GRAPH_CLIENT_ID || ""; +const MS_GRAPH_CLIENT_SECRET = process.env.MS_GRAPH_CLIENT_SECRET || ""; + +export default class Office365CalendarService implements Calendar { + private url = ""; + private integrationName = ""; + auth: { getToken: () => Promise }; + + log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + + constructor(credential: Credential) { + this.integrationName = CALENDAR_INTEGRATIONS_TYPES.office365; + this.auth = this.o365Auth(credential); + } + + async createEvent(event: CalendarEvent): Promise { + try { + const accessToken = await this.auth.getToken(); + + const calendarId = event.destinationCalendar?.externalId + ? `${event.destinationCalendar.externalId}/` + : ""; + + const response = await fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(this.translateEvent(event)), + }); + + return handleErrorsJson(response); + } catch (error) { + this.log.error(error); + + throw error; + } + } + + async updateEvent(uid: string, event: CalendarEvent): Promise { + try { + const accessToken = await this.auth.getToken(); + + const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "PATCH", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(this.translateEvent(event)), + }); + + return handleErrorsRaw(response); + } catch (error) { + this.log.error(error); + + throw error; + } + } + + async deleteEvent(uid: string): Promise { + try { + const accessToken = await this.auth.getToken(); + + const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }); + + handleErrorsRaw(response); + } catch (error) { + this.log.error(error); + + throw error; + } + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + const dateFromParsed = new Date(dateFrom); + const dateToParsed = new Date(dateTo); + + const filter = `?startdatetime=${encodeURIComponent( + dateFromParsed.toISOString() + )}&enddatetime=${encodeURIComponent(dateToParsed.toISOString())}`; + return this.auth + .getToken() + .then((accessToken) => { + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === this.integrationName) + .map((e) => e.externalId) + .filter(Boolean); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + return Promise.resolve([]); + } + + return ( + selectedCalendarIds.length === 0 + ? this.listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || []) + : Promise.resolve(selectedCalendarIds) + ).then((ids) => { + const requests = ids.map((calendarId, id) => ({ + id, + method: "GET", + url: `/me/calendars/${calendarId}/calendarView${filter}`, + })); + + return fetch("https://graph.microsoft.com/v1.0/$batch", { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ requests }), + }) + .then(handleErrorsJson) + .then((responseBody: BatchResponse) => + responseBody.responses.reduce( + (acc: BufferedBusyTime[], subResponse) => + acc.concat( + subResponse.body.value.map((evt) => { + return { + start: evt.start.dateTime + "Z", + end: evt.end.dateTime + "Z", + }; + }) + ), + [] + ) + ); + }); + }) + .catch((err) => { + console.log(err); + return Promise.reject([]); + }); + } + + async listCalendars(): Promise { + return this.auth.getToken().then((accessToken) => + fetch("https://graph.microsoft.com/v1.0/me/calendars", { + method: "get", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + }) + .then(handleErrorsJson) + .then((responseBody: { value: OfficeCalendar[] }) => { + return responseBody.value.map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.id ?? "No Id", + integration: this.integrationName, + name: cal.name ?? "No calendar name", + primary: cal.isDefaultCalendar ?? false, + }; + return calendar; + }); + }) + ); + } + + private o365Auth = (credential: Credential) => { + const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000); + + const o365AuthCredentials = credential.key as O365AuthCredentials; + + const refreshAccessToken = (refreshToken: string) => { + return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + scope: "User.Read Calendars.Read Calendars.ReadWrite", + client_id: MS_GRAPH_CLIENT_ID, + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret: MS_GRAPH_CLIENT_SECRET, + }), + }) + .then(handleErrorsJson) + .then((responseBody) => { + o365AuthCredentials.access_token = responseBody.access_token; + o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: o365AuthCredentials, + }, + }) + .then(() => o365AuthCredentials.access_token); + }); + }; + + return { + getToken: () => + !isExpired(o365AuthCredentials.expiry_date) + ? Promise.resolve(o365AuthCredentials.access_token) + : refreshAccessToken(o365AuthCredentials.refresh_token), + }; + }; + + private translateEvent = (event: CalendarEvent) => { + return { + subject: event.title, + body: { + contentType: "HTML", + content: getRichDescription(event), + }, + start: { + dateTime: event.startTime, + timeZone: event.organizer.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.organizer.timeZone, + }, + attendees: event.attendees.map((attendee) => ({ + emailAddress: { + address: attendee.email, + name: attendee.name, + }, + type: "required", + })), + location: event.location ? { displayName: getLocation(event) } : undefined, + }; + }; +} diff --git a/lib/integrations/calendar/utils/CalendarUtils.ts b/lib/integrations/calendar/utils/CalendarUtils.ts new file mode 100644 index 0000000000..a5927e543c --- /dev/null +++ b/lib/integrations/calendar/utils/CalendarUtils.ts @@ -0,0 +1,16 @@ +import dayjs from "dayjs"; +import { Attendee, DateArray, DurationObject, Person } from "ics"; + +export const convertDate = (date: string): DateArray => + dayjs(date) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray; + +export const getDuration = (start: string, end: string): DurationObject => ({ + minutes: dayjs(end).diff(dayjs(start), "minute"), +}); + +export const getAttendees = (attendees: Person[]): Attendee[] => + attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" })); diff --git a/lib/queries/availability/index.ts b/lib/queries/availability/index.ts index eb94c32242..3833c05d9c 100644 --- a/lib/queries/availability/index.ts +++ b/lib/queries/availability/index.ts @@ -4,7 +4,7 @@ import dayjs from "dayjs"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; -import { getBusyCalendarTimes } from "@lib/calendarClient"; +import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager"; import prisma from "@lib/prisma"; export async function getUserAvailability(query: { diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 7e5d7168f7..cd1cdd3e01 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -7,9 +7,9 @@ import { EventResult } from "@lib/events/EventManager"; import { PartialReference } from "@lib/events/EventManager"; import logger from "@lib/logger"; -import { CalendarEvent } from "./calendarClient"; import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter"; import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter"; +import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar"; import { Ensure } from "./types/utils"; const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] }); diff --git a/lib/webhooks/sendPayload.tsx b/lib/webhooks/sendPayload.tsx index e13ebd36ae..8ed67c1f53 100644 --- a/lib/webhooks/sendPayload.tsx +++ b/lib/webhooks/sendPayload.tsx @@ -1,6 +1,6 @@ import { compile } from "handlebars"; -import { CalendarEvent } from "@lib/calendarClient"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; type ContentType = "application/json" | "application/x-www-form-urlencoded"; diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index cd8f357af3..fc6bca48f3 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -7,7 +7,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; -import { getBusyCalendarTimes } from "@lib/calendarClient"; +import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager"; import prisma from "@lib/prisma"; dayjs.extend(utc); diff --git a/pages/api/availability/calendar.ts b/pages/api/availability/calendar.ts index 3e88e09ff7..a8f4c64d92 100644 --- a/pages/api/availability/calendar.ts +++ b/pages/api/availability/calendar.ts @@ -1,12 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; +import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager"; import notEmpty from "@lib/notEmpty"; import prisma from "@lib/prisma"; -import getCalendarCredentials from "@server/integrations/getCalendarCredentials"; -import getConnectedCalendars from "@server/integrations/getConnectedCalendars"; - export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req }); diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 6c72466dea..82b4496d00 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -4,10 +4,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { refund } from "@ee/lib/stripe/server"; import { getSession } from "@lib/auth"; -import { CalendarEvent, AdditionInformation } from "@lib/calendarClient"; import { sendDeclinedEmails } from "@lib/emails/email-manager"; import { sendScheduledEmails } from "@lib/emails/email-manager"; import EventManager from "@lib/events/EventManager"; +import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; import { BookingConfirmBody } from "@lib/types/booking"; diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index af7a693ccd..ed7bb4fc3d 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -11,7 +11,6 @@ import { v5 as uuidv5 } from "uuid"; import { handlePayment } from "@ee/lib/stripe/server"; -import { CalendarEvent, AdditionInformation, getBusyCalendarTimes } from "@lib/calendarClient"; import { sendScheduledEmails, sendRescheduledEmails, @@ -21,7 +20,9 @@ import { ensureArray } from "@lib/ensureArray"; import { getErrorFromUnknown } from "@lib/errors"; import { getEventName } from "@lib/event"; import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager"; -import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; +import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager"; +import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar"; +import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar"; import logger from "@lib/logger"; import notEmpty from "@lib/notEmpty"; import prisma from "@lib/prisma"; diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts index 332263d5f6..a12a24bf55 100644 --- a/pages/api/cancel.ts +++ b/pages/api/cancel.ts @@ -6,9 +6,10 @@ import { refund } from "@ee/lib/stripe/server"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; -import { CalendarEvent, deleteEvent } from "@lib/calendarClient"; import { sendCancelledEmails } from "@lib/emails/email-manager"; import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter"; +import { getCalendar } from "@lib/integrations/calendar/CalendarManager"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import prisma from "@lib/prisma"; import { deleteMeeting } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; @@ -138,9 +139,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid; if (bookingRefUid) { if (credential.type.endsWith("_calendar")) { - return await deleteEvent(credential, bookingRefUid); + const calendar = getCalendar(credential); + + return calendar?.deleteEvent(bookingRefUid); } else if (credential.type.endsWith("_video")) { - return await deleteMeeting(credential, bookingRefUid); + return deleteMeeting(credential, bookingRefUid); } } }); diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts index 021cbb5f28..d56818e69c 100644 --- a/pages/api/cron/bookingReminder.ts +++ b/pages/api/cron/bookingReminder.ts @@ -2,8 +2,8 @@ import { ReminderType } from "@prisma/client"; import dayjs from "dayjs"; import type { NextApiRequest, NextApiResponse } from "next"; -import { CalendarEvent } from "@lib/calendarClient"; import { sendOrganizerRequestReminderEmail } from "@lib/emails/email-manager"; +import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; import prisma from "@lib/prisma"; import { getTranslation } from "@server/lib/i18n"; diff --git a/pages/api/integrations/apple/add.ts b/pages/api/integrations/apple/add.ts index 23ebd7bac2..df76768329 100644 --- a/pages/api/integrations/apple/add.ts +++ b/pages/api/integrations/apple/add.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; import { symmetricEncrypt } from "@lib/crypto"; -import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter"; +import { getCalendar } from "@lib/integrations/calendar/CalendarManager"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; @@ -35,11 +35,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; try { - const dav = new AppleCalendar({ + const dav = getCalendar({ id: 0, ...data, }); - await dav.listCalendars(); + await dav?.listCalendars(); await prisma.credential.create({ data, }); diff --git a/pages/api/integrations/caldav/add.ts b/pages/api/integrations/caldav/add.ts index e00740aba9..5cc8864536 100644 --- a/pages/api/integrations/caldav/add.ts +++ b/pages/api/integrations/caldav/add.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; import { symmetricEncrypt } from "@lib/crypto"; -import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter"; +import { getCalendar } from "@lib/integrations/calendar/CalendarManager"; import logger from "@lib/logger"; import prisma from "@lib/prisma"; @@ -38,11 +38,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; try { - const dav = new CalDavCalendar({ + const dav = getCalendar({ id: 0, ...data, }); - await dav.listCalendars(); + await dav?.listCalendars(); await prisma.credential.create({ data, }); diff --git a/pages/getting-started.tsx b/pages/getting-started.tsx index 97439e8005..168a7e441b 100644 --- a/pages/getting-started.tsx +++ b/pages/getting-started.tsx @@ -18,6 +18,7 @@ import TimezoneSelect from "react-timezone-select"; import { getSession } from "@lib/auth"; import { DEFAULT_SCHEDULE } from "@lib/availability"; import { useLocale } from "@lib/hooks/useLocale"; +import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager"; import getIntegrations from "@lib/integrations/getIntegrations"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -32,9 +33,6 @@ import Button from "@components/ui/Button"; import Text from "@components/ui/Text"; import Schedule from "@components/ui/form/Schedule"; -import getCalendarCredentials from "@server/integrations/getCalendarCredentials"; -import getConnectedCalendars from "@server/integrations/getConnectedCalendars"; - import getEventTypes from "../lib/queries/event-types/get-event-types"; dayjs.extend(utc); diff --git a/playwright/lib/globalSetup.ts b/playwright/lib/globalSetup.ts index c5cbcfdd70..368ec7c6be 100644 --- a/playwright/lib/globalSetup.ts +++ b/playwright/lib/globalSetup.ts @@ -1,6 +1,9 @@ import { Browser, chromium } from "@playwright/test"; +import fs from "fs"; async function loginAsUser(username: string, browser: Browser) { + // Skip is file exists + if (fs.existsSync(`playwright/artifacts/${username}StorageState.json`)) return; const page = await browser.newPage(); await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`); // Click input[name="email"] diff --git a/server/integrations/getCalendarCredentials.ts b/server/integrations/getCalendarCredentials.ts deleted file mode 100644 index 20c6c49b5f..0000000000 --- a/server/integrations/getCalendarCredentials.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Credential } from "@prisma/client"; - -import { getCalendarAdapterOrNull } from "@lib/calendarClient"; -import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations"; - -export default function getCalendarCredentials( - credentials: Array>, - userId: number -) { - const calendarCredentials = credentials - .filter((credential) => credential.type.endsWith("_calendar")) - .flatMap((credential) => { - const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type); - - const adapter = getCalendarAdapterOrNull({ - ...credential, - userId, - }); - return integration && adapter && integration.variant === "calendar" - ? [{ integration, credential, adapter }] - : []; - }); - - return calendarCredentials; -} diff --git a/server/integrations/getConnectedCalendars.ts b/server/integrations/getConnectedCalendars.ts deleted file mode 100644 index 461075e937..0000000000 --- a/server/integrations/getConnectedCalendars.ts +++ /dev/null @@ -1,50 +0,0 @@ -import _ from "lodash"; - -import { getErrorFromUnknown } from "@lib/errors"; - -import getCalendarCredentials from "./getCalendarCredentials"; - -export default async function getConnectedCalendars( - calendarCredentials: ReturnType, - selectedCalendars: { externalId: string }[] -) { - const connectedCalendars = await Promise.all( - calendarCredentials.map(async (item) => { - const { adapter, integration, credential } = item; - - const credentialId = credential.id; - try { - const cals = await adapter.listCalendars(); - const calendars = _(cals) - .map((cal) => ({ - ...cal, - primary: cal.primary || null, - isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId), - })) - .sortBy(["primary"]) - .value(); - const primary = calendars.find((item) => item.primary) ?? calendars[0]; - if (!primary) { - throw new Error("No primary calendar found"); - } - return { - integration, - credentialId, - primary, - calendars, - }; - } catch (_error) { - const error = getErrorFromUnknown(_error); - return { - integration, - credentialId, - error: { - message: error.message, - }, - }; - } - }) - ); - - return connectedCalendars; -} diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index e0f57638a9..8669794116 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -5,12 +5,11 @@ import { z } from "zod"; import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername"; import { checkRegularUsername } from "@lib/core/checkRegularUsername"; +import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager"; import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations"; import slugify from "@lib/slugify"; import { Schedule } from "@lib/types/schedule"; -import getCalendarCredentials from "@server/integrations/getCalendarCredentials"; -import getConnectedCalendars from "@server/integrations/getConnectedCalendars"; import { TRPCError } from "@trpc/server"; import { createProtectedRouter, createRouter } from "../createRouter"; @@ -298,7 +297,10 @@ const loggedInViewerRouter = createProtectedRouter() }, ], }; - const bookingListingOrderby: Record = { + const bookingListingOrderby: Record< + typeof bookingListingByStatus, + Prisma.BookingOrderByWithAggregationInput + > = { upcoming: { startTime: "desc" }, past: { startTime: "desc" }, cancelled: { startTime: "desc" },