[CAL-770] add new integration architecture revamp (#1369)

* [CAL-770] add new integration architecture revamp

* Type fixes

* Type fixes

* [CAL-770] Remove tsconfig.tsbuildinfo

* [CAL-770] add integration test

* Improve google calendar test integration

* Remove console.log

* Change response any to void in the deleteEvent method

* Remove unnecesary const

* Add tsconfig.tsbuildinfo to the .gitignore

* Remove process env variables as const

Co-authored-by: Edward Fernández <edwardfernandez@Edwards-Mac-mini.local>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Edward Fernandez <edward.fernandez@rappi.com>
This commit is contained in:
Edward Fernández 2022-01-06 12:28:31 -05:00 committed by GitHub
parent 8a70ea66e9
commit bd2a795d7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1098 additions and 945 deletions

2
.gitignore vendored
View File

@ -56,3 +56,5 @@ yarn-error.log*
# Local History for Visual Studio Code
.history/
# Typescript
tsconfig.tsbuildinfo

View File

@ -270,7 +270,7 @@ Contributions are what make the open source community such an amazing place to b
3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)**
4. Set the **Web** redirect URI to `<Cal.com URL>/api/integrations/office365calendar/callback` replacing Cal.com URL with the URI at which your application runs.
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
## Obtaining Zoom Client ID and Secret

View File

@ -2,8 +2,8 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types
import { useState } from "react";
import { useMutation } from "react-query";
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
import { ButtonBaseProps } from "@components/ui/Button";

View File

@ -2,9 +2,9 @@ import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import { CalendarEvent } from "@lib/calendarClient";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { createPaymentLink } from "./client";
@ -77,7 +77,7 @@ export async function handlePayment(
data: Object.assign({}, paymentIntent, {
stripe_publishable_key,
stripeAccount: stripe_user_id,
}) as PaymentData as unknown as Prisma.JsonValue,
}) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
externalId: paymentIntent.id,
},
});

View File

@ -4,11 +4,11 @@ import Stripe from "stripe";
import stripe from "@ee/lib/stripe/server";
import { CalendarEvent } from "@lib/calendarClient";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { getErrorFromUnknown } from "@lib/errors";
import EventManager from "@lib/events/EventManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { Ensure } from "@lib/types/utils";

View File

@ -1,10 +1,11 @@
import { Person } from "ics";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent, Person } from "./calendarClient";
import { BASE_URL } from "./config/constants";
import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
const translator = short();

View File

@ -1,172 +0,0 @@
import { Credential, DestinationCalendar, SelectedCalendar } from "@prisma/client";
import { TFunction } from "next-i18next";
import { PaymentInfo } from "@ee/lib/stripe/server";
import { getUid } from "@lib/CalEventParser";
import { Event, EventResult } from "@lib/events/EventManager";
import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
import {
ConferenceData,
GoogleCalendarApiAdapter,
} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter";
import { Office365CalendarApiAdapter } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
import logger from "@lib/logger";
import { VideoCallData } from "@lib/videoClient";
import notEmpty from "./notEmpty";
import { Ensure } from "./types/utils";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
export type Person = { name: string; email: string; timeZone: string };
export interface EntryPoint {
entryPointType?: string;
uri?: string;
label?: string;
pin?: string;
accessCode?: string;
meetingCode?: string;
passcode?: string;
password?: string;
}
export interface AdditionInformation {
conferenceData?: ConferenceData;
entryPoints?: EntryPoint[];
hangoutLink?: string;
}
export interface CalendarEvent {
type: string;
title: string;
startTime: string;
endTime: string;
description?: string | null;
team?: {
name: string;
members: string[];
};
location?: string | null;
organizer: Person;
attendees: Person[];
conferenceData?: ConferenceData;
language: TFunction;
additionInformation?: AdditionInformation;
uid?: string | null;
videoCallData?: VideoCallData;
paymentInfo?: PaymentInfo | null;
destinationCalendar?: DestinationCalendar | null;
}
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
primary?: boolean;
name?: string;
}
type EventBusyDate = Record<"start" | "end", Date | string>;
export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<Event>;
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
deleteEvent(uid: string): Promise<unknown>;
getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]>;
listCalendars(): Promise<IntegrationCalendar[]>;
}
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
switch (credential.type) {
case "google_calendar":
return GoogleCalendarApiAdapter(credential);
case "office365_calendar":
return Office365CalendarApiAdapter(credential);
case "caldav_calendar":
return new CalDavCalendar(credential);
case "apple_calendar":
return new AppleCalendar(credential);
}
return null;
}
const getBusyCalendarTimes = async (
withCredentials: Credential[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
) => {
const adapters = withCredentials.map(getCalendarAdapterOrNull).filter(notEmpty);
const results = await Promise.all(
adapters.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
);
return results.reduce((acc, availability) => acc.concat(availability), []);
};
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
const uid: string = getUid(calEvent);
const adapter = getCalendarAdapterOrNull(credential);
let success = true;
const creationResult = adapter
? await adapter.createEvent(calEvent).catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
createdEvent: creationResult,
originalEvent: calEvent,
};
};
const updateEvent = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRefUid: string | null
): Promise<EventResult> => {
const uid = getUid(calEvent);
const adapter = getCalendarAdapterOrNull(credential);
let success = true;
const updatedResult =
adapter && bookingRefUid
? await adapter.updateEvent(bookingRefUid, calEvent).catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
};
};
const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
const adapter = getCalendarAdapterOrNull(credential);
if (adapter) {
return adapter.deleteEvent(uid);
}
return Promise.resolve({});
};
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, getCalendarAdapterOrNull };

View File

@ -1,4 +1,3 @@
import { CalendarEvent } from "@lib/calendarClient";
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email";
@ -12,6 +11,7 @@ import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-reque
import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend = [];

View File

@ -3,13 +3,13 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import { createEvent, DateArray, Person } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { serverConfig } from "@lib/serverConfig";
import {

View File

@ -3,13 +3,13 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import { createEvent, DateArray, Person } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { serverConfig } from "@lib/serverConfig";
import {

View File

@ -3,8 +3,9 @@ import async from "async";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { createEvent, updateEvent } from "@lib/integrations/calendar/CalendarManager";
import { AdditionInformation, CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import { LocationType } from "@lib/location";
import prisma from "@lib/prisma";
import { Ensure } from "@lib/types/utils";

View File

@ -1,10 +0,0 @@
import { Credential } from "@prisma/client";
import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter";
import { CalendarApiAdapter } from "@lib/calendarClient";
export class AppleCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter {
constructor(credential: Credential) {
super(credential, "apple_calendar", "https://caldav.icloud.com");
}
}

View File

@ -1,10 +0,0 @@
import { Credential } from "@prisma/client";
import { BaseCalendarApiAdapter } from "@lib/BaseCalendarApiAdapter";
import { CalendarApiAdapter } from "@lib/calendarClient";
export class CalDavCalendar extends BaseCalendarApiAdapter implements CalendarApiAdapter {
constructor(credential: Credential) {
super(credential, "caldav_calendar");
}
}

View File

@ -1,12 +1,13 @@
import { Credential } from "@prisma/client";
import { CalendarEvent } from "@lib/calendarClient";
import { BASE_URL } from "@lib/config/constants";
import { handleErrorsJson } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import prisma from "@lib/prisma";
import { VideoApiAdapter, VideoCallData } from "@lib/videoClient";
import { CalendarEvent } from "../calendar/interfaces/Calendar";
export interface DailyReturnType {
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
id: string;

View File

@ -1,290 +0,0 @@
import { Credential, Prisma } from "@prisma/client";
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
import { Auth, calendar_v3, google } from "googleapis";
import { getLocation, getRichDescription } from "@lib/CalEventParser";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient";
import prisma from "@lib/prisma";
export interface ConferenceData {
createRequest?: calendar_v3.Schema$CreateConferenceRequest;
}
class MyGoogleAuth extends google.auth.OAuth2 {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
isTokenExpiring() {
return super.isTokenExpiring();
}
async refreshToken(token: string | null | undefined) {
return super.refreshToken(token);
}
}
const googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
const isExpired = () => myGoogleAuth.isTokenExpiring();
const refreshAccessToken = () =>
myGoogleAuth
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
})
.catch((err) => {
console.error("Error refreshing google token", err);
return myGoogleAuth;
});
return {
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
};
};
export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAdapter => {
const auth = googleAuth(credential);
const integrationType = "google_calendar";
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) {
reject(err);
}
let result: Prisma.PromiseReturnType<CalendarApiAdapter["getAvailability"]> = [];
if (apires?.data.calendars) {
result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as typeof result);
}
resolve(result);
}
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = getLocation(event);
}
if (event.conferenceData && event.location === "integrations:google:meet") {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve({
...event.data,
id: event.data.id || "",
hangoutLink: event.data.hangoutLink || "",
type: "google_calendar",
password: "",
url: "",
});
}
);
})
),
updateEvent: (uid: string, event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: true,
},
};
if (event.location) {
payload["location"] = getLocation(event);
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
deleteEvent: (uid: string) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
listCalendars: () =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: integrationType,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
};
};

View File

@ -1,220 +0,0 @@
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential } from "@prisma/client";
import { getLocation, getRichDescription } from "@lib/CalEventParser";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import prisma from "@lib/prisma";
export type BufferedBusyTime = {
start: string;
end: string;
};
type O365AuthCredentials = {
expiry_date: number;
access_token: string;
refresh_token: string;
};
const o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: process.env.MS_GRAPH_CLIENT_ID!,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET!,
}),
})
.then(handleErrorsJson)
.then((responseBody) => {
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
})
.then(() => o365AuthCredentials.access_token);
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
export const Office365CalendarApiAdapter = (credential: Credential): CalendarApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
body: {
contentType: "HTML",
content: getRichDescription(event),
},
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
location: event.location ? { displayName: getLocation(event) } : undefined,
};
};
const integrationType = "office365_calendar";
function listCalendars(): Promise<IntegrationCalendar[]> {
return auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
})
.then(handleErrorsJson)
.then((responseBody: { value: OfficeCalendar[] }) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: integrationType,
name: cal.name ?? "No calendar name",
primary: cal.isDefaultCalendar ?? false,
};
return calendar;
});
})
);
}
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const dateFromParsed = new Date(dateFrom);
const dateToParsed = new Date(dateTo);
const filter = `?startdatetime=${encodeURIComponent(
dateFromParsed.toISOString()
)}&enddatetime=${encodeURIComponent(dateToParsed.toISOString())}`;
return auth
.getToken()
.then((accessToken) => {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId)
.filter(Boolean);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
return (
selectedCalendarIds.length === 0
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
).then((ids) => {
const requests = ids.map((calendarId, id) => ({
id,
method: "GET",
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
type BatchResponse = {
responses: SubResponse[];
};
type SubResponse = {
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
};
return fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
})
.then(handleErrorsJson)
.then((responseBody: BatchResponse) =>
responseBody.responses.reduce(
(acc: BufferedBusyTime[], subResponse) =>
acc.concat(
subResponse.body.value.map((evt) => {
return {
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
};
})
),
[]
)
);
});
})
.catch((err) => {
console.log(err);
return Promise.reject([]);
});
},
createEvent: (event: CalendarEvent) =>
auth.getToken().then((accessToken) => {
const calendarId = event.destinationCalendar?.externalId
? `${event.destinationCalendar.externalId}/`
: "";
return fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson);
}),
deleteEvent: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw)
),
updateEvent: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
listCalendars,
};
};

View File

@ -1,11 +1,12 @@
import { Credential } from "@prisma/client";
import { CalendarEvent } from "@lib/calendarClient";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import prisma from "@lib/prisma";
import { VideoApiAdapter, VideoCallData } from "@lib/videoClient";
import { CalendarEvent } from "../calendar/interfaces/Calendar";
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
export interface ZoomEventResult {
password: string;

View File

@ -0,0 +1,177 @@
import { Credential, SelectedCalendar } from "@prisma/client";
import _ from "lodash";
import { getUid } from "@lib/CalEventParser";
import { getErrorFromUnknown } from "@lib/errors";
import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger";
import notEmpty from "@lib/notEmpty";
import { ALL_INTEGRATIONS } from "../getIntegrations";
import { CALENDAR_INTEGRATIONS_TYPES } from "./constants/generals";
import { CalendarServiceType } from "./constants/types";
import { Calendar, CalendarEvent } from "./interfaces/Calendar";
import AppleCalendarService from "./services/AppleCalendarService";
import CalDavCalendarService from "./services/CalDavCalendarService";
import GoogleCalendarService from "./services/GoogleCalendarService";
import Office365CalendarService from "./services/Office365CalendarService";
const CALENDARS: Record<string, CalendarServiceType> = {
[CALENDAR_INTEGRATIONS_TYPES.apple]: AppleCalendarService,
[CALENDAR_INTEGRATIONS_TYPES.caldav]: CalDavCalendarService,
[CALENDAR_INTEGRATIONS_TYPES.google]: GoogleCalendarService,
[CALENDAR_INTEGRATIONS_TYPES.office365]: Office365CalendarService,
};
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
export const getCalendar = (credential: Credential): Calendar | null => {
const { type: calendarType } = credential;
const calendar = CALENDARS[calendarType];
if (!calendar) {
log.warn(`calendar of type ${calendarType} does not implemented`);
return null;
}
return new calendar(credential);
};
export const getCalendarCredentials = (credentials: Array<Omit<Credential, "userId">>, userId: number) => {
const calendarCredentials = credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.flatMap((credential) => {
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
const calendar = getCalendar({
...credential,
userId,
});
return integration && calendar && integration.variant === "calendar"
? [{ integration, credential, calendar }]
: [];
});
return calendarCredentials;
};
export const getConnectedCalendars = async (
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
selectedCalendars: { externalId: string }[]
) => {
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
const { calendar, integration, credential } = item;
const credentialId = credential.id;
try {
const cals = await calendar.listCalendars();
const calendars = _(cals)
.map((cal) => ({
...cal,
primary: cal.primary || null,
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
}))
.sortBy(["primary"])
.value();
const primary = calendars.find((item) => item.primary) ?? calendars[0];
if (!primary) {
throw new Error("No primary calendar found");
}
return {
integration,
credentialId,
primary,
calendars,
};
} catch (_error) {
const error = getErrorFromUnknown(_error);
return {
integration,
credentialId,
error: {
message: error.message,
},
};
}
})
);
return connectedCalendars;
};
export const getBusyCalendarTimes = async (
withCredentials: Credential[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
) => {
const calendars = withCredentials
.filter((credential) => credential.type.endsWith("_calendar"))
.map((credential) => getCalendar(credential))
.filter(notEmpty);
const results = await Promise.all(
calendars.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
);
return results.reduce((acc, availability) => acc.concat(availability), []);
};
export const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
const uid: string = getUid(calEvent);
const calendar = getCalendar(credential);
let success = true;
const creationResult = calendar
? await calendar.createEvent(calEvent).catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
createdEvent: creationResult,
originalEvent: calEvent,
};
};
export const updateEvent = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRefUid: string | null
): Promise<EventResult> => {
const uid = getUid(calEvent);
const calendar = getCalendar(credential);
let success = true;
const updatedResult =
calendar && bookingRefUid
? await calendar.updateEvent(bookingRefUid, calEvent).catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
};
};
export const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
const calendar = getCalendar(credential);
if (calendar) {
return calendar.deleteEvent(uid);
}
return Promise.resolve({});
};

View File

@ -45,7 +45,7 @@ export function AddAppleIntegrationModal(props: DialogProps) {
<Form
form={form}
onSubmit={form.handleSubmit(async (values) => {
handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/apple/add", {
method: "POST",
@ -60,7 +60,7 @@ export function AddAppleIntegrationModal(props: DialogProps) {
} else {
props.onOpenChange?.(false);
}
})}>
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<TextField
required

View File

@ -43,7 +43,7 @@ export function AddCalDavIntegrationModal(props: DialogProps) {
<Form
form={form}
onSubmit={form.handleSubmit(async (values) => {
handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/caldav/add", {
method: "POST",
@ -58,7 +58,7 @@ export function AddCalDavIntegrationModal(props: DialogProps) {
} else {
props.onOpenChange?.(false);
}
})}>
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<TextField
required
@ -123,14 +123,14 @@ const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, re
<label htmlFor="url" className="block text-sm font-medium text-gray-700">
Calendar URL
</label>
<div className="mt-1 rounded-md shadow-sm flex">
<div className="flex mt-1 rounded-md shadow-sm">
<input
required
type="text"
name="url"
id="url"
placeholder="https://example.com/calendar"
className="focus:ring-black focus:border-brand flex-grow block w-full min-w-0 rounded-none rounded-r-sm sm:text-sm border-gray-300 lowercase"
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-brand sm:text-sm"
/>
</div>
</div>
@ -144,7 +144,7 @@ const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, re
name="username"
id="username"
placeholder="rickroll"
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
<div className="mb-2">
@ -157,7 +157,7 @@ const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, re
name="password"
id="password"
placeholder="•••••••••••••"
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
</div>
</form>

View File

@ -0,0 +1 @@
export const TIMEZONE_FORMAT = "YYYY-MM-DDTHH:mm:ss[Z]";

View File

@ -0,0 +1,10 @@
export const CALDAV_CALENDAR_TYPE = "caldav";
export const APPLE_CALENDAR_URL = "https://caldav.icloud.com";
export const CALENDAR_INTEGRATIONS_TYPES = {
apple: "apple_calendar",
caldav: "caldav_calendar",
google: "google_calendar",
office365: "office365_calendar",
};

View File

@ -0,0 +1,56 @@
import dayjs from "dayjs";
import ICAL from "ical.js";
import AppleCalendarService from "../services/AppleCalendarService";
import CalDavCalendarService from "../services/CalDavCalendarService";
import GoogleCalendarService from "../services/GoogleCalendarService";
import Office365CalendarService from "../services/Office365CalendarService";
export type EventBusyDate = Record<"start" | "end", Date | string>;
export type CalendarServiceType =
| typeof AppleCalendarService
| typeof CalDavCalendarService
| typeof GoogleCalendarService
| typeof Office365CalendarService;
export type NewCalendarEventType = {
uid: string;
id: string;
type: string;
password: string;
url: string;
additionalInfo: Record<string, any>;
};
export type CalendarEventType = {
uid: string;
etag: string;
url: string;
summary: string;
description: string;
location: string;
sequence: number;
startDate: Date | dayjs.Dayjs;
endDate: Date | dayjs.Dayjs;
duration: {
weeks: number;
days: number;
hours: number;
minutes: number;
seconds: number;
isNegative: boolean;
};
organizer: string;
attendees: any[][];
recurrenceId: ICAL.Time;
timezone: any;
};
export type BatchResponse = {
responses: SubResponse[];
};
export type SubResponse = {
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
};

View File

@ -0,0 +1,78 @@
import { DestinationCalendar, SelectedCalendar } from "@prisma/client";
import { TFunction } from "next-i18next";
import { PaymentInfo } from "@ee/lib/stripe/server";
import { Ensure } from "@lib/types/utils";
import { VideoCallData } from "@lib/videoClient";
import { NewCalendarEventType } from "../constants/types";
import { ConferenceData } from "./GoogleCalendar";
export type Person = {
name: string;
email: string;
timeZone: string;
};
export interface EntryPoint {
entryPointType?: string;
uri?: string;
label?: string;
pin?: string;
accessCode?: string;
meetingCode?: string;
passcode?: string;
password?: string;
}
export interface AdditionInformation {
conferenceData?: ConferenceData;
entryPoints?: EntryPoint[];
hangoutLink?: string;
}
export interface CalendarEvent {
type: string;
title: string;
startTime: string;
endTime: string;
description?: string | null;
team?: {
name: string;
members: string[];
};
location?: string | null;
organizer: Person;
attendees: Person[];
conferenceData?: ConferenceData;
language: TFunction;
additionInformation?: AdditionInformation;
uid?: string | null;
videoCallData?: VideoCallData;
paymentInfo?: PaymentInfo | null;
destinationCalendar?: DestinationCalendar | null;
}
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
primary?: boolean;
name?: string;
}
type EventBusyDate = Record<"start" | "end", Date | string>;
export interface Calendar {
createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
deleteEvent(uid: string): Promise<unknown>;
getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]>;
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]>;
}

View File

@ -0,0 +1,5 @@
import { calendar_v3 } from "googleapis";
export interface ConferenceData {
createRequest?: calendar_v3.Schema$CreateConferenceRequest;
}

View File

@ -0,0 +1,10 @@
export type BufferedBusyTime = {
start: string;
end: string;
};
export type O365AuthCredentials = {
expiry_date: number;
access_token: string;
refresh_token: string;
};

View File

@ -0,0 +1,10 @@
import { Credential } from "@prisma/client";
import { APPLE_CALENDAR_URL, CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals";
import CalendarService from "./BaseCalendarService";
export default class AppleCalendarService extends CalendarService {
constructor(credential: Credential) {
super(credential, CALENDAR_INTEGRATIONS_TYPES.apple, APPLE_CALENDAR_URL);
}
}

View File

@ -1,11 +1,11 @@
import { Credential } from "@prisma/client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import ICAL from "ical.js";
import { Attendee, createEvent, DateArray, DurationObject } from "ics";
import { createEvent } from "ics";
import {
createAccount,
createCalendarObject,
DAVAccount,
deleteCalendarObject,
fetchCalendarObjects,
fetchCalendars,
@ -18,73 +18,62 @@ import { getLocation, getRichDescription } from "@lib/CalEventParser";
import { symmetricDecrypt } from "@lib/crypto";
import logger from "@lib/logger";
import { CalendarEvent, IntegrationCalendar } from "./calendarClient";
import { TIMEZONE_FORMAT } from "../constants/formats";
import { CALDAV_CALENDAR_TYPE } from "../constants/generals";
import { CalendarEventType, EventBusyDate, NewCalendarEventType } from "../constants/types";
import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
import { convertDate, getAttendees, getDuration } from "../utils/CalendarUtils";
dayjs.extend(utc);
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
export type Person = { name: string; email: string; timeZone: string };
export default abstract class BaseCalendarService implements Calendar {
private url = "";
private credentials: Record<string, string> = {};
private headers: Record<string, string> = {};
protected integrationName = "";
export class BaseCalendarApiAdapter {
private url: string;
private credentials: Record<string, string>;
private headers: Record<string, string>;
private integrationName = "";
log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
constructor(credential: Credential, integrationName: string, url?: string) {
const decryptedCredential = JSON.parse(
symmetricDecrypt(credential.key as string, process.env.CALENDSO_ENCRYPTION_KEY!)
);
const username = decryptedCredential.username;
const password = decryptedCredential.password;
this.url = url || decryptedCredential.url;
this.integrationName = integrationName;
const {
username,
password,
url: credentialURL,
} = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
this.url = url || credentialURL;
this.credentials = { username, password };
this.headers = getBasicAuthHeaders({ username, password });
}
log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
convertDate(date: string): DateArray {
return dayjs(date)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray;
}
getDuration(start: string, end: string): DurationObject {
return {
minutes: dayjs(end).diff(dayjs(start), "minute"),
};
}
getAttendees(attendees: Person[]): Attendee[] {
return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
}
async createEvent(event: CalendarEvent) {
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
try {
const calendars = await this.listCalendars(event);
const uid = uuidv4();
/** We create local ICS files */
// We create local ICS files
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
start: convertDate(event.startTime),
duration: getDuration(event.startTime, event.endTime),
title: event.title,
description: getRichDescription(event),
location: getLocation(event),
organizer: { email: event.organizer.email, name: event.organizer.name },
// according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component. "Attendees" MUST NOT be present
// attendees: this.getAttendees(event.attendees),
/** according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component.
* "Attendees" MUST NOT be present
* `attendees: this.getAttendees(event.attendees),`
*/
});
if (error) throw new Error("Error creating iCalString");
if (error || !iCalString) throw new Error("Error creating iCalString");
if (!iCalString) throw new Error("Error creating iCalString");
/** We create the event directly on iCal */
// We create the event directly on iCal
const responses = await Promise.all(
calendars
.filter((c) =>
@ -117,46 +106,40 @@ export class BaseCalendarApiAdapter {
type: this.integrationName,
password: "",
url: "",
additionalInfo: {},
};
} catch (reason) {
console.error(reason);
logger.error(reason);
throw reason;
}
}
async updateEvent(uid: string, event: CalendarEvent) {
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
try {
const calendars = await this.listCalendars();
const events = [];
for (const cal of calendars) {
const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
for (const ev of calEvents) {
events.push(ev);
}
}
const events = await this.getEventsByUID(uid);
const { error, value: iCalString } = createEvent({
uid,
startInputType: "utc",
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
start: convertDate(event.startTime),
duration: getDuration(event.startTime, event.endTime),
title: event.title,
description: getRichDescription(event),
location: getLocation(event),
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event.attendees),
attendees: getAttendees(event.attendees),
});
if (error) {
this.log.debug("Error creating iCalString");
return {};
}
const eventsToUpdate = events.filter((event) => event.uid === uid);
return await Promise.all(
return Promise.all(
eventsToUpdate.map((event) => {
return updateCalendarObject({
calendarObject: {
@ -169,28 +152,20 @@ export class BaseCalendarApiAdapter {
})
);
} catch (reason) {
console.error(reason);
this.log.error(reason);
throw reason;
}
}
async deleteEvent(uid: string): Promise<void> {
try {
const calendars = await this.listCalendars();
const events = [];
const events = await this.getEventsByUID(uid);
for (const cal of calendars) {
const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
for (const ev of calEvents) {
events.push(ev);
}
}
const eventsToUpdate = events.filter((event) => event.uid === uid);
const eventsToDelete = events.filter((event) => event.uid === uid);
await Promise.all(
eventsToUpdate.map((event) => {
eventsToDelete.map((event) => {
return deleteCalendarObject({
calendarObject: {
url: event.url,
@ -201,52 +176,30 @@ export class BaseCalendarApiAdapter {
})
);
} catch (reason) {
console.error(reason);
this.log.error(reason);
throw reason;
}
}
async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) {
try {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
this.log.warn(
`Method not implemented. dateFrom: ${dateFrom}, dateTo: ${dateTo}, selectedCalendars: ${selectedCalendars}`
);
return (
selectedCalendarIds.length === 0
? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId))
: Promise.resolve(selectedCalendarIds)
).then(async (ids: string[]) => {
if (ids.length === 0) {
return Promise.resolve([]);
}
const eventsBusyDate: EventBusyDate[] = [];
return (
await Promise.all(
ids.map(async (calId) => {
return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
return {
start: event.startDate.toISOString(),
end: event.endDate.toISOString(),
};
});
})
)
).flatMap((event) => event);
});
} catch (reason) {
this.log.error(reason);
throw reason;
}
return Promise.resolve(eventsBusyDate);
}
async listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
try {
const account = await this.getAccount();
const calendars = await fetchCalendars({
account,
headers: this.headers,
@ -254,6 +207,7 @@ export class BaseCalendarApiAdapter {
return calendars.reduce<IntegrationCalendar[]>((newCalendars, calendar) => {
if (!calendar.components?.includes("VEVENT")) return newCalendars;
newCalendars.push({
externalId: calendar.url,
name: calendar.displayName ?? "",
@ -265,12 +219,13 @@ export class BaseCalendarApiAdapter {
return newCalendars;
}, []);
} catch (reason) {
console.error(reason);
logger.error(reason);
throw reason;
}
}
async getEvents(
private async getEvents(
calId: string,
dateFrom: string | null,
dateTo: string | null,
@ -285,8 +240,8 @@ export class BaseCalendarApiAdapter {
timeRange:
dateFrom && dateTo
? {
start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
start: dayjs(dateFrom).utc().format(TIMEZONE_FORMAT),
end: dayjs(dateTo).utc().format(TIMEZONE_FORMAT),
}
: undefined,
headers: this.headers,
@ -296,7 +251,9 @@ export class BaseCalendarApiAdapter {
.filter((e) => !!e.data)
.map((object) => {
const jcalData = ICAL.parse(object.data);
const vcalendar = new ICAL.Component(jcalData);
const vevent = vcalendar.getFirstSubcomponent("vevent");
const event = new ICAL.Event(vevent);
@ -306,6 +263,7 @@ export class BaseCalendarApiAdapter {
const startDate = calendarTimezone
? dayjs(event.startDate.toJSDate()).tz(calendarTimezone)
: new Date(event.startDate.toUnixTime() * 1000);
const endDate = calendarTimezone
? dayjs(event.endDate.toJSDate()).tz(calendarTimezone)
: new Date(event.endDate.toUnixTime() * 1000);
@ -342,16 +300,30 @@ export class BaseCalendarApiAdapter {
}
}
private async getAccount() {
const account = await createAccount({
private async getEventsByUID(uid: string): Promise<CalendarEventType[]> {
const events = [];
const calendars = await this.listCalendars();
for (const cal of calendars) {
const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
for (const ev of calEvents) {
events.push(ev);
}
}
return events;
}
private async getAccount(): Promise<DAVAccount> {
return createAccount({
account: {
serverUrl: this.url,
accountType: "caldav",
accountType: CALDAV_CALENDAR_TYPE,
credentials: this.credentials,
},
headers: this.headers,
});
return account;
}
}

View File

@ -0,0 +1,10 @@
import { Credential } from "@prisma/client";
import { CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals";
import CalendarService from "./BaseCalendarService";
export default class CalDavCalendarService extends CalendarService {
constructor(credential: Credential) {
super(credential, CALENDAR_INTEGRATIONS_TYPES.caldav);
}
}

View File

@ -0,0 +1,324 @@
import { Credential, Prisma } from "@prisma/client";
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
import { Auth, calendar_v3, google } from "googleapis";
import { getLocation, getRichDescription } from "@lib/CalEventParser";
import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { EventBusyDate, NewCalendarEventType } from "../constants/types";
import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
import CalendarService from "./BaseCalendarService";
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "";
export default class GoogleCalendarService implements Calendar {
private url = "";
private integrationName = "";
private auth: { getToken: () => Promise<MyGoogleAuth> };
log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
constructor(credential: Credential) {
this.integrationName = CALENDAR_INTEGRATIONS_TYPES.google;
this.auth = this.googleAuth(credential);
}
private googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(GOOGLE_API_CREDENTIALS).web;
const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
const isExpired = () => myGoogleAuth.isTokenExpiring();
const refreshAccessToken = () =>
myGoogleAuth
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
})
.catch((err) => {
this.log.error("Error refreshing google token", err);
return myGoogleAuth;
});
return {
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
};
};
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = getLocation(event);
}
if (event.conferenceData && event.location === "integrations:google:meet") {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve({
uid: "",
...event.data,
id: event.data.id || "",
additionalInfo: {
hangoutLink: event.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
});
}
);
})
);
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: true,
},
};
if (event.location) {
payload["location"] = getLocation(event);
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
);
}
async deleteEvent(uid: string): Promise<void> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
);
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) {
reject(err);
}
let result: Prisma.PromiseReturnType<CalendarService["getAvailability"]> = [];
if (apires?.data.calendars) {
result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as typeof result);
}
resolve(result);
}
);
})
.catch((err) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
);
}
async listCalendars(): Promise<IntegrationCalendar[]> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: this.integrationName,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
);
}
}
class MyGoogleAuth extends google.auth.OAuth2 {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
isTokenExpiring() {
return super.isTokenExpiring();
}
async refreshToken(token: string | null | undefined) {
return super.refreshToken(token);
}
}

View File

@ -0,0 +1,250 @@
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential } from "@prisma/client";
import { getLocation, getRichDescription } from "@lib/CalEventParser";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BatchResponse, EventBusyDate, NewCalendarEventType } from "../constants/types";
import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
import { BufferedBusyTime, O365AuthCredentials } from "../interfaces/Office365Calendar";
const MS_GRAPH_CLIENT_ID = process.env.MS_GRAPH_CLIENT_ID || "";
const MS_GRAPH_CLIENT_SECRET = process.env.MS_GRAPH_CLIENT_SECRET || "";
export default class Office365CalendarService implements Calendar {
private url = "";
private integrationName = "";
auth: { getToken: () => Promise<string> };
log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
constructor(credential: Credential) {
this.integrationName = CALENDAR_INTEGRATIONS_TYPES.office365;
this.auth = this.o365Auth(credential);
}
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
try {
const accessToken = await this.auth.getToken();
const calendarId = event.destinationCalendar?.externalId
? `${event.destinationCalendar.externalId}/`
: "";
const response = await fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(this.translateEvent(event)),
});
return handleErrorsJson(response);
} catch (error) {
this.log.error(error);
throw error;
}
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
try {
const accessToken = await this.auth.getToken();
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(this.translateEvent(event)),
});
return handleErrorsRaw(response);
} catch (error) {
this.log.error(error);
throw error;
}
}
async deleteEvent(uid: string): Promise<void> {
try {
const accessToken = await this.auth.getToken();
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
});
handleErrorsRaw(response);
} catch (error) {
this.log.error(error);
throw error;
}
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
const dateFromParsed = new Date(dateFrom);
const dateToParsed = new Date(dateTo);
const filter = `?startdatetime=${encodeURIComponent(
dateFromParsed.toISOString()
)}&enddatetime=${encodeURIComponent(dateToParsed.toISOString())}`;
return this.auth
.getToken()
.then((accessToken) => {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId)
.filter(Boolean);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
return (
selectedCalendarIds.length === 0
? this.listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
).then((ids) => {
const requests = ids.map((calendarId, id) => ({
id,
method: "GET",
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
return fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
})
.then(handleErrorsJson)
.then((responseBody: BatchResponse) =>
responseBody.responses.reduce(
(acc: BufferedBusyTime[], subResponse) =>
acc.concat(
subResponse.body.value.map((evt) => {
return {
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
};
})
),
[]
)
);
});
})
.catch((err) => {
console.log(err);
return Promise.reject([]);
});
}
async listCalendars(): Promise<IntegrationCalendar[]> {
return this.auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
})
.then(handleErrorsJson)
.then((responseBody: { value: OfficeCalendar[] }) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: this.integrationName,
name: cal.name ?? "No calendar name",
primary: cal.isDefaultCalendar ?? false,
};
return calendar;
});
})
);
}
private o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: MS_GRAPH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: MS_GRAPH_CLIENT_SECRET,
}),
})
.then(handleErrorsJson)
.then((responseBody) => {
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
})
.then(() => o365AuthCredentials.access_token);
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
private translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
body: {
contentType: "HTML",
content: getRichDescription(event),
},
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
location: event.location ? { displayName: getLocation(event) } : undefined,
};
};
}

View File

@ -0,0 +1,16 @@
import dayjs from "dayjs";
import { Attendee, DateArray, DurationObject, Person } from "ics";
export const convertDate = (date: string): DateArray =>
dayjs(date)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray;
export const getDuration = (start: string, end: string): DurationObject => ({
minutes: dayjs(end).diff(dayjs(start), "minute"),
});
export const getAttendees = (attendees: Person[]): Attendee[] =>
attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));

View File

@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import prisma from "@lib/prisma";
export async function getUserAvailability(query: {

View File

@ -7,9 +7,9 @@ import { EventResult } from "@lib/events/EventManager";
import { PartialReference } from "@lib/events/EventManager";
import logger from "@lib/logger";
import { CalendarEvent } from "./calendarClient";
import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter";
import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter";
import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
import { Ensure } from "./types/utils";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });

View File

@ -1,6 +1,6 @@
import { compile } from "handlebars";
import { CalendarEvent } from "@lib/calendarClient";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
type ContentType = "application/json" | "application/x-www-form-urlencoded";

View File

@ -7,7 +7,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { getBusyCalendarTimes } from "@lib/calendarClient";
import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import prisma from "@lib/prisma";
dayjs.extend(utc);

View File

@ -1,12 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });

View File

@ -4,10 +4,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { refund } from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
import { CalendarEvent, AdditionInformation } from "@lib/calendarClient";
import { sendDeclinedEmails } from "@lib/emails/email-manager";
import { sendScheduledEmails } from "@lib/emails/email-manager";
import EventManager from "@lib/events/EventManager";
import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BookingConfirmBody } from "@lib/types/booking";

View File

@ -11,7 +11,6 @@ import { v5 as uuidv5 } from "uuid";
import { handlePayment } from "@ee/lib/stripe/server";
import { CalendarEvent, AdditionInformation, getBusyCalendarTimes } from "@lib/calendarClient";
import {
sendScheduledEmails,
sendRescheduledEmails,
@ -21,7 +20,9 @@ import { ensureArray } from "@lib/ensureArray";
import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event";
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar";
import logger from "@lib/logger";
import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma";

View File

@ -6,9 +6,10 @@ import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
import { sendCancelledEmails } from "@lib/emails/email-manager";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { deleteMeeting } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
@ -138,9 +139,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
const calendar = getCalendar(credential);
return calendar?.deleteEvent(bookingRefUid);
} else if (credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
return deleteMeeting(credential, bookingRefUid);
}
}
});

View File

@ -2,8 +2,8 @@ import { ReminderType } from "@prisma/client";
import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next";
import { CalendarEvent } from "@lib/calendarClient";
import { sendOrganizerRequestReminderEmail } from "@lib/emails/email-manager";
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";

View File

@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { symmetricEncrypt } from "@lib/crypto";
import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
@ -35,11 +35,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
try {
const dav = new AppleCalendar({
const dav = getCalendar({
id: 0,
...data,
});
await dav.listCalendars();
await dav?.listCalendars();
await prisma.credential.create({
data,
});

View File

@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { symmetricEncrypt } from "@lib/crypto";
import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
@ -38,11 +38,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
try {
const dav = new CalDavCalendar({
const dav = getCalendar({
id: 0,
...data,
});
await dav.listCalendars();
await dav?.listCalendars();
await prisma.credential.create({
data,
});

View File

@ -18,6 +18,7 @@ import TimezoneSelect from "react-timezone-select";
import { getSession } from "@lib/auth";
import { DEFAULT_SCHEDULE } from "@lib/availability";
import { useLocale } from "@lib/hooks/useLocale";
import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
import getIntegrations from "@lib/integrations/getIntegrations";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -32,9 +33,6 @@ import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
import Schedule from "@components/ui/form/Schedule";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
import getEventTypes from "../lib/queries/event-types/get-event-types";
dayjs.extend(utc);

View File

@ -1,6 +1,9 @@
import { Browser, chromium } from "@playwright/test";
import fs from "fs";
async function loginAsUser(username: string, browser: Browser) {
// Skip is file exists
if (fs.existsSync(`playwright/artifacts/${username}StorageState.json`)) return;
const page = await browser.newPage();
await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`);
// Click input[name="email"]

View File

@ -1,25 +0,0 @@
import { Credential } from "@prisma/client";
import { getCalendarAdapterOrNull } from "@lib/calendarClient";
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
export default function getCalendarCredentials(
credentials: Array<Omit<Credential, "userId">>,
userId: number
) {
const calendarCredentials = credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.flatMap((credential) => {
const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
const adapter = getCalendarAdapterOrNull({
...credential,
userId,
});
return integration && adapter && integration.variant === "calendar"
? [{ integration, credential, adapter }]
: [];
});
return calendarCredentials;
}

View File

@ -1,50 +0,0 @@
import _ from "lodash";
import { getErrorFromUnknown } from "@lib/errors";
import getCalendarCredentials from "./getCalendarCredentials";
export default async function getConnectedCalendars(
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
selectedCalendars: { externalId: string }[]
) {
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
const { adapter, integration, credential } = item;
const credentialId = credential.id;
try {
const cals = await adapter.listCalendars();
const calendars = _(cals)
.map((cal) => ({
...cal,
primary: cal.primary || null,
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
}))
.sortBy(["primary"])
.value();
const primary = calendars.find((item) => item.primary) ?? calendars[0];
if (!primary) {
throw new Error("No primary calendar found");
}
return {
integration,
credentialId,
primary,
calendars,
};
} catch (_error) {
const error = getErrorFromUnknown(_error);
return {
integration,
credentialId,
error: {
message: error.message,
},
};
}
})
);
return connectedCalendars;
}

View File

@ -5,12 +5,11 @@ import { z } from "zod";
import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername";
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
import { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations";
import slugify from "@lib/slugify";
import { Schedule } from "@lib/types/schedule";
import getCalendarCredentials from "@server/integrations/getCalendarCredentials";
import getConnectedCalendars from "@server/integrations/getConnectedCalendars";
import { TRPCError } from "@trpc/server";
import { createProtectedRouter, createRouter } from "../createRouter";
@ -298,7 +297,10 @@ const loggedInViewerRouter = createProtectedRouter()
},
],
};
const bookingListingOrderby: Record<typeof bookingListingByStatus, Prisma.BookingOrderByInput> = {
const bookingListingOrderby: Record<
typeof bookingListingByStatus,
Prisma.BookingOrderByWithAggregationInput
> = {
upcoming: { startTime: "desc" },
past: { startTime: "desc" },
cancelled: { startTime: "desc" },