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 { useMutation } from "react-query";
|
||||||
|
import Select from "react-select";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
@ -98,9 +99,13 @@ function ConnectedCalendarsList(props: Props) {
|
||||||
<QueryCell
|
<QueryCell
|
||||||
query={query}
|
query={query}
|
||||||
empty={() => null}
|
empty={() => null}
|
||||||
success={({ data }) => (
|
success={({ data }) => {
|
||||||
|
if (!data.connectedCalendars.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<List>
|
<List>
|
||||||
{data.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.calendars ? (
|
{item.calendars ? (
|
||||||
<IntegrationListItem
|
<IntegrationListItem
|
||||||
|
@ -139,7 +144,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||||
id={item.credentialId}
|
id={item.credentialId}
|
||||||
render={(btnProps) => (
|
render={(btnProps) => (
|
||||||
<Button {...btnProps} color="warn">
|
<Button {...btnProps} color="warn">
|
||||||
{t("disconnect")}
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
onOpenChange={() => props.onChanged()}
|
onOpenChange={() => props.onChanged()}
|
||||||
|
@ -150,7 +155,68 @@ function ConnectedCalendarsList(props: Props) {
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</List>
|
</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 && (
|
{heading && (
|
||||||
<ShellSubHeading
|
<ShellSubHeading
|
||||||
className="mt-10"
|
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")}
|
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} />
|
<ConnectedCalendarsList onChanged={onChanged} />
|
||||||
{!!query.data?.length && (
|
{!!query.data?.connectedCalendars.length && (
|
||||||
<ShellSubHeading
|
<ShellSubHeading
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
|
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 Stripe from "stripe";
|
||||||
import { JsonValue } from "type-fest";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import { CalendarEvent } from "@lib/calendarClient";
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
@ -39,7 +38,7 @@ export async function handlePayment(
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
},
|
},
|
||||||
stripeCredential: { key: JsonValue },
|
stripeCredential: { key: Prisma.JsonValue },
|
||||||
booking: {
|
booking: {
|
||||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -74,7 +73,7 @@ export async function handlePayment(
|
||||||
data: Object.assign({}, paymentIntent, {
|
data: Object.assign({}, paymentIntent, {
|
||||||
stripe_publishable_key,
|
stripe_publishable_key,
|
||||||
stripeAccount: stripe_user_id,
|
stripeAccount: stripe_user_id,
|
||||||
}) as PaymentData as unknown as JsonValue,
|
}) as PaymentData as unknown as Prisma.JsonValue,
|
||||||
externalId: paymentIntent.id,
|
externalId: paymentIntent.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -103,7 +102,7 @@ export async function refund(
|
||||||
success: boolean;
|
success: boolean;
|
||||||
refunded: boolean;
|
refunded: boolean;
|
||||||
externalId: string;
|
externalId: string;
|
||||||
data: JsonValue;
|
data: Prisma.JsonValue;
|
||||||
type: PaymentType;
|
type: PaymentType;
|
||||||
}[];
|
}[];
|
||||||
},
|
},
|
||||||
|
@ -113,7 +112,7 @@ export async function refund(
|
||||||
const payment = booking.payment.find((e) => e.success && !e.refunded);
|
const payment = booking.payment.find((e) => e.success && !e.refunded);
|
||||||
if (!payment) return;
|
if (!payment) return;
|
||||||
|
|
||||||
if (payment.type != PaymentType.STRIPE) {
|
if (payment.type !== PaymentType.STRIPE) {
|
||||||
await handleRefundError({
|
await handleRefundError({
|
||||||
event: calEvent,
|
event: calEvent,
|
||||||
reason: "cannot refund non Stripe payment",
|
reason: "cannot refund non Stripe payment",
|
||||||
|
|
|
@ -57,6 +57,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
|
destinationCalendar: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -91,7 +92,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
if (booking.location) evt.location = booking.location;
|
if (booking.location) evt.location = booking.location;
|
||||||
|
|
||||||
if (booking.confirmed) {
|
if (booking.confirmed) {
|
||||||
const eventManager = new EventManager(user.credentials);
|
const eventManager = new EventManager(user);
|
||||||
const scheduleResult = await eventManager.create(evt);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
|
|
|
@ -3,6 +3,7 @@ const opts = {
|
||||||
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
|
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
|
||||||
collectCoverage: false, // not possible in Next.js 12
|
collectCoverage: false, // not possible in Next.js 12
|
||||||
executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
|
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));
|
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 calEvent.additionInformation.hangoutLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
return providerName || calEvent.location;
|
return providerName || calEvent.location || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getManageLink = (calEvent: CalendarEvent) => {
|
export const getManageLink = (calEvent: CalendarEvent) => {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
import { Credential, DestinationCalendar, SelectedCalendar } from "@prisma/client";
|
||||||
import { Credential, SelectedCalendar } from "@prisma/client";
|
|
||||||
import { TFunction } from "next-i18next";
|
import { TFunction } from "next-i18next";
|
||||||
|
|
||||||
import { PaymentInfo } from "@ee/lib/stripe/server";
|
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 { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
|
||||||
import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
|
import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
|
||||||
import {
|
import {
|
||||||
GoogleCalendarApiAdapter,
|
|
||||||
ConferenceData,
|
ConferenceData,
|
||||||
|
GoogleCalendarApiAdapter,
|
||||||
} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter";
|
} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter";
|
||||||
import {
|
import { Office365CalendarApiAdapter } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
|
||||||
Office365CalendarApiAdapter,
|
|
||||||
BufferedBusyTime,
|
|
||||||
} from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
|
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
import { VideoCallData } from "@lib/videoClient";
|
import { VideoCallData } from "@lib/videoClient";
|
||||||
|
|
||||||
|
import notEmpty from "./notEmpty";
|
||||||
import { Ensure } from "./types/utils";
|
import { Ensure } from "./types/utils";
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||||
|
@ -61,6 +58,7 @@ export interface CalendarEvent {
|
||||||
uid?: string | null;
|
uid?: string | null;
|
||||||
videoCallData?: VideoCallData;
|
videoCallData?: VideoCallData;
|
||||||
paymentInfo?: PaymentInfo | null;
|
paymentInfo?: PaymentInfo | null;
|
||||||
|
destinationCalendar?: DestinationCalendar | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
|
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
|
||||||
|
@ -68,6 +66,8 @@ export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventBusyDate = Record<"start" | "end", Date | string>;
|
||||||
|
|
||||||
export interface CalendarApiAdapter {
|
export interface CalendarApiAdapter {
|
||||||
createEvent(event: CalendarEvent): Promise<Event>;
|
createEvent(event: CalendarEvent): Promise<Event>;
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ export interface CalendarApiAdapter {
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string,
|
dateTo: string,
|
||||||
selectedCalendars: IntegrationCalendar[]
|
selectedCalendars: IntegrationCalendar[]
|
||||||
): Promise<BufferedBusyTime[]>;
|
): Promise<EventBusyDate[]>;
|
||||||
|
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
@ -98,72 +98,32 @@ function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter |
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const getBusyCalendarTimes = async (
|
||||||
* @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 = (
|
|
||||||
withCredentials: Credential[],
|
withCredentials: Credential[],
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string,
|
dateTo: string,
|
||||||
selectedCalendars: SelectedCalendar[]
|
selectedCalendars: SelectedCalendar[]
|
||||||
) =>
|
) => {
|
||||||
Promise.all(
|
const adapters = withCredentials.map(getCalendarAdapterOrNull).filter(notEmpty);
|
||||||
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
const results = await Promise.all(
|
||||||
).then((results) => {
|
adapters.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||||
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)
|
|
||||||
);
|
);
|
||||||
|
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||||
|
};
|
||||||
|
|
||||||
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
||||||
const uid: string = getUid(calEvent);
|
const uid: string = getUid(calEvent);
|
||||||
|
const adapter = getCalendarAdapterOrNull(credential);
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const creationResult = credential
|
const creationResult = adapter
|
||||||
? await calendars([credential])[0]
|
? await adapter.createEvent(calEvent).catch((e) => {
|
||||||
.createEvent(calEvent)
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("createEvent failed", e, calEvent);
|
log.error("createEvent failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!creationResult) {
|
|
||||||
return {
|
|
||||||
type: credential.type,
|
|
||||||
success,
|
|
||||||
uid,
|
|
||||||
originalEvent: calEvent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
success,
|
success,
|
||||||
|
@ -179,28 +139,18 @@ const updateEvent = async (
|
||||||
bookingRefUid: string | null
|
bookingRefUid: string | null
|
||||||
): Promise<EventResult> => {
|
): Promise<EventResult> => {
|
||||||
const uid = getUid(calEvent);
|
const uid = getUid(calEvent);
|
||||||
|
const adapter = getCalendarAdapterOrNull(credential);
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const updatedResult =
|
const updatedResult =
|
||||||
credential && bookingRefUid
|
adapter && bookingRefUid
|
||||||
? await calendars([credential])[0]
|
? await adapter.updateEvent(bookingRefUid, calEvent).catch((e) => {
|
||||||
.updateEvent(bookingRefUid, calEvent)
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("updateEvent failed", e, calEvent);
|
log.error("updateEvent failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!updatedResult) {
|
|
||||||
return {
|
|
||||||
type: credential.type,
|
|
||||||
success,
|
|
||||||
uid,
|
|
||||||
originalEvent: calEvent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
success,
|
success,
|
||||||
|
@ -211,18 +161,12 @@ const updateEvent = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
if (credential) {
|
const adapter = getCalendarAdapterOrNull(credential);
|
||||||
return calendars([credential])[0].deleteEvent(uid);
|
if (adapter) {
|
||||||
|
return adapter.deleteEvent(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, getCalendarAdapterOrNull };
|
||||||
getBusyCalendarTimes,
|
|
||||||
createEvent,
|
|
||||||
updateEvent,
|
|
||||||
deleteEvent,
|
|
||||||
listCalendars,
|
|
||||||
getCalendarAdapterOrNull,
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential, DestinationCalendar } from "@prisma/client";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import merge from "lodash/merge";
|
import merge from "lodash/merge";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
@ -86,18 +86,22 @@ export const processLocation = (event: CalendarEvent): CalendarEvent => {
|
||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EventManagerUser = {
|
||||||
|
credentials: Credential[];
|
||||||
|
destinationCalendar: DestinationCalendar | null;
|
||||||
|
};
|
||||||
export default class EventManager {
|
export default class EventManager {
|
||||||
calendarCredentials: Array<Credential>;
|
calendarCredentials: Credential[];
|
||||||
videoCredentials: Array<Credential>;
|
videoCredentials: Credential[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes an array of credentials and initializes a new instance of the EventManager.
|
* Takes an array of credentials and initializes a new instance of the EventManager.
|
||||||
*
|
*
|
||||||
* @param credentials
|
* @param credentials
|
||||||
*/
|
*/
|
||||||
constructor(credentials: Array<Credential>) {
|
constructor(user: EventManagerUser) {
|
||||||
this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
this.calendarCredentials = user.credentials.filter((cred) => cred.type.endsWith("_calendar"));
|
||||||
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
|
this.videoCredentials = user.credentials.filter((cred) => cred.type.endsWith("_video"));
|
||||||
|
|
||||||
//for Daily.co video, temporarily pushes a credential for the daily-video-client
|
//for Daily.co video, temporarily pushes a credential for the daily-video-client
|
||||||
const hasDailyIntegration = process.env.DAILY_API_KEY;
|
const hasDailyIntegration = process.env.DAILY_API_KEY;
|
||||||
|
@ -180,6 +184,7 @@ export default class EventManager {
|
||||||
meetingUrl: true,
|
meetingUrl: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
destinationCalendar: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -194,6 +199,7 @@ export default class EventManager {
|
||||||
const result = await this.updateVideoEvent(evt, booking);
|
const result = await this.updateVideoEvent(evt, booking);
|
||||||
if (result.updatedEvent) {
|
if (result.updatedEvent) {
|
||||||
evt.videoCallData = result.updatedEvent;
|
evt.videoCallData = result.updatedEvent;
|
||||||
|
evt.location = result.updatedEvent.url;
|
||||||
}
|
}
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
|
@ -240,13 +246,21 @@ export default class EventManager {
|
||||||
* @param noMail
|
* @param noMail
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private async createAllCalendarEvents(event: CalendarEvent): Promise<Array<EventResult>> {
|
private async createAllCalendarEvents(event: CalendarEvent): Promise<Array<EventResult>> {
|
||||||
const [firstCalendar] = this.calendarCredentials;
|
/** Can I use destinationCalendar here? */
|
||||||
if (!firstCalendar) {
|
/* 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 [];
|
||||||
}
|
}
|
||||||
return [await createEvent(firstCalendar, event)];
|
return [await createEvent(credential, event)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,346 +1,10 @@
|
||||||
import { Credential } from "@prisma/client";
|
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 { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter";
|
||||||
import { symmetricDecrypt } from "@lib/crypto";
|
import { CalendarApiAdapter } from "@lib/calendarClient";
|
||||||
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";
|
|
||||||
|
|
||||||
|
export class AppleCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter {
|
||||||
constructor(credential: Credential) {
|
constructor(credential: Credential) {
|
||||||
const decryptedCredential = JSON.parse(
|
super(credential, "apple_calendar", "https://caldav.icloud.com");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,347 +1,10 @@
|
||||||
import { Credential } from "@prisma/client";
|
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 { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter";
|
||||||
import { symmetricDecrypt } from "@lib/crypto";
|
import { CalendarApiAdapter } from "@lib/calendarClient";
|
||||||
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";
|
|
||||||
|
|
||||||
|
export class CalDavCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter {
|
||||||
constructor(credential: Credential) {
|
constructor(credential: Credential) {
|
||||||
const decryptedCredential = JSON.parse(
|
super(credential, "caldav_calendar");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,18 +10,30 @@ export interface ConferenceData {
|
||||||
createRequest?: calendar_v3.Schema$CreateConferenceRequest;
|
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 googleAuth = (credential: Credential) => {
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
|
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;
|
const googleCredentials = credential.key as Auth.Credentials;
|
||||||
myGoogleAuth.setCredentials(googleCredentials);
|
myGoogleAuth.setCredentials(googleCredentials);
|
||||||
|
|
||||||
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
|
||||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||||
|
|
||||||
const refreshAccessToken = () =>
|
const refreshAccessToken = () =>
|
||||||
myGoogleAuth
|
myGoogleAuth
|
||||||
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
|
||||||
.refreshToken(googleCredentials.refresh_token)
|
.refreshToken(googleCredentials.refresh_token)
|
||||||
.then((res: GetTokenResponse) => {
|
.then((res: GetTokenResponse) => {
|
||||||
const token = res.res?.data;
|
const token = res.res?.data;
|
||||||
|
@ -149,7 +161,9 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda
|
||||||
calendar.events.insert(
|
calendar.events.insert(
|
||||||
{
|
{
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: "primary",
|
calendarId: event.destinationCalendar?.externalId
|
||||||
|
? event.destinationCalendar.externalId
|
||||||
|
: "primary",
|
||||||
requestBody: payload,
|
requestBody: payload,
|
||||||
conferenceDataVersion: 1,
|
conferenceDataVersion: 1,
|
||||||
},
|
},
|
||||||
|
@ -201,7 +215,9 @@ export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAda
|
||||||
calendar.events.update(
|
calendar.events.update(
|
||||||
{
|
{
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: "primary",
|
calendarId: event.destinationCalendar?.externalId
|
||||||
|
? event.destinationCalendar.externalId
|
||||||
|
: "primary",
|
||||||
eventId: uid,
|
eventId: uid,
|
||||||
sendNotifications: true,
|
sendNotifications: true,
|
||||||
sendUpdates: "all",
|
sendUpdates: "all",
|
||||||
|
|
|
@ -182,16 +182,19 @@ export const Office365CalendarApiAdapter = (credential: Credential): CalendarApi
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
createEvent: (event: CalendarEvent) =>
|
createEvent: (event: CalendarEvent) =>
|
||||||
auth.getToken().then((accessToken) =>
|
auth.getToken().then((accessToken) => {
|
||||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
|
const calendarId = event.destinationCalendar?.externalId
|
||||||
|
? `${event.destinationCalendar.externalId}/`
|
||||||
|
: "";
|
||||||
|
return fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer " + accessToken,
|
Authorization: "Bearer " + accessToken,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(translateEvent(event)),
|
body: JSON.stringify(translateEvent(event)),
|
||||||
}).then(handleErrorsJson)
|
}).then(handleErrorsJson);
|
||||||
),
|
}),
|
||||||
deleteEvent: (uid: string) =>
|
deleteEvent: (uid: string) =>
|
||||||
auth.getToken().then((accessToken) =>
|
auth.getToken().then((accessToken) =>
|
||||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
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;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) =>
|
const getBusyVideoTimes = (withCredentials: Credential[]) =>
|
||||||
Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
||||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,6 +20,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { user, eventTypes } = props;
|
const { user, eventTypes } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
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 || "";
|
const nameOrUsername = user.name || user.username || "";
|
||||||
|
|
||||||
|
@ -54,9 +56,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/${user.username}/${type.slug}`,
|
pathname: `/${user.username}/${type.slug}`,
|
||||||
query: {
|
query,
|
||||||
...router.query,
|
|
||||||
},
|
|
||||||
}}>
|
}}>
|
||||||
<a className="block px-6 py-4" data-testid="event-type-link">
|
<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>
|
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import notEmpty from "@lib/notEmpty";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import { IntegrationCalendar, listCalendars } from "../../../lib/calendarClient";
|
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
|
||||||
import prisma from "../../../lib/prisma";
|
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req });
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
},
|
},
|
||||||
|
@ -21,25 +23,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
credentials: true,
|
credentials: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
selectedCalendars: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!user) {
|
||||||
res.status(401).json({ message: "Not authenticated" });
|
res.status(401).json({ message: "Not authenticated" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method == "POST") {
|
if (req.method === "POST") {
|
||||||
await prisma.selectedCalendar.upsert({
|
await prisma.selectedCalendar.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_integration_externalId: {
|
userId_integration_externalId: {
|
||||||
userId: currentUser.id,
|
userId: user.id,
|
||||||
integration: req.body.integration,
|
integration: req.body.integration,
|
||||||
externalId: req.body.externalId,
|
externalId: req.body.externalId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: currentUser.id,
|
userId: user.id,
|
||||||
integration: req.body.integration,
|
integration: req.body.integration,
|
||||||
externalId: req.body.externalId,
|
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" });
|
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method == "DELETE") {
|
if (req.method === "DELETE") {
|
||||||
await prisma.selectedCalendar.delete({
|
await prisma.selectedCalendar.delete({
|
||||||
where: {
|
where: {
|
||||||
userId_integration_externalId: {
|
userId_integration_externalId: {
|
||||||
userId: currentUser.id,
|
userId: user.id,
|
||||||
externalId: req.body.externalId,
|
externalId: req.body.externalId,
|
||||||
integration: req.body.integration,
|
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" });
|
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method == "GET") {
|
if (req.method === "GET") {
|
||||||
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
externalId: true,
|
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) => {
|
const selectableCalendars = calendars.map((cal) => {
|
||||||
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { refund } from "@ee/lib/stripe/server";
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
@ -70,6 +70,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
username: 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" });
|
return res.status(404).json({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method == "PATCH") {
|
if (req.method === "PATCH") {
|
||||||
const booking = await prisma.booking.findFirst({
|
const booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: bookingId,
|
id: bookingId,
|
||||||
|
@ -128,7 +129,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reqBody.confirmed) {
|
if (reqBody.confirmed) {
|
||||||
const eventManager = new EventManager(currentUser.credentials);
|
const eventManager = new EventManager(currentUser);
|
||||||
const scheduleResult = await eventManager.create(evt);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
const results = scheduleResult.results;
|
const results = scheduleResult.results;
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { getEventName } from "@lib/event";
|
||||||
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
|
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
|
||||||
import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
|
import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
import notEmpty from "@lib/notEmpty";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { BookingCreateBody } from "@lib/types/booking";
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
import { getBusyVideoTimes } from "@lib/videoClient";
|
import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
|
@ -133,6 +134,7 @@ const userSelect = Prisma.validator<Prisma.UserArgs>()({
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
bufferTime: true,
|
bufferTime: true,
|
||||||
|
destinationCalendar: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -301,6 +303,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
location: reqBody.location, // Will be processed by the EventManager later.
|
location: reqBody.location, // Will be processed by the EventManager later.
|
||||||
language: t,
|
language: t,
|
||||||
|
/** For team events, we will need to handle each member destinationCalendar eventually */
|
||||||
|
destinationCalendar: users[0].destinationCalendar,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
||||||
|
@ -368,6 +372,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
let referencesToCreate: PartialReference[] = [];
|
let referencesToCreate: PartialReference[] = [];
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
|
|
||||||
|
/** Let's start cheking for availability */
|
||||||
for (const currentUser of users) {
|
for (const currentUser of users) {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
console.error(`currentUser not found`);
|
console.error(`currentUser not found`);
|
||||||
|
@ -390,8 +395,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
selectedCalendars
|
selectedCalendars
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time);
|
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
|
||||||
calendarBusyTimes.push(...(videoBusyTimes as any[])); // FIXME add types
|
calendarBusyTimes.push(...videoBusyTimes);
|
||||||
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
|
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
|
||||||
|
|
||||||
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
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.");
|
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.
|
// 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) {
|
if (rescheduleUid) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
|
|
|
@ -46,6 +46,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
|
brandColor: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
title: true,
|
title: true,
|
||||||
|
@ -97,6 +98,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
image: team.logo,
|
image: team.logo,
|
||||||
theme: null,
|
theme: null,
|
||||||
weekStart: "Sunday",
|
weekStart: "Sunday",
|
||||||
|
brandColor: "" /* TODO: Add a way to set a brand color for Teams */,
|
||||||
},
|
},
|
||||||
date: dateParam,
|
date: dateParam,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
|
|
|
@ -37,7 +37,7 @@ describe("webhooks", () => {
|
||||||
// --- Book the first available day next month in the pro user's "30min"-event
|
// --- Book the first available day next month in the pro user's "30min"-event
|
||||||
await page.goto(`http://localhost:3000/pro/30min`);
|
await page.goto(`http://localhost:3000/pro/30min`);
|
||||||
await page.click('[data-testid="incrementMonth"]');
|
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"]');
|
await page.click('[data-testid="time"]');
|
||||||
|
|
||||||
// --- fill form
|
// --- fill form
|
||||||
|
@ -80,7 +80,9 @@ describe("webhooks", () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"destinationCalendar": null,
|
||||||
"endTime": "[redacted/dynamic]",
|
"endTime": "[redacted/dynamic]",
|
||||||
|
"metadata": Object {},
|
||||||
"organizer": Object {
|
"organizer": Object {
|
||||||
"email": "pro@example.com",
|
"email": "pro@example.com",
|
||||||
"name": "Pro Example",
|
"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?
|
teamId Int?
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
availability Availability[]
|
availability Availability[]
|
||||||
|
destinationCalendar DestinationCalendar[]
|
||||||
eventName String?
|
eventName String?
|
||||||
customInputs EventTypeCustomInput[]
|
customInputs EventTypeCustomInput[]
|
||||||
timeZone String?
|
timeZone String?
|
||||||
|
@ -70,6 +71,18 @@ enum UserPlan {
|
||||||
PRO
|
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 {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String? @unique
|
username String? @unique
|
||||||
|
@ -103,6 +116,8 @@ model User {
|
||||||
Schedule Schedule[]
|
Schedule Schedule[]
|
||||||
webhooks Webhook[]
|
webhooks Webhook[]
|
||||||
brandColor String @default("#292929")
|
brandColor String @default("#292929")
|
||||||
|
// the location where the events will end up
|
||||||
|
destinationCalendar DestinationCalendar?
|
||||||
|
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
}
|
}
|
||||||
|
@ -188,17 +203,13 @@ model Booking {
|
||||||
references BookingReference[]
|
references BookingReference[]
|
||||||
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
eventType EventType? @relation(fields: [eventTypeId], references: [id])
|
||||||
eventTypeId Int?
|
eventTypeId Int?
|
||||||
|
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
startTime DateTime
|
startTime DateTime
|
||||||
endTime DateTime
|
endTime DateTime
|
||||||
|
|
||||||
attendees Attendee[]
|
attendees Attendee[]
|
||||||
location String?
|
location String?
|
||||||
|
|
||||||
dailyRef DailyEventReference?
|
dailyRef DailyEventReference?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime?
|
updatedAt DateTime?
|
||||||
confirmed Boolean @default(true)
|
confirmed Boolean @default(true)
|
||||||
|
@ -206,6 +217,7 @@ model Booking {
|
||||||
status BookingStatus @default(ACCEPTED)
|
status BookingStatus @default(ACCEPTED)
|
||||||
paid Boolean @default(false)
|
paid Boolean @default(false)
|
||||||
payment Payment[]
|
payment Payment[]
|
||||||
|
destinationCalendar DestinationCalendar?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
|
|
|
@ -545,6 +545,7 @@
|
||||||
"connect_your_favourite_apps": "Connect your favourite apps.",
|
"connect_your_favourite_apps": "Connect your favourite apps.",
|
||||||
"automation": "Automation",
|
"automation": "Automation",
|
||||||
"configure_how_your_event_types_interact": "Configure how your event types should interact with your calendars.",
|
"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",
|
"connect_an_additional_calendar": "Connect an additional calendar",
|
||||||
"conferencing": "Conferencing",
|
"conferencing": "Conferencing",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
|
|
|
@ -28,6 +28,7 @@ async function createUserAndEventType(opts: {
|
||||||
password: await hashPassword(opts.user.password),
|
password: await hashPassword(opts.user.password),
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
completedOnboarding: opts.user.completedOnboarding ?? true,
|
completedOnboarding: opts.user.completedOnboarding ?? true,
|
||||||
|
locale: "en",
|
||||||
availability: {
|
availability: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||||
|
|
|
@ -61,6 +61,7 @@ async function getUserFromSession({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
completedOnboarding: true,
|
completedOnboarding: true,
|
||||||
|
destinationCalendar: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -376,7 +376,42 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
// get all the connected integrations' calendars (from third party)
|
// get all the connected integrations' calendars (from third party)
|
||||||
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
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", {
|
.query("integrations", {
|
||||||
|
|
Loading…
Reference in a new issue