add select primary calendar (#1133)

* add primary

* fix

* refactor eventmanager to take `CalendarDestination`

* `DestinationCalendar`

* fix

* wip

* wip

* Minor fixes (#1156)

* Followup for #1242

* Updates schema

* Renames fields to destinationCalendar

* Migration fixes

* Updates user destination calendar

* Abstracts convertDate to BaseCalendarApiAdapter

* Type fixes

* Uses abstracted convertDate method

* Abstracts getDuration and getAttendees

* Fixes circular dependecy issue

* Adds notEmpty util

* Reverts empty location string

* Fixes property name

* Removes deprecated code

* WIP

* AppleCal is basically CalDav

* Fixes missing destinationCalendar

* Type fixes

* Select primary calendar on Office and gCal

* Adds pretty basic instructions for destination calendar

* Cleanup

* Type fix

* Test fixes

* Updates test snapshot

* Local test fixes

* Type fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Alex Johansson 2021-12-09 16:51:37 +01:00 committed by GitHub
parent 1890d5daf7
commit 850497ea80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 745 additions and 917 deletions

View File

@ -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")} />}

View File

@ -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",

View File

@ -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({

View File

@ -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));

View File

@ -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;
}
}

View File

@ -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) => {

View File

@ -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 };

View File

@ -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)];
}
/**

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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",

View File

@ -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, {

3
lib/notEmpty.ts Normal file
View File

@ -0,0 +1,3 @@
const notEmpty = <T>(value: T): value is NonNullable<typeof value> => !!value;
export default notEmpty;

View File

@ -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), [])
);

View File

@ -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>

View File

@ -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 };
});

View File

@ -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;

View File

@ -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.

View File

@ -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,

View File

@ -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",

View File

@ -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;

View File

@ -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 {

View File

@ -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",

View File

@ -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),

View File

@ -61,6 +61,7 @@ async function getUserFromSession({
},
},
completedOnboarding: true,
destinationCalendar: true,
locale: true,
},
});

View File

@ -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", {