diff --git a/components/integrations/CalendarListContainer.tsx b/components/integrations/CalendarListContainer.tsx index cbab1c94..f07b769e 100644 --- a/components/integrations/CalendarListContainer.tsx +++ b/components/integrations/CalendarListContainer.tsx @@ -1,5 +1,6 @@ -import React, { Fragment } from "react"; +import React, { Fragment, useState } from "react"; import { useMutation } from "react-query"; +import Select from "react-select"; import { QueryCell } from "@lib/QueryCell"; import { useLocale } from "@lib/hooks/useLocale"; @@ -98,59 +99,124 @@ function ConnectedCalendarsList(props: Props) { <QueryCell query={query} empty={() => null} - success={({ data }) => ( - <List> - {data.map((item) => ( - <Fragment key={item.credentialId}> - {item.calendars ? ( - <IntegrationListItem - {...item.integration} - description={item.primary?.externalId || "No external Id"} - actions={ - <DisconnectIntegration - id={item.credentialId} - render={(btnProps) => ( - <Button {...btnProps} color="warn"> - {t("disconnect")} - </Button> - )} - onOpenChange={props.onChanged} - /> - }> - <ul className="p-4 space-y-2"> - {item.calendars.map((cal) => ( - <CalendarSwitch - key={cal.externalId} - externalId={cal.externalId as string} - title={cal.name as string} - type={item.integration.type} - defaultSelected={cal.isSelected} + success={({ data }) => { + if (!data.connectedCalendars.length) { + return null; + } + return ( + <List> + {data.connectedCalendars.map((item) => ( + <Fragment key={item.credentialId}> + {item.calendars ? ( + <IntegrationListItem + {...item.integration} + description={item.primary?.externalId || "No external Id"} + actions={ + <DisconnectIntegration + id={item.credentialId} + render={(btnProps) => ( + <Button {...btnProps} color="warn"> + {t("disconnect")} + </Button> + )} + onOpenChange={props.onChanged} /> - ))} - </ul> - </IntegrationListItem> - ) : ( - <Alert - severity="warning" - title="Something went wrong" - message={item.error?.message} - actions={ - <DisconnectIntegration - id={item.credentialId} - render={(btnProps) => ( - <Button {...btnProps} color="warn"> - {t("disconnect")} - </Button> - )} - onOpenChange={() => props.onChanged()} - /> - } - /> - )} - </Fragment> - ))} - </List> - )} + }> + <ul className="p-4 space-y-2"> + {item.calendars.map((cal) => ( + <CalendarSwitch + key={cal.externalId} + externalId={cal.externalId as string} + title={cal.name as string} + type={item.integration.type} + defaultSelected={cal.isSelected} + /> + ))} + </ul> + </IntegrationListItem> + ) : ( + <Alert + severity="warning" + title="Something went wrong" + message={item.error?.message} + actions={ + <DisconnectIntegration + id={item.credentialId} + render={(btnProps) => ( + <Button {...btnProps} color="warn"> + Disconnect + </Button> + )} + onOpenChange={() => props.onChanged()} + /> + } + /> + )} + </Fragment> + ))} + </List> + ); + }} + /> + ); +} + +function PrimaryCalendarSelector() { + const query = trpc.useQuery(["viewer.connectedCalendars"], { + suspense: true, + }); + const [selectedOption, setSelectedOption] = useState(() => { + const selected = query.data?.connectedCalendars + .map((connected) => connected.calendars ?? []) + .flat() + .find((cal) => cal.externalId === query.data.destinationCalendar?.externalId); + + if (!selected) { + return null; + } + + return { + value: `${selected.integration}:${selected.externalId}`, + label: selected.name, + }; + }); + + const mutation = trpc.useMutation("viewer.setUserDestinationCalendar"); + + if (!query.data?.connectedCalendars.length) { + return null; + } + const options = + query.data.connectedCalendars.map((selectedCalendar) => ({ + key: selectedCalendar.credentialId, + label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`, + options: (selectedCalendar.calendars ?? []).map((cal) => ({ + label: cal.name || "", + value: `${cal.integration}:${cal.externalId}`, + })), + })) ?? []; + return ( + <Select + name={"primarySelectedCalendar"} + options={options} + isSearchable={false} + className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm" + onChange={(option) => { + setSelectedOption(option); + if (!option) { + return; + } + + /* Split only the first `:`, since Apple uses the full URL as externalId */ + const [integration, externalId] = option.value.split(/:(.+)/); + + mutation.mutate({ + integration, + externalId, + }); + }} + isLoading={mutation.isLoading} + value={selectedOption} /> ); } @@ -201,12 +267,20 @@ export function CalendarListContainer(props: { heading?: false }) { {heading && ( <ShellSubHeading className="mt-10" - title={<SubHeadingTitleWithConnections title={t("calendar")} numConnections={query.data?.length} />} + title={ + <SubHeadingTitleWithConnections + title="Calendars" + numConnections={query.data?.connectedCalendars.length} + /> + } subtitle={t("configure_how_your_event_types_interact")} + actions={<div className="block"></div>} /> )} + <p className="mr-4 text-sm text-neutral-500">{t("select_destination_calendar")}</p> + <PrimaryCalendarSelector /> <ConnectedCalendarsList onChanged={onChanged} /> - {!!query.data?.length && ( + {!!query.data?.connectedCalendars.length && ( <ShellSubHeading className="mt-6" title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />} diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts index fd97252a..979396c8 100644 --- a/ee/lib/stripe/server.ts +++ b/ee/lib/stripe/server.ts @@ -1,6 +1,5 @@ -import { PaymentType } from "@prisma/client"; +import { PaymentType, Prisma } from "@prisma/client"; import Stripe from "stripe"; -import { JsonValue } from "type-fest"; import { v4 as uuidv4 } from "uuid"; import { CalendarEvent } from "@lib/calendarClient"; @@ -39,7 +38,7 @@ export async function handlePayment( price: number; currency: string; }, - stripeCredential: { key: JsonValue }, + stripeCredential: { key: Prisma.JsonValue }, booking: { user: { email: string | null; name: string | null; timeZone: string } | null; id: number; @@ -74,7 +73,7 @@ export async function handlePayment( data: Object.assign({}, paymentIntent, { stripe_publishable_key, stripeAccount: stripe_user_id, - }) as PaymentData as unknown as JsonValue, + }) as PaymentData as unknown as Prisma.JsonValue, externalId: paymentIntent.id, }, }); @@ -103,7 +102,7 @@ export async function refund( success: boolean; refunded: boolean; externalId: string; - data: JsonValue; + data: Prisma.JsonValue; type: PaymentType; }[]; }, @@ -113,7 +112,7 @@ export async function refund( const payment = booking.payment.find((e) => e.success && !e.refunded); if (!payment) return; - if (payment.type != PaymentType.STRIPE) { + if (payment.type !== PaymentType.STRIPE) { await handleRefundError({ event: calEvent, reason: "cannot refund non Stripe payment", diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts index 459db97f..126f6998 100644 --- a/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/ee/pages/api/integrations/stripepayment/webhook.ts @@ -57,6 +57,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { email: true, name: true, locale: true, + destinationCalendar: true, }, }, }, @@ -91,7 +92,7 @@ async function handlePaymentSuccess(event: Stripe.Event) { if (booking.location) evt.location = booking.location; if (booking.confirmed) { - const eventManager = new EventManager(user.credentials); + const eventManager = new EventManager(user); const scheduleResult = await eventManager.create(evt); await prisma.booking.update({ diff --git a/jest.playwright.config.js b/jest.playwright.config.js index e4654d46..f73715d3 100644 --- a/jest.playwright.config.js +++ b/jest.playwright.config.js @@ -3,6 +3,7 @@ const opts = { headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS, collectCoverage: false, // not possible in Next.js 12 executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH, + locale: "en", // So tests won't fail if local machine is not in english }; console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4)); diff --git a/lib/BaseCalendarApiAdapter.ts b/lib/BaseCalendarApiAdapter.ts new file mode 100644 index 00000000..2adc6a38 --- /dev/null +++ b/lib/BaseCalendarApiAdapter.ts @@ -0,0 +1,349 @@ +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 { + createAccount, + createCalendarObject, + deleteCalendarObject, + fetchCalendarObjects, + fetchCalendars, + getBasicAuthHeaders, + updateCalendarObject, +} from "tsdav"; +import { v4 as uuidv4 } from "uuid"; + +import { getLocation, getRichDescription } from "@lib/CalEventParser"; +import { symmetricDecrypt } from "@lib/crypto"; +import logger from "@lib/logger"; + +import { CalendarEvent, IntegrationCalendar } from "./calendarClient"; + +dayjs.extend(utc); + +export type Person = { name: string; email: string; timeZone: string }; + +export class BaseCalendarApiAdapter { + private url: string; + private credentials: Record<string, string>; + private headers: Record<string, string>; + private 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; + 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) { + try { + const calendars = await this.listCalendars(event); + const uid = uuidv4(); + /** 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), + title: event.title, + description: getRichDescription(event), + location: getLocation(event), + organizer: { email: event.organizer.email, name: event.organizer.name }, + attendees: this.getAttendees(event.attendees), + }); + + if (error) throw new Error("Error creating iCalString"); + + if (!iCalString) throw new Error("Error creating iCalString"); + + /** We create the event directly on iCal */ + await Promise.all( + calendars + .filter((c) => + event.destinationCalendar?.externalId + ? c.externalId === event.destinationCalendar.externalId + : true + ) + .map((calendar) => + createCalendarObject({ + calendar: { + url: calendar.externalId, + }, + filename: `${uid}.ics`, + iCalString, + headers: this.headers, + }) + ) + ); + + return { + uid, + id: uid, + type: this.integrationName, + password: "", + url: "", + }; + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async updateEvent(uid: string, event: CalendarEvent) { + 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 { error, value: iCalString } = createEvent({ + uid, + startInputType: "utc", + start: this.convertDate(event.startTime), + duration: this.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), + }); + + if (error) { + this.log.debug("Error creating iCalString"); + return {}; + } + + const eventsToUpdate = events.filter((event) => event.uid === uid); + + return await Promise.all( + eventsToUpdate.map((event) => { + return updateCalendarObject({ + calendarObject: { + url: event.url, + data: iCalString, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async deleteEvent(uid: string): Promise<void> { + 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 eventsToUpdate = events.filter((event) => event.uid === uid); + + await Promise.all( + eventsToUpdate.map((event) => { + return deleteCalendarObject({ + calendarObject: { + url: event.url, + etag: event?.etag, + }, + headers: this.headers, + }); + }) + ); + } catch (reason) { + console.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([]); + } + + 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([]); + } + + 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; + } + } + + async listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> { + try { + const account = await this.getAccount(); + const calendars = await fetchCalendars({ + account, + headers: this.headers, + }); + + return calendars.reduce<IntegrationCalendar[]>((newCalendars, calendar) => { + if (!calendar.components?.includes("VEVENT")) return newCalendars; + newCalendars.push({ + externalId: calendar.url, + name: calendar.displayName ?? "", + primary: event?.destinationCalendar?.externalId + ? event.destinationCalendar.externalId === calendar.url + : false, + integration: this.integrationName, + }); + return newCalendars; + }, []); + } catch (reason) { + console.error(reason); + throw reason; + } + } + + async getEvents( + calId: string, + dateFrom: string | null, + dateTo: string | null, + objectUrls?: string[] | null + ) { + try { + const objects = await fetchCalendarObjects({ + calendar: { + url: calId, + }, + objectUrls: objectUrls ? objectUrls : undefined, + 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]"), + } + : undefined, + headers: this.headers, + }); + + const events = objects + .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); + + const calendarTimezone = + vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || ""; + + 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); + + return { + uid: event.uid, + etag: object.etag, + url: object.url, + summary: event.summary, + description: event.description, + location: event.location, + sequence: event.sequence, + startDate, + endDate, + duration: { + weeks: event.duration.weeks, + days: event.duration.days, + hours: event.duration.hours, + minutes: event.duration.minutes, + seconds: event.duration.seconds, + isNegative: event.duration.isNegative, + }, + organizer: event.organizer, + attendees: event.attendees.map((a) => a.getValues()), + recurrenceId: event.recurrenceId, + timezone: calendarTimezone, + }; + }); + + return events; + } catch (reason) { + console.error(reason); + throw reason; + } + } + + private async getAccount() { + const account = await createAccount({ + account: { + serverUrl: this.url, + accountType: "caldav", + credentials: this.credentials, + }, + headers: this.headers, + }); + + return account; + } +} diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 92ba977c..b32aae68 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -68,7 +68,7 @@ export const getLocation = (calEvent: CalendarEvent) => { return calEvent.additionInformation.hangoutLink; } - return providerName || calEvent.location; + return providerName || calEvent.location || ""; }; export const getManageLink = (calEvent: CalendarEvent) => { diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index ee6841f6..89914a00 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import { Credential, SelectedCalendar } from "@prisma/client"; +import { Credential, DestinationCalendar, SelectedCalendar } from "@prisma/client"; import { TFunction } from "next-i18next"; import { PaymentInfo } from "@ee/lib/stripe/server"; @@ -9,16 +8,14 @@ import { Event, EventResult } from "@lib/events/EventManager"; import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter"; import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter"; import { - GoogleCalendarApiAdapter, ConferenceData, + GoogleCalendarApiAdapter, } from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter"; -import { - Office365CalendarApiAdapter, - BufferedBusyTime, -} from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; +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"] }); @@ -61,6 +58,7 @@ export interface CalendarEvent { uid?: string | null; videoCallData?: VideoCallData; paymentInfo?: PaymentInfo | null; + destinationCalendar?: DestinationCalendar | null; } export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> { @@ -68,6 +66,8 @@ export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, " name?: string; } +type EventBusyDate = Record<"start" | "end", Date | string>; + export interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise<Event>; @@ -79,7 +79,7 @@ export interface CalendarApiAdapter { dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[] - ): Promise<BufferedBusyTime[]>; + ): Promise<EventBusyDate[]>; listCalendars(): Promise<IntegrationCalendar[]>; } @@ -98,72 +98,32 @@ function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | return null; } -/** - * @deprecated - */ -const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] => - withCredentials - .map((cred) => { - switch (cred.type) { - case "google_calendar": - return GoogleCalendarApiAdapter(cred); - case "office365_calendar": - return Office365CalendarApiAdapter(cred); - case "caldav_calendar": - return new CalDavCalendar(cred); - case "apple_calendar": - return new AppleCalendar(cred); - default: - return; // unknown credential, could be legacy? In any case, ignore - } - }) - .flatMap((item) => (item ? [item as CalendarApiAdapter] : [])); - -const getBusyCalendarTimes = ( +const getBusyCalendarTimes = async ( withCredentials: Credential[], dateFrom: string, dateTo: string, selectedCalendars: SelectedCalendar[] -) => - Promise.all( - calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) - ).then((results) => { - return results.reduce((acc, availability) => acc.concat(availability), []); - }); - -/** - * - * @param withCredentials - * @deprecated - */ -const listCalendars = (withCredentials: Credential[]) => - Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => - results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) +) => { + 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<EventResult> => { const uid: string = getUid(calEvent); + const adapter = getCalendarAdapterOrNull(credential); let success = true; - const creationResult = credential - ? await calendars([credential])[0] - .createEvent(calEvent) - .catch((e) => { - log.error("createEvent failed", e, calEvent); - success = false; - return undefined; - }) + const creationResult = adapter + ? await adapter.createEvent(calEvent).catch((e) => { + log.error("createEvent failed", e, calEvent); + success = false; + return undefined; + }) : undefined; - if (!creationResult) { - return { - type: credential.type, - success, - uid, - originalEvent: calEvent, - }; - } - return { type: credential.type, success, @@ -179,28 +139,18 @@ const updateEvent = async ( bookingRefUid: string | null ): Promise<EventResult> => { const uid = getUid(calEvent); + const adapter = getCalendarAdapterOrNull(credential); let success = true; const updatedResult = - credential && bookingRefUid - ? await calendars([credential])[0] - .updateEvent(bookingRefUid, calEvent) - .catch((e) => { - log.error("updateEvent failed", e, calEvent); - success = false; - return undefined; - }) + adapter && bookingRefUid + ? await adapter.updateEvent(bookingRefUid, calEvent).catch((e) => { + log.error("updateEvent failed", e, calEvent); + success = false; + return undefined; + }) : undefined; - if (!updatedResult) { - return { - type: credential.type, - success, - uid, - originalEvent: calEvent, - }; - } - return { type: credential.type, success, @@ -211,18 +161,12 @@ const updateEvent = async ( }; const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => { - if (credential) { - return calendars([credential])[0].deleteEvent(uid); + const adapter = getCalendarAdapterOrNull(credential); + if (adapter) { + return adapter.deleteEvent(uid); } return Promise.resolve({}); }; -export { - getBusyCalendarTimes, - createEvent, - updateEvent, - deleteEvent, - listCalendars, - getCalendarAdapterOrNull, -}; +export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, getCalendarAdapterOrNull }; diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 790e6e2e..f6bf7206 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -1,4 +1,4 @@ -import { Credential } from "@prisma/client"; +import { Credential, DestinationCalendar } from "@prisma/client"; import async from "async"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; @@ -86,18 +86,22 @@ export const processLocation = (event: CalendarEvent): CalendarEvent => { return event; }; +type EventManagerUser = { + credentials: Credential[]; + destinationCalendar: DestinationCalendar | null; +}; export default class EventManager { - calendarCredentials: Array<Credential>; - videoCredentials: Array<Credential>; + calendarCredentials: Credential[]; + videoCredentials: Credential[]; /** * Takes an array of credentials and initializes a new instance of the EventManager. * * @param credentials */ - constructor(credentials: Array<Credential>) { - this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar")); - this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video")); + constructor(user: EventManagerUser) { + this.calendarCredentials = user.credentials.filter((cred) => cred.type.endsWith("_calendar")); + this.videoCredentials = user.credentials.filter((cred) => cred.type.endsWith("_video")); //for Daily.co video, temporarily pushes a credential for the daily-video-client const hasDailyIntegration = process.env.DAILY_API_KEY; @@ -180,6 +184,7 @@ export default class EventManager { meetingUrl: true, }, }, + destinationCalendar: true, }, }); @@ -194,6 +199,7 @@ export default class EventManager { const result = await this.updateVideoEvent(evt, booking); if (result.updatedEvent) { evt.videoCallData = result.updatedEvent; + evt.location = result.updatedEvent.url; } results.push(result); } @@ -240,13 +246,21 @@ export default class EventManager { * @param noMail * @private */ - private async createAllCalendarEvents(event: CalendarEvent): Promise<Array<EventResult>> { - const [firstCalendar] = this.calendarCredentials; - if (!firstCalendar) { + /** Can I use destinationCalendar here? */ + /* How can I link a DC to a cred? */ + if (event.destinationCalendar) { + const destinationCalendarCredentials = this.calendarCredentials.filter( + (c) => c.type === event.destinationCalendar?.integration + ); + return Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event))); + } + + const [credential] = this.calendarCredentials; + if (!credential) { return []; } - return [await createEvent(firstCalendar, event)]; + return [await createEvent(credential, event)]; } /** diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts index f23187f6..61d25839 100644 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -1,346 +1,10 @@ import { Credential } from "@prisma/client"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import ICAL from "ical.js"; -import { createEvent, DurationObject, Attendee, Person } from "ics"; -import { - createAccount, - fetchCalendars, - fetchCalendarObjects, - getBasicAuthHeaders, - createCalendarObject, - updateCalendarObject, - deleteCalendarObject, -} from "tsdav"; -import { v4 as uuidv4 } from "uuid"; -import { getLocation, getRichDescription } from "@lib/CalEventParser"; -import { symmetricDecrypt } from "@lib/crypto"; -import logger from "@lib/logger"; - -import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient"; - -dayjs.extend(utc); - -const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] }); - -export class AppleCalendar implements CalendarApiAdapter { - private url: string; - private credentials: Record<string, string>; - private headers: Record<string, string>; - private readonly integrationName: string = "apple_calendar"; +import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter"; +import { CalendarApiAdapter } from "@lib/calendarClient"; +export class AppleCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter { constructor(credential: Credential) { - const decryptedCredential = JSON.parse( - symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!) - ); - const username = decryptedCredential.username; - const password = decryptedCredential.password; - - this.url = "https://caldav.icloud.com"; - - this.credentials = { - username, - password, - }; - - this.headers = getBasicAuthHeaders({ - username, - password, - }); - } - - convertDate(date: string): [number, number, number] { - return dayjs(date) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number]; - } - - 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) { - try { - const calendars = await this.listCalendars(); - const uid = uuidv4(); - const { error, value: iCalString } = createEvent({ - uid, - startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.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), - }); - - if (error) throw new Error("Error creating iCalString"); - - if (!iCalString) throw new Error("Error creating iCalString"); - - await Promise.all( - calendars.map((calendar) => { - return createCalendarObject({ - calendar: { - url: calendar.externalId, - }, - filename: `${uid}.ics`, - iCalString: iCalString, - headers: this.headers, - }); - }) - ); - - return { - uid, - id: uid, - type: "apple_calendar", - password: "", - url: "", - }; - } catch (reason) { - console.error(reason); - throw reason; - } - } - - async updateEvent(uid: string, event: CalendarEvent): Promise<unknown> { - 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 { error, value: iCalString } = createEvent({ - uid, - startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.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), - }); - - if (error) { - log.debug("Error creating iCalString"); - return {}; - } - - const eventsToUpdate = events.filter((event) => event.uid === uid); - - return await Promise.all( - eventsToUpdate.map((event) => { - return updateCalendarObject({ - calendarObject: { - url: event.url, - data: iCalString, - etag: event?.etag, - }, - headers: this.headers, - }); - }) - ); - } catch (reason) { - console.error(reason); - throw reason; - } - } - - async deleteEvent(uid: string): Promise<void> { - 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 eventsToUpdate = events.filter((event) => event.uid === uid); - - await Promise.all( - eventsToUpdate.map((event) => { - return deleteCalendarObject({ - calendarObject: { - url: event.url, - etag: event?.etag, - }, - headers: this.headers, - }); - }) - ); - } catch (reason) { - console.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([]); - } - - 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([]); - } - - 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) { - log.error(reason); - throw reason; - } - } - - async listCalendars(): Promise<IntegrationCalendar[]> { - try { - const account = await this.getAccount(); - const calendars = await fetchCalendars({ - account, - headers: this.headers, - }); - - return calendars - .filter((calendar) => { - return calendar.components?.includes("VEVENT"); - }) - .map((calendar, index) => ({ - externalId: calendar.url, - name: calendar.displayName ?? "", - // FIXME Find a better way to set the primary calendar - primary: index === 0, - integration: this.integrationName, - })); - } catch (reason) { - console.error(reason); - throw reason; - } - } - - async getEvents( - calId: string, - dateFrom: string | null, - dateTo: string | null, - objectUrls?: string[] | null - ) { - try { - const objects = await fetchCalendarObjects({ - calendar: { - url: calId, - }, - objectUrls: objectUrls ? objectUrls : undefined, - 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]"), - } - : undefined, - headers: this.headers, - }); - - const events = objects - .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); - - const calendarTimezone = - vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || ""; - - 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); - - return { - uid: event.uid, - etag: object.etag, - url: object.url, - summary: event.summary, - description: event.description, - location: event.location, - sequence: event.sequence, - startDate, - endDate, - duration: { - weeks: event.duration.weeks, - days: event.duration.days, - hours: event.duration.hours, - minutes: event.duration.minutes, - seconds: event.duration.seconds, - isNegative: event.duration.isNegative, - }, - organizer: event.organizer, - attendees: event.attendees.map((a) => a.getValues()), - recurrenceId: event.recurrenceId, - timezone: calendarTimezone, - }; - }); - - return events; - } catch (reason) { - console.error(reason); - throw reason; - } - } - - private async getAccount() { - const account = await createAccount({ - account: { - serverUrl: this.url, - accountType: "caldav", - credentials: this.credentials, - }, - headers: this.headers, - }); - - return account; + super(credential, "apple_calendar", "https://caldav.icloud.com"); } } diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts index dc3e02ac..e7361bf8 100644 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -1,347 +1,10 @@ import { Credential } from "@prisma/client"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import ICAL from "ical.js"; -import { Attendee, createEvent, DurationObject, Person } from "ics"; -import { - createAccount, - createCalendarObject, - deleteCalendarObject, - fetchCalendarObjects, - fetchCalendars, - getBasicAuthHeaders, - updateCalendarObject, -} from "tsdav"; -import { v4 as uuidv4 } from "uuid"; -import { getLocation, getRichDescription } from "@lib/CalEventParser"; -import { symmetricDecrypt } from "@lib/crypto"; -import logger from "@lib/logger"; - -import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient"; - -dayjs.extend(utc); - -const log = logger.getChildLogger({ prefix: ["[lib] caldav"] }); - -export class CalDavCalendar implements CalendarApiAdapter { - private url: string; - private credentials: Record<string, string>; - private headers: Record<string, string>; - private readonly integrationName: string = "caldav_calendar"; +import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter"; +import { CalendarApiAdapter } from "@lib/calendarClient"; +export class CalDavCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter { constructor(credential: Credential) { - const decryptedCredential = JSON.parse( - symmetricDecrypt(credential.key as string, 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.headers = getBasicAuthHeaders({ - username, - password, - }); - } - - convertDate(date: string): [number, number, number] { - return dayjs(date) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as [number, number, number]; - } - - 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) { - try { - const calendars = await this.listCalendars(); - const uid = uuidv4(); - - const { error, value: iCalString } = createEvent({ - uid, - startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.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), - }); - - if (error) throw new Error("Error creating iCalString"); - - if (!iCalString) throw new Error("Error creating iCalString"); - - await Promise.all( - calendars.map((calendar) => { - return createCalendarObject({ - calendar: { - url: calendar.externalId, - }, - filename: `${uid}.ics`, - iCalString: iCalString, - headers: this.headers, - }); - }) - ); - - return { - uid, - id: uid, - type: "caldav_calendar", - password: "", - url: "", - }; - } catch (reason) { - log.error(reason); - throw reason; - } - } - - async updateEvent(uid: string, event: CalendarEvent): Promise<unknown> { - try { - const calendars = await this.listCalendars(); - const events = []; - - for (const cal of calendars) { - const calEvents = await this.getEvents(cal.externalId, null, null); - - for (const ev of calEvents) { - events.push(ev); - } - } - - const { error, value: iCalString } = await createEvent({ - uid, - startInputType: "utc", - start: this.convertDate(event.startTime), - duration: this.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), - }); - - if (error) { - log.debug("Error creating iCalString"); - return {}; - } - - const eventsToUpdate = events.filter((event) => event.uid === uid); - - return await Promise.all( - eventsToUpdate.map((event) => { - return updateCalendarObject({ - calendarObject: { - url: event.url, - data: iCalString, - etag: event?.etag, - }, - headers: this.headers, - }); - }) - ); - } catch (reason) { - log.error(reason); - throw reason; - } - } - - async deleteEvent(uid: string): Promise<void> { - try { - const calendars = await this.listCalendars(); - const events = []; - - for (const cal of calendars) { - const calEvents = await this.getEvents(cal.externalId, null, null); - - for (const ev of calEvents) { - events.push(ev); - } - } - - const eventsToUpdate = events.filter((event) => event.uid === uid); - - await Promise.all( - eventsToUpdate.map((event) => { - return deleteCalendarObject({ - calendarObject: { - url: event.url, - etag: event?.etag, - }, - headers: this.headers, - }); - }) - ); - } catch (reason) { - 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([]); - } - - 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([]); - } - - 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) { - log.error(reason); - throw reason; - } - } - - async listCalendars(): Promise<IntegrationCalendar[]> { - try { - const account = await this.getAccount(); - const calendars = await fetchCalendars({ - account, - headers: this.headers, - }); - - return calendars - .filter((calendar) => { - return calendar.components?.includes("VEVENT"); - }) - .map((calendar, index) => ({ - externalId: calendar.url, - name: calendar.displayName ?? "", - // FIXME Find a better way to set the primary calendar - primary: index === 0, - integration: this.integrationName, - })); - } catch (reason) { - log.error(reason); - throw reason; - } - } - - async getEvents(calId: string, dateFrom: string | null, dateTo: string | null) { - try { - const objects = await fetchCalendarObjects({ - calendar: { - url: calId, - }, - 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]"), - } - : undefined, - headers: this.headers, - }); - - if (!objects || objects?.length === 0) { - return []; - } - - const events = objects - .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); - - const calendarTimezone = - vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || ""; - - 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); - - return { - uid: event.uid, - etag: object.etag, - url: object.url, - summary: event.summary, - description: event.description, - location: event.location, - sequence: event.sequence, - startDate, - endDate, - duration: { - weeks: event.duration.weeks, - days: event.duration.days, - hours: event.duration.hours, - minutes: event.duration.minutes, - seconds: event.duration.seconds, - isNegative: event.duration.isNegative, - }, - organizer: event.organizer, - attendees: event.attendees.map((a) => a.getValues()), - recurrenceId: event.recurrenceId, - timezone: calendarTimezone, - }; - }); - - return events; - } catch (reason) { - log.error(reason); - throw reason; - } - } - - private async getAccount() { - const account = await createAccount({ - account: { - serverUrl: `${this.url}`, - accountType: "caldav", - credentials: this.credentials, - }, - headers: this.headers, - }); - - return account; + super(credential, "caldav_calendar"); } } diff --git a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts index b9395bd8..3a89a864 100644 --- a/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts +++ b/lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter.ts @@ -10,18 +10,30 @@ 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 google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); const googleCredentials = credential.key as Auth.Credentials; myGoogleAuth.setCredentials(googleCredentials); - // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ const isExpired = () => myGoogleAuth.isTokenExpiring(); const refreshAccessToken = () => myGoogleAuth - // FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯ .refreshToken(googleCredentials.refresh_token) .then((res: GetTokenResponse) => { const token = res.res?.data; @@ -149,7 +161,9 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda calendar.events.insert( { auth: myGoogleAuth, - calendarId: "primary", + calendarId: event.destinationCalendar?.externalId + ? event.destinationCalendar.externalId + : "primary", requestBody: payload, conferenceDataVersion: 1, }, @@ -201,7 +215,9 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda calendar.events.update( { auth: myGoogleAuth, - calendarId: "primary", + calendarId: event.destinationCalendar?.externalId + ? event.destinationCalendar.externalId + : "primary", eventId: uid, sendNotifications: true, sendUpdates: "all", diff --git a/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts b/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts index e0843a80..6369e644 100644 --- a/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts +++ b/lib/integrations/Office365Calendar/Office365CalendarApiAdapter.ts @@ -182,16 +182,19 @@ export const Office365CalendarApiAdapter = (credential: Credential): CalendarApi }); }, createEvent: (event: CalendarEvent) => - auth.getToken().then((accessToken) => - fetch("https://graph.microsoft.com/v1.0/me/calendar/events", { + 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) - ), + }).then(handleErrorsJson); + }), deleteEvent: (uid: string) => auth.getToken().then((accessToken) => fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { diff --git a/lib/notEmpty.ts b/lib/notEmpty.ts new file mode 100644 index 00000000..ed8480b5 --- /dev/null +++ b/lib/notEmpty.ts @@ -0,0 +1,3 @@ +const notEmpty = <T>(value: T): value is NonNullable<typeof value> => !!value; + +export default notEmpty; diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 9e811da4..e57d2347 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -51,7 +51,7 @@ const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] => return acc; }, []); -const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) => +const getBusyVideoTimes = (withCredentials: Credential[]) => Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) => results.reduce((acc, availability) => acc.concat(availability), []) ); diff --git a/pages/[user].tsx b/pages/[user].tsx index 0a9fd3d2..8d42c8f5 100644 --- a/pages/[user].tsx +++ b/pages/[user].tsx @@ -20,6 +20,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) { const { user, eventTypes } = props; const { t } = useLocale(); const router = useRouter(); + const query = { ...router.query }; + delete query.user; // So it doesn't display in the Link (and make tests fail) const nameOrUsername = user.name || user.username || ""; @@ -54,9 +56,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) { <Link href={{ pathname: `/${user.username}/${type.slug}`, - query: { - ...router.query, - }, + query, }}> <a className="block px-6 py-4" data-testid="event-type-link"> <h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2> diff --git a/pages/api/availability/calendar.ts b/pages/api/availability/calendar.ts index 6f976a70..3e88e09f 100644 --- a/pages/api/availability/calendar.ts +++ b/pages/api/availability/calendar.ts @@ -1,19 +1,21 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; +import notEmpty from "@lib/notEmpty"; +import prisma from "@lib/prisma"; -import { IntegrationCalendar, listCalendars } from "../../../lib/calendarClient"; -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: req }); + const session = await getSession({ req }); if (!session?.user?.id) { res.status(401).json({ message: "Not authenticated" }); return; } - const currentUser = await prisma.user.findUnique({ + const user = await prisma.user.findUnique({ where: { id: session.user.id, }, @@ -21,25 +23,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) credentials: true, timeZone: true, id: true, + selectedCalendars: true, }, }); - if (!currentUser) { + if (!user) { res.status(401).json({ message: "Not authenticated" }); return; } - if (req.method == "POST") { + if (req.method === "POST") { await prisma.selectedCalendar.upsert({ where: { userId_integration_externalId: { - userId: currentUser.id, + userId: user.id, integration: req.body.integration, externalId: req.body.externalId, }, }, create: { - userId: currentUser.id, + userId: user.id, integration: req.body.integration, externalId: req.body.externalId, }, @@ -49,11 +52,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(200).json({ message: "Calendar Selection Saved" }); } - if (req.method == "DELETE") { + if (req.method === "DELETE") { await prisma.selectedCalendar.delete({ where: { userId_integration_externalId: { - userId: currentUser.id, + userId: user.id, externalId: req.body.externalId, integration: req.body.integration, }, @@ -63,17 +66,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(200).json({ message: "Calendar Selection Saved" }); } - if (req.method == "GET") { + if (req.method === "GET") { const selectedCalendarIds = await prisma.selectedCalendar.findMany({ where: { - userId: currentUser.id, + userId: user.id, }, select: { externalId: true, }, }); - const calendars: IntegrationCalendar[] = await listCalendars(currentUser.credentials); + // get user's credentials + their connected integrations + const calendarCredentials = getCalendarCredentials(user.credentials, user.id); + // get all the connected integrations' calendars (from third party) + const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); + const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty); const selectableCalendars = calendars.map((cal) => { return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal }; }); diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 7aa599bb..6c72466d 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -1,4 +1,4 @@ -import { User, Booking, SchedulingType, BookingStatus } from "@prisma/client"; +import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { refund } from "@ee/lib/stripe/server"; @@ -70,6 +70,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) email: true, name: true, username: true, + destinationCalendar: true, }, }); @@ -77,7 +78,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(404).json({ message: "User not found" }); } - if (req.method == "PATCH") { + if (req.method === "PATCH") { const booking = await prisma.booking.findFirst({ where: { id: bookingId, @@ -128,7 +129,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; if (reqBody.confirmed) { - const eventManager = new EventManager(currentUser.credentials); + const eventManager = new EventManager(currentUser); const scheduleResult = await eventManager.create(evt); const results = scheduleResult.results; diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 7bbcf195..23f08cc6 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -23,6 +23,7 @@ import { getEventName } from "@lib/event"; import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager"; import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter"; import logger from "@lib/logger"; +import notEmpty from "@lib/notEmpty"; import prisma from "@lib/prisma"; import { BookingCreateBody } from "@lib/types/booking"; import { getBusyVideoTimes } from "@lib/videoClient"; @@ -133,6 +134,7 @@ const userSelect = Prisma.validator<Prisma.UserArgs>()({ timeZone: true, credentials: true, bufferTime: true, + destinationCalendar: true, }, }); @@ -301,6 +303,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: attendeesList, location: reqBody.location, // Will be processed by the EventManager later. language: t, + /** For team events, we will need to handle each member destinationCalendar eventually */ + destinationCalendar: users[0].destinationCalendar, }; if (eventType.schedulingType === SchedulingType.COLLECTIVE) { @@ -368,6 +372,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let referencesToCreate: PartialReference[] = []; let user: User | null = null; + /** Let's start cheking for availability */ for (const currentUser of users) { if (!currentUser) { console.error(`currentUser not found`); @@ -390,8 +395,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) selectedCalendars ); - const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time); - calendarBusyTimes.push(...(videoBusyTimes as any[])); // FIXME add types + const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty); + calendarBusyTimes.push(...videoBusyTimes); console.log("calendarBusyTimes==>>>", calendarBusyTimes); const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({ @@ -449,7 +454,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!user) throw Error("Can't continue, user not found."); // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. - const eventManager = new EventManager(await refreshCredentials(user.credentials)); + const credentials = await refreshCredentials(user.credentials); + const eventManager = new EventManager({ ...user, credentials }); if (rescheduleUid) { // Use EventManager to conditionally use all needed integrations. diff --git a/pages/team/[slug]/[type].tsx b/pages/team/[slug]/[type].tsx index 8f9e8725..7a8ac34a 100644 --- a/pages/team/[slug]/[type].tsx +++ b/pages/team/[slug]/[type].tsx @@ -46,6 +46,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => timeZone: true, hideBranding: true, plan: true, + brandColor: true, }, }, title: true, @@ -97,6 +98,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => image: team.logo, theme: null, weekStart: "Sunday", + brandColor: "" /* TODO: Add a way to set a brand color for Teams */, }, date: dateParam, eventType: eventTypeObject, diff --git a/playwright/integrations.test.ts b/playwright/integrations.test.ts index 2bca930d..86dcfa7b 100644 --- a/playwright/integrations.test.ts +++ b/playwright/integrations.test.ts @@ -37,7 +37,7 @@ describe("webhooks", () => { // --- Book the first available day next month in the pro user's "30min"-event await page.goto(`http://localhost:3000/pro/30min`); await page.click('[data-testid="incrementMonth"]'); - await page.click('[data-testid="day"]'); + await page.click('[data-testid="day"][data-disabled="false"]'); await page.click('[data-testid="time"]'); // --- fill form @@ -80,7 +80,9 @@ describe("webhooks", () => { }, ], "description": "", + "destinationCalendar": null, "endTime": "[redacted/dynamic]", + "metadata": Object {}, "organizer": Object { "email": "pro@example.com", "name": "Pro Example", diff --git a/prisma/migrations/20211207010154_add_destination_calendar/migration.sql b/prisma/migrations/20211207010154_add_destination_calendar/migration.sql new file mode 100644 index 00000000..6ab2fd19 --- /dev/null +++ b/prisma/migrations/20211207010154_add_destination_calendar/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "DestinationCalendar" ( + "id" SERIAL NOT NULL, + "integration" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "userId" INTEGER, + "bookingId" INTEGER, + "eventTypeId" INTEGER, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DestinationCalendar.userId_unique" ON "DestinationCalendar"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DestinationCalendar.bookingId_unique" ON "DestinationCalendar"("bookingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DestinationCalendar.eventTypeId_unique" ON "DestinationCalendar"("eventTypeId"); + +-- AddForeignKey +ALTER TABLE "DestinationCalendar" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DestinationCalendar" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DestinationCalendar" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba956b78..69201ca4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model EventType { teamId Int? bookings Booking[] availability Availability[] + destinationCalendar DestinationCalendar[] eventName String? customInputs EventTypeCustomInput[] timeZone String? @@ -70,39 +71,53 @@ enum UserPlan { PRO } +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + booking Booking? @relation(fields: [bookingId], references: [id]) + bookingId Int? @unique + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? @unique +} + model User { - id Int @id @default(autoincrement()) - username String? @unique + id Int @id @default(autoincrement()) + username String? @unique name String? - email String @unique + email String @unique emailVerified DateTime? password String? bio String? avatar String? - timeZone String @default("Europe/London") - weekStart String @default("Sunday") + timeZone String @default("Europe/London") + weekStart String @default("Sunday") // DEPRECATED - TO BE REMOVED - startTime Int @default(0) - endTime Int @default(1440) + startTime Int @default(0) + endTime Int @default(1440) // </DEPRECATED> - bufferTime Int @default(0) - hideBranding Boolean @default(false) + bufferTime Int @default(0) + hideBranding Boolean @default(false) theme String? - createdDate DateTime @default(now()) @map(name: "created") - eventTypes EventType[] @relation("user_eventtype") + createdDate DateTime @default(now()) @map(name: "created") + eventTypes EventType[] @relation("user_eventtype") credentials Credential[] teams Membership[] bookings Booking[] availability Availability[] selectedCalendars SelectedCalendar[] - completedOnboarding Boolean @default(false) + completedOnboarding Boolean @default(false) locale String? twoFactorSecret String? - twoFactorEnabled Boolean @default(false) - plan UserPlan @default(PRO) + twoFactorEnabled Boolean @default(false) + plan UserPlan @default(PRO) Schedule Schedule[] webhooks Webhook[] - brandColor String @default("#292929") + brandColor String @default("#292929") + // the location where the events will end up + destinationCalendar DestinationCalendar? @@map(name: "users") } @@ -181,31 +196,28 @@ model DailyEventReference { } model Booking { - id Int @id @default(autoincrement()) - uid String @unique - user User? @relation(fields: [userId], references: [id]) - userId Int? - references BookingReference[] - eventType EventType? @relation(fields: [eventTypeId], references: [id]) - eventTypeId Int? - - title String - description String? - startTime DateTime - endTime DateTime - - attendees Attendee[] - location String? - - dailyRef DailyEventReference? - - createdAt DateTime @default(now()) - updatedAt DateTime? - confirmed Boolean @default(true) - rejected Boolean @default(false) - status BookingStatus @default(ACCEPTED) - paid Boolean @default(false) - payment Payment[] + id Int @id @default(autoincrement()) + uid String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + dailyRef DailyEventReference? + createdAt DateTime @default(now()) + updatedAt DateTime? + confirmed Boolean @default(true) + rejected Boolean @default(false) + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? } model Schedule { diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index 0ad5e887..4af8c46c 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -545,6 +545,7 @@ "connect_your_favourite_apps": "Connect your favourite apps.", "automation": "Automation", "configure_how_your_event_types_interact": "Configure how your event types should interact with your calendars.", + "select_destination_calendar": "Select a destination calendar for your bookings.", "connect_an_additional_calendar": "Connect an additional calendar", "conferencing": "Conferencing", "calendar": "Calendar", diff --git a/scripts/seed.ts b/scripts/seed.ts index 089a186b..e75b453a 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -28,6 +28,7 @@ async function createUserAndEventType(opts: { password: await hashPassword(opts.user.password), emailVerified: new Date(), completedOnboarding: opts.user.completedOnboarding ?? true, + locale: "en", availability: { createMany: { data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE), diff --git a/server/createContext.ts b/server/createContext.ts index e7423984..1e68e9ff 100644 --- a/server/createContext.ts +++ b/server/createContext.ts @@ -61,6 +61,7 @@ async function getUserFromSession({ }, }, completedOnboarding: true, + destinationCalendar: true, locale: true, }, }); diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index a3cd0d5e..faec9e2a 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -376,7 +376,42 @@ const loggedInViewerRouter = createProtectedRouter() // get all the connected integrations' calendars (from third party) const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); - return connectedCalendars; + return { + connectedCalendars, + destinationCalendar: user.destinationCalendar, + }; + }, + }) + .mutation("setUserDestinationCalendar", { + input: z.object({ + integration: z.string(), + externalId: z.string(), + }), + async resolve({ ctx, input }) { + const { user } = ctx; + const userId = ctx.user.id; + const calendarCredentials = getCalendarCredentials(user.credentials, user.id); + const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); + const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); + + if ( + !allCals.find((cal) => cal.externalId === input.externalId && cal.integration === input.integration) + ) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` }); + } + await ctx.prisma.destinationCalendar.upsert({ + where: { + userId, + }, + update: { + ...input, + userId, + }, + create: { + ...input, + userId, + }, + }); }, }) .query("integrations", {