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:
parent
1890d5daf7
commit
850497ea80
26 changed files with 745 additions and 917 deletions
|
@ -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")} />}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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));
|
||||
|
|
349
lib/BaseCalendarApiAdapter.ts
Normal file
349
lib/BaseCalendarApiAdapter.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
3
lib/notEmpty.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
const notEmpty = <T>(value: T): value is NonNullable<typeof value> => !!value;
|
||||
|
||||
export default notEmpty;
|
|
@ -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), [])
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -61,6 +61,7 @@ async function getUserFromSession({
|
|||
},
|
||||
},
|
||||
completedOnboarding: true,
|
||||
destinationCalendar: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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", {
|
||||
|
|
Loading…
Reference in a new issue