Refactors video integrations (#1037)
* Fixes error types * Type fixes * Refactors video meeting handling * More type fixes * Type fixes * More fixes * Makes language non optional * Adds missing translations * Apply suggestions from code review Co-authored-by: Alex Johansson <alexander@n1s.se> * Feedback Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Alex Johansson <alexander@n1s.se>
This commit is contained in:
parent
eabb096e14
commit
e38086b8fe
17 changed files with 618 additions and 612 deletions
|
@ -9,6 +9,9 @@ import { HttpError } from "@lib/core/http/error";
|
||||||
import { getErrorFromUnknown } from "@lib/errors";
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import EventManager from "@lib/events/EventManager";
|
import EventManager from "@lib/events/EventManager";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { Ensure } from "@lib/types/utils";
|
||||||
|
|
||||||
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
|
@ -69,7 +72,9 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
|
|
||||||
if (!user) throw new Error("No user found");
|
if (!user) throw new Error("No user found");
|
||||||
|
|
||||||
const evt: CalendarEvent = {
|
const t = await getTranslation(/* FIXME handle mulitple locales here */ "en", "common");
|
||||||
|
|
||||||
|
const evt: Ensure<CalendarEvent, "language"> = {
|
||||||
type: booking.title,
|
type: booking.title,
|
||||||
title: booking.title,
|
title: booking.title,
|
||||||
description: booking.description || undefined,
|
description: booking.description || undefined,
|
||||||
|
@ -77,12 +82,14 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
|
organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
|
||||||
attendees: booking.attendees,
|
attendees: booking.attendees,
|
||||||
|
uid: booking.uid,
|
||||||
|
language: t,
|
||||||
};
|
};
|
||||||
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.credentials);
|
||||||
const scheduleResult = await eventManager.create(evt, booking.uid);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import { Credential } from "@prisma/client";
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||||
|
import { Credential, Prisma, SelectedCalendar } from "@prisma/client";
|
||||||
|
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
|
||||||
|
import { Auth, calendar_v3, google } from "googleapis";
|
||||||
import { TFunction } from "next-i18next";
|
import { TFunction } from "next-i18next";
|
||||||
|
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { Event, EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
import { VideoCallData } from "@lib/videoClient";
|
import { VideoCallData } from "@lib/videoClient";
|
||||||
|
|
||||||
|
@ -14,34 +18,34 @@ import prisma from "./prisma";
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
const googleAuth = (credential: Credential) => {
|
||||||
const { google } = require("googleapis");
|
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
|
||||||
|
|
||||||
const googleAuth = (credential) => {
|
|
||||||
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
|
|
||||||
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||||
myGoogleAuth.setCredentials(credential.key);
|
const googleCredentials = credential.key as Auth.Credentials;
|
||||||
|
myGoogleAuth.setCredentials(googleCredentials);
|
||||||
|
|
||||||
|
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
||||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||||
|
|
||||||
const refreshAccessToken = () =>
|
const refreshAccessToken = () =>
|
||||||
myGoogleAuth
|
myGoogleAuth
|
||||||
.refreshToken(credential.key.refresh_token)
|
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
||||||
.then((res) => {
|
.refreshToken(googleCredentials.refresh_token)
|
||||||
const token = res.res.data;
|
.then((res: GetTokenResponse) => {
|
||||||
credential.key.access_token = token.access_token;
|
const token = res.res?.data;
|
||||||
credential.key.expiry_date = token.expiry_date;
|
googleCredentials.access_token = token.access_token;
|
||||||
|
googleCredentials.expiry_date = token.expiry_date;
|
||||||
return prisma.credential
|
return prisma.credential
|
||||||
.update({
|
.update({
|
||||||
where: {
|
where: {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
key: credential.key,
|
key: googleCredentials as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
myGoogleAuth.setCredentials(credential.key);
|
myGoogleAuth.setCredentials(googleCredentials);
|
||||||
return myGoogleAuth;
|
return myGoogleAuth;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -71,13 +75,21 @@ function handleErrorsRaw(response: Response) {
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
const o365Auth = (credential) => {
|
type O365AuthCredentials = {
|
||||||
const isExpired = (expiryDate) => expiryDate < Math.round(+new Date() / 1000);
|
expiry_date: number;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
const refreshAccessToken = (refreshToken) => {
|
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", {
|
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
// FIXME types - IDK how to type this TBH
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
scope: "User.Read Calendars.Read Calendars.ReadWrite",
|
scope: "User.Read Calendars.Read Calendars.ReadWrite",
|
||||||
client_id: process.env.MS_GRAPH_CLIENT_ID,
|
client_id: process.env.MS_GRAPH_CLIENT_ID,
|
||||||
|
@ -88,26 +100,26 @@ const o365Auth = (credential) => {
|
||||||
})
|
})
|
||||||
.then(handleErrorsJson)
|
.then(handleErrorsJson)
|
||||||
.then((responseBody) => {
|
.then((responseBody) => {
|
||||||
credential.key.access_token = responseBody.access_token;
|
o365AuthCredentials.access_token = responseBody.access_token;
|
||||||
credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
return prisma.credential
|
return prisma.credential
|
||||||
.update({
|
.update({
|
||||||
where: {
|
where: {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
key: credential.key,
|
key: o365AuthCredentials,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => credential.key.access_token);
|
.then(() => o365AuthCredentials.access_token);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: () =>
|
getToken: () =>
|
||||||
!isExpired(credential.key.expiry_date)
|
!isExpired(o365AuthCredentials.expiry_date)
|
||||||
? Promise.resolve(credential.key.access_token)
|
? Promise.resolve(o365AuthCredentials.access_token)
|
||||||
: refreshAccessToken(credential.key.refresh_token),
|
: refreshAccessToken(o365AuthCredentials.refresh_token),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -146,24 +158,22 @@ export interface CalendarEvent {
|
||||||
conferenceData?: ConferenceData;
|
conferenceData?: ConferenceData;
|
||||||
language: TFunction;
|
language: TFunction;
|
||||||
additionInformation?: AdditionInformation;
|
additionInformation?: AdditionInformation;
|
||||||
|
/** If this property exist it we can assume it's a reschedule/update */
|
||||||
uid?: string | null;
|
uid?: string | null;
|
||||||
videoCallData?: VideoCallData;
|
videoCallData?: VideoCallData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConferenceData {
|
export interface ConferenceData {
|
||||||
createRequest: unknown;
|
createRequest: calendar_v3.Schema$CreateConferenceRequest;
|
||||||
}
|
}
|
||||||
|
export interface IntegrationCalendar extends Partial<SelectedCalendar> {
|
||||||
export interface IntegrationCalendar {
|
primary?: boolean;
|
||||||
integration: string;
|
name?: string;
|
||||||
primary: boolean;
|
|
||||||
externalId: string;
|
|
||||||
name: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BufferedBusyTime = { start: string; end: string };
|
type BufferedBusyTime = { start: string; end: string };
|
||||||
export interface CalendarApiAdapter {
|
export interface CalendarApiAdapter {
|
||||||
createEvent(event: CalendarEvent): Promise<unknown>;
|
createEvent(event: CalendarEvent): Promise<Event>;
|
||||||
|
|
||||||
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
|
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
|
@ -178,15 +188,10 @@ export interface CalendarApiAdapter {
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => {
|
||||||
const auth = o365Auth(credential);
|
const auth = o365Auth(credential);
|
||||||
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
const optional = {};
|
|
||||||
if (event.location) {
|
|
||||||
optional.location = { displayName: event.location };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject: event.title,
|
subject: event.title,
|
||||||
body: {
|
body: {
|
||||||
|
@ -208,7 +213,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
type: "required",
|
type: "required",
|
||||||
})),
|
})),
|
||||||
...optional,
|
location: event.location ? { displayName: event.location } : undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -224,13 +229,13 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(handleErrorsJson)
|
.then(handleErrorsJson)
|
||||||
.then((responseBody) => {
|
.then((responseBody: { value: OfficeCalendar[] }) => {
|
||||||
return responseBody.value.map((cal) => {
|
return responseBody.value.map((cal) => {
|
||||||
const calendar: IntegrationCalendar = {
|
const calendar: IntegrationCalendar = {
|
||||||
externalId: cal.id,
|
externalId: cal.id ?? "No Id",
|
||||||
integration: integrationType,
|
integration: integrationType,
|
||||||
name: cal.name,
|
name: cal.name ?? "No calendar name",
|
||||||
primary: cal.isDefaultCalendar,
|
primary: cal.isDefaultCalendar ?? false,
|
||||||
};
|
};
|
||||||
return calendar;
|
return calendar;
|
||||||
});
|
});
|
||||||
|
@ -248,17 +253,18 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
.then((accessToken) => {
|
.then((accessToken) => {
|
||||||
const selectedCalendarIds = selectedCalendars
|
const selectedCalendarIds = selectedCalendars
|
||||||
.filter((e) => e.integration === integrationType)
|
.filter((e) => e.integration === integrationType)
|
||||||
.map((e) => e.externalId);
|
.map((e) => e.externalId)
|
||||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
|
.filter(Boolean);
|
||||||
|
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
|
||||||
// Only calendars of other integrations selected
|
// Only calendars of other integrations selected
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectedCalendarIds.length == 0
|
selectedCalendarIds.length === 0
|
||||||
? listCalendars().then((cals) => cals.map((e) => e.externalId))
|
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
|
||||||
: Promise.resolve(selectedCalendarIds).then((x) => x)
|
: Promise.resolve(selectedCalendarIds)
|
||||||
).then((ids: string[]) => {
|
).then((ids) => {
|
||||||
const requests = ids.map((calendarId, id) => ({
|
const requests = ids.map((calendarId, id) => ({
|
||||||
id,
|
id,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -268,6 +274,13 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
url: `/me/calendars/${calendarId}/calendarView${filter}`,
|
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", {
|
return fetch("https://graph.microsoft.com/v1.0/$batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -277,9 +290,9 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
body: JSON.stringify({ requests }),
|
body: JSON.stringify({ requests }),
|
||||||
})
|
})
|
||||||
.then(handleErrorsJson)
|
.then(handleErrorsJson)
|
||||||
.then((responseBody) =>
|
.then((responseBody: BatchResponse) =>
|
||||||
responseBody.responses.reduce(
|
responseBody.responses.reduce(
|
||||||
(acc, subResponse) =>
|
(acc: BufferedBusyTime[], subResponse) =>
|
||||||
acc.concat(
|
acc.concat(
|
||||||
subResponse.body.value.map((evt) => {
|
subResponse.body.value.map((evt) => {
|
||||||
return {
|
return {
|
||||||
|
@ -295,6 +308,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
return Promise.reject([]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
createEvent: (event: CalendarEvent) =>
|
createEvent: (event: CalendarEvent) =>
|
||||||
|
@ -337,7 +351,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const GoogleCalendar = (credential): CalendarApiAdapter => {
|
const GoogleCalendar = (credential: Credential): CalendarApiAdapter => {
|
||||||
const auth = googleAuth(credential);
|
const auth = googleAuth(credential);
|
||||||
const integrationType = "google_calendar";
|
const integrationType = "google_calendar";
|
||||||
|
|
||||||
|
@ -352,14 +366,16 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
const selectedCalendarIds = selectedCalendars
|
const selectedCalendarIds = selectedCalendars
|
||||||
.filter((e) => e.integration === integrationType)
|
.filter((e) => e.integration === integrationType)
|
||||||
.map((e) => e.externalId);
|
.map((e) => e.externalId);
|
||||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
|
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
|
||||||
// Only calendars of other integrations selected
|
// Only calendars of other integrations selected
|
||||||
resolve([]);
|
resolve([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(selectedCalendarIds.length == 0
|
(selectedCalendarIds.length === 0
|
||||||
? calendar.calendarList.list().then((cals) => cals.data.items.map((cal) => cal.id))
|
? calendar.calendarList
|
||||||
|
.list()
|
||||||
|
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
|
||||||
: Promise.resolve(selectedCalendarIds)
|
: Promise.resolve(selectedCalendarIds)
|
||||||
)
|
)
|
||||||
.then((calsIds) => {
|
.then((calsIds) => {
|
||||||
|
@ -375,6 +391,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
// @ts-ignore FIXME
|
||||||
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -388,7 +405,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
createEvent: (event: CalendarEvent) =>
|
createEvent: (event: CalendarEvent) =>
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
auth.getToken().then((myGoogleAuth) => {
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
const payload = {
|
const payload: calendar_v3.Schema$Event = {
|
||||||
summary: event.title,
|
summary: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: {
|
start: {
|
||||||
|
@ -422,14 +439,15 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
{
|
{
|
||||||
auth: myGoogleAuth,
|
auth: myGoogleAuth,
|
||||||
calendarId: "primary",
|
calendarId: "primary",
|
||||||
resource: payload,
|
requestBody: payload,
|
||||||
conferenceDataVersion: 1,
|
conferenceDataVersion: 1,
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err || !event?.data) {
|
||||||
console.error("There was an error contacting google calendar service: ", err);
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
// @ts-ignore FIXME
|
||||||
return resolve(event.data);
|
return resolve(event.data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -438,7 +456,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
updateEvent: (uid: string, event: CalendarEvent) =>
|
updateEvent: (uid: string, event: CalendarEvent) =>
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
auth.getToken().then((myGoogleAuth) => {
|
auth.getToken().then((myGoogleAuth) => {
|
||||||
const payload = {
|
const payload: calendar_v3.Schema$Event = {
|
||||||
summary: event.title,
|
summary: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: {
|
start: {
|
||||||
|
@ -471,14 +489,14 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
eventId: uid,
|
eventId: uid,
|
||||||
sendNotifications: true,
|
sendNotifications: true,
|
||||||
sendUpdates: "all",
|
sendUpdates: "all",
|
||||||
resource: payload,
|
requestBody: payload,
|
||||||
},
|
},
|
||||||
function (err, event) {
|
function (err, event) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("There was an error contacting google calendar service: ", err);
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
return resolve(event?.data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
@ -503,7 +521,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
console.error("There was an error contacting google calendar service: ", err);
|
console.error("There was an error contacting google calendar service: ", err);
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
return resolve(event.data);
|
return resolve(event?.data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
@ -519,15 +537,15 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||||
.list()
|
.list()
|
||||||
.then((cals) => {
|
.then((cals) => {
|
||||||
resolve(
|
resolve(
|
||||||
cals.data.items.map((cal) => {
|
cals.data.items?.map((cal) => {
|
||||||
const calendar: IntegrationCalendar = {
|
const calendar: IntegrationCalendar = {
|
||||||
externalId: cal.id,
|
externalId: cal.id ?? "No id",
|
||||||
integration: integrationType,
|
integration: integrationType,
|
||||||
name: cal.summary,
|
name: cal.summary ?? "No name",
|
||||||
primary: cal.primary,
|
primary: cal.primary ?? false,
|
||||||
};
|
};
|
||||||
return calendar;
|
return calendar;
|
||||||
})
|
}) || []
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -576,7 +594,12 @@ const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
|
||||||
})
|
})
|
||||||
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
|
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
|
||||||
|
|
||||||
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) =>
|
const getBusyCalendarTimes = (
|
||||||
|
withCredentials: Credential[],
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
selectedCalendars: SelectedCalendar[]
|
||||||
|
) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
|
@ -588,7 +611,7 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
|
||||||
* @param withCredentials
|
* @param withCredentials
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
const listCalendars = (withCredentials) =>
|
const listCalendars = (withCredentials: Credential[]) =>
|
||||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||||
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
||||||
);
|
);
|
||||||
|
@ -609,14 +632,15 @@ const createEvent = async (
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const creationResult: any = credential
|
const creationResult = credential
|
||||||
? await calendars([credential])[0]
|
? await calendars([credential])[0]
|
||||||
.createEvent(richEvent)
|
.createEvent(richEvent)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
log.error("createEvent failed", e, calEvent);
|
log.error("createEvent failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
|
return undefined;
|
||||||
})
|
})
|
||||||
: null;
|
: undefined;
|
||||||
|
|
||||||
const metadata: AdditionInformation = {};
|
const metadata: AdditionInformation = {};
|
||||||
if (creationResult) {
|
if (creationResult) {
|
||||||
|
|
|
@ -1,233 +0,0 @@
|
||||||
import { Credential } from "@prisma/client";
|
|
||||||
import short from "short-uuid";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
|
|
||||||
import CalEventParser from "@lib/CalEventParser";
|
|
||||||
import { getIntegrationName } from "@lib/emails/helpers";
|
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
|
||||||
import logger from "@lib/logger";
|
|
||||||
|
|
||||||
import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
|
|
||||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
|
||||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
|
||||||
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] dailyVideoClient"] });
|
|
||||||
|
|
||||||
const translator = short();
|
|
||||||
|
|
||||||
export interface DailyVideoCallData {
|
|
||||||
type: string;
|
|
||||||
id: string;
|
|
||||||
password: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleErrorsJson(response: Response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
response.json().then(console.log);
|
|
||||||
throw Error(response.statusText);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyCredential = process.env.DAILY_API_KEY;
|
|
||||||
|
|
||||||
interface DailyVideoApiAdapter {
|
|
||||||
dailyCreateMeeting(event: CalendarEvent): Promise<any>;
|
|
||||||
|
|
||||||
dailyUpdateMeeting(uid: string, event: CalendarEvent): Promise<any>;
|
|
||||||
|
|
||||||
dailyDeleteMeeting(uid: string): Promise<unknown>;
|
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DailyVideo = (credential: Credential): DailyVideoApiAdapter => {
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
|
||||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
|
||||||
// added a 1 hour buffer for room expiration and room entry
|
|
||||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
|
|
||||||
const nbf = Math.round(new Date(event.startTime).getTime() / 1000) - 60 * 60;
|
|
||||||
return {
|
|
||||||
privacy: "private",
|
|
||||||
properties: {
|
|
||||||
enable_new_call_ui: true,
|
|
||||||
enable_prejoin_ui: true,
|
|
||||||
enable_knocking: true,
|
|
||||||
enable_screenshare: true,
|
|
||||||
enable_chat: true,
|
|
||||||
exp: exp,
|
|
||||||
nbf: nbf,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getAvailability: () => {
|
|
||||||
return credential;
|
|
||||||
},
|
|
||||||
dailyCreateMeeting: (event: CalendarEvent) =>
|
|
||||||
fetch("https://api.daily.co/v1/rooms", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + dailyCredential,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(translateEvent(event)),
|
|
||||||
}).then(handleErrorsJson),
|
|
||||||
dailyDeleteMeeting: (uid: string) =>
|
|
||||||
fetch("https://api.daily.co/v1/rooms/" + uid, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + dailyCredential,
|
|
||||||
},
|
|
||||||
}).then(handleErrorsJson),
|
|
||||||
dailyUpdateMeeting: (uid: string, event: CalendarEvent) =>
|
|
||||||
fetch("https://api.daily.co/v1/rooms/" + uid, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + dailyCredential,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(translateEvent(event)),
|
|
||||||
}).then(handleErrorsJson),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// factory
|
|
||||||
const videoIntegrations = (withCredentials): DailyVideoApiAdapter[] =>
|
|
||||||
withCredentials
|
|
||||||
.map((cred) => {
|
|
||||||
return DailyVideo(cred);
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
|
|
||||||
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
|
||||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const dailyCreateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
|
||||||
const uid: string = parser.getUid();
|
|
||||||
|
|
||||||
if (!credential) {
|
|
||||||
throw new Error(
|
|
||||||
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let success = true;
|
|
||||||
|
|
||||||
const creationResult = await videoIntegrations([credential])[0]
|
|
||||||
.dailyCreateMeeting(calEvent)
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("createMeeting failed", e, calEvent);
|
|
||||||
success = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentRoute = process.env.BASE_URL;
|
|
||||||
|
|
||||||
const videoCallData: DailyVideoCallData = {
|
|
||||||
type: "Daily.co Video",
|
|
||||||
id: creationResult.name,
|
|
||||||
password: creationResult.password,
|
|
||||||
url: currentRoute + "/call/" + uid,
|
|
||||||
};
|
|
||||||
|
|
||||||
const entryPoint: EntryPoint = {
|
|
||||||
entryPointType: getIntegrationName(videoCallData),
|
|
||||||
uri: videoCallData.url,
|
|
||||||
label: calEvent.language("enter_meeting"),
|
|
||||||
pin: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const additionInformation: AdditionInformation = {
|
|
||||||
entryPoints: [entryPoint],
|
|
||||||
};
|
|
||||||
const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const organizerMail = new VideoEventOrganizerMail(emailEvent);
|
|
||||||
await organizerMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
|
||||||
try {
|
|
||||||
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
|
|
||||||
await attendeeMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "daily",
|
|
||||||
success,
|
|
||||||
uid,
|
|
||||||
createdEvent: creationResult,
|
|
||||||
originalEvent: calEvent,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dailyUpdateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
|
||||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
|
||||||
|
|
||||||
if (!credential) {
|
|
||||||
throw new Error(
|
|
||||||
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let success = true;
|
|
||||||
|
|
||||||
const updateResult =
|
|
||||||
credential && calEvent.uid
|
|
||||||
? await videoIntegrations([credential])[0]
|
|
||||||
.dailyUpdateMeeting(calEvent.uid, calEvent)
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("updateMeeting failed", e, calEvent);
|
|
||||||
success = false;
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const emailEvent = { ...calEvent, uid: newUid };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
|
|
||||||
await organizerMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
|
||||||
try {
|
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
|
||||||
await attendeeMail.sendEmail();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: credential.type,
|
|
||||||
success,
|
|
||||||
uid: newUid,
|
|
||||||
updatedEvent: updateResult,
|
|
||||||
originalEvent: calEvent,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dailyDeleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
|
|
||||||
if (credential) {
|
|
||||||
return videoIntegrations([credential])[0].dailyDeleteMeeting(uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({});
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getBusyVideoTimes, dailyCreateMeeting, dailyUpdateMeeting, dailyDeleteMeeting };
|
|
|
@ -1,4 +1,9 @@
|
||||||
export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: unknown } {
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: string } {
|
||||||
|
if (cause instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return cause;
|
||||||
|
}
|
||||||
if (cause instanceof Error) {
|
if (cause instanceof Error) {
|
||||||
return cause;
|
return cause;
|
||||||
}
|
}
|
||||||
|
@ -9,3 +14,19 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb
|
||||||
|
|
||||||
return new Error(`Unhandled error of type '${typeof cause}''`);
|
return new Error(`Unhandled error of type '${typeof cause}''`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleErrorsJson(response: Response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
response.json().then(console.log);
|
||||||
|
throw Error(response.statusText);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleErrorsRaw(response: Response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
response.text().then(console.log);
|
||||||
|
throw Error(response.statusText);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
|
@ -3,20 +3,27 @@ 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";
|
||||||
|
|
||||||
import { CalendarEvent, AdditionInformation, createEvent, updateEvent } from "@lib/calendarClient";
|
import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
|
||||||
import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient";
|
|
||||||
import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
|
import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
|
||||||
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
|
||||||
|
import { DailyEventResult, FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
|
||||||
|
import { ZoomEventResult } from "@lib/integrations/Zoom/ZoomVideoApiAdapter";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { Ensure } from "@lib/types/utils";
|
||||||
import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
|
import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
|
||||||
|
|
||||||
|
export type Event = AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean } & (
|
||||||
|
| ZoomEventResult
|
||||||
|
| DailyEventResult
|
||||||
|
);
|
||||||
|
|
||||||
export interface EventResult {
|
export interface EventResult {
|
||||||
type: string;
|
type: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
uid: string;
|
uid: string;
|
||||||
createdEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
|
createdEvent?: Event;
|
||||||
updatedEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
|
updatedEvent?: Event;
|
||||||
originalEvent: CalendarEvent;
|
originalEvent: CalendarEvent;
|
||||||
videoCallData?: VideoCallData;
|
videoCallData?: VideoCallData;
|
||||||
}
|
}
|
||||||
|
@ -44,9 +51,6 @@ interface GetLocationRequestFromIntegrationRequest {
|
||||||
location: string;
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
//const to idenfity a daily event location
|
|
||||||
const dailyLocation = "integrations:daily";
|
|
||||||
|
|
||||||
export default class EventManager {
|
export default class EventManager {
|
||||||
calendarCredentials: Array<Credential>;
|
calendarCredentials: Array<Credential>;
|
||||||
videoCredentials: Array<Credential>;
|
videoCredentials: Array<Credential>;
|
||||||
|
@ -61,16 +65,9 @@ export default class EventManager {
|
||||||
this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
|
this.videoCredentials = 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;
|
||||||
const dailyCredential: Credential = {
|
|
||||||
id: +new Date().getTime(),
|
|
||||||
type: "daily_video",
|
|
||||||
key: { apikey: process.env.DAILY_API_KEY },
|
|
||||||
userId: +new Date().getTime(),
|
|
||||||
};
|
|
||||||
if (hasDailyIntegration) {
|
if (hasDailyIntegration) {
|
||||||
this.videoCredentials.push(dailyCredential);
|
this.videoCredentials.push(FAKE_DAILY_CREDENTIAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +78,7 @@ export default class EventManager {
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
*/
|
*/
|
||||||
public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
|
public async create(event: Ensure<CalendarEvent, "language">): Promise<CreateUpdateResult> {
|
||||||
let evt = EventManager.processLocation(event);
|
let evt = EventManager.processLocation(event);
|
||||||
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
|
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
|
||||||
|
|
||||||
|
@ -103,13 +100,14 @@ export default class EventManager {
|
||||||
results = results.concat(await this.createAllCalendarEvents(evt, isDedicated));
|
results = results.concat(await this.createAllCalendarEvents(evt, isDedicated));
|
||||||
|
|
||||||
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
|
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
|
||||||
const isDailyResult = result.type === "daily";
|
|
||||||
let uid = "";
|
let uid = "";
|
||||||
if (isDailyResult && result.createdEvent) {
|
if (result.createdEvent) {
|
||||||
uid = result.createdEvent.name.toString();
|
const isDailyResult = result.type === "daily_video";
|
||||||
|
if (isDailyResult) {
|
||||||
|
uid = (result.createdEvent as DailyEventResult).name.toString();
|
||||||
|
} else {
|
||||||
|
uid = (result.createdEvent as ZoomEventResult).id.toString();
|
||||||
}
|
}
|
||||||
if (!isDailyResult && result.createdEvent) {
|
|
||||||
uid = result.createdEvent.id.toString();
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: result.type,
|
type: result.type,
|
||||||
|
@ -132,11 +130,11 @@ export default class EventManager {
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
*/
|
*/
|
||||||
public async update(event: CalendarEvent): Promise<CreateUpdateResult> {
|
public async update(event: Ensure<CalendarEvent, "uid">): Promise<CreateUpdateResult> {
|
||||||
let evt = EventManager.processLocation(event);
|
let evt = EventManager.processLocation(event);
|
||||||
|
|
||||||
if (!evt.uid) {
|
if (!evt.uid) {
|
||||||
throw new Error("missing uid");
|
throw new Error("You called eventManager.update without an `uid`. This should never happen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get details of existing booking.
|
// Get details of existing booking.
|
||||||
|
@ -163,9 +161,7 @@ export default class EventManager {
|
||||||
throw new Error("booking not found");
|
throw new Error("booking not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDedicated = evt.location
|
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
|
||||||
? EventManager.isDedicatedIntegration(evt.location) || evt.location === dailyLocation
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let results: Array<EventResult> = [];
|
let results: Array<EventResult> = [];
|
||||||
|
|
||||||
|
@ -259,15 +255,11 @@ export default class EventManager {
|
||||||
* @param event
|
* @param event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private createVideoEvent(event: CalendarEvent): Promise<EventResult> {
|
private createVideoEvent(event: Ensure<CalendarEvent, "language">): Promise<EventResult> {
|
||||||
const credential = this.getVideoCredential(event);
|
const credential = this.getVideoCredential(event);
|
||||||
|
|
||||||
const isDaily = event.location === dailyLocation;
|
if (credential) {
|
||||||
|
|
||||||
if (credential && !isDaily) {
|
|
||||||
return createMeeting(credential, event);
|
return createMeeting(credential, event);
|
||||||
} else if (credential && isDaily) {
|
|
||||||
return dailyCreateMeeting(credential, event);
|
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject("No suitable credentials given for the requested integration name.");
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
}
|
}
|
||||||
|
@ -307,9 +299,8 @@ export default class EventManager {
|
||||||
*/
|
*/
|
||||||
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
|
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
|
||||||
const credential = this.getVideoCredential(event);
|
const credential = this.getVideoCredential(event);
|
||||||
const isDaily = event.location === dailyLocation;
|
|
||||||
|
|
||||||
if (credential && !isDaily) {
|
if (credential) {
|
||||||
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
|
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
|
||||||
const evt = { ...event, uid: bookingRef?.uid };
|
const evt = { ...event, uid: bookingRef?.uid };
|
||||||
return updateMeeting(credential, evt).then((returnVal: EventResult) => {
|
return updateMeeting(credential, evt).then((returnVal: EventResult) => {
|
||||||
|
@ -320,13 +311,6 @@ export default class EventManager {
|
||||||
return returnVal;
|
return returnVal;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (credential && isDaily) {
|
|
||||||
const bookingRefUid = booking
|
|
||||||
? booking.references.filter((ref) => ref.type === "daily")[0].uid
|
|
||||||
: null;
|
|
||||||
const evt = { ...event, uid: bookingRefUid };
|
|
||||||
return dailyUpdateMeeting(credential, evt);
|
|
||||||
}
|
|
||||||
return Promise.reject("No suitable credentials given for the requested integration name.");
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -345,7 +329,7 @@ export default class EventManager {
|
||||||
private static isDedicatedIntegration(location: string): boolean {
|
private static isDedicatedIntegration(location: string): boolean {
|
||||||
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
|
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
|
||||||
|
|
||||||
return location === "integrations:zoom" || location === dailyLocation;
|
return location === "integrations:zoom" || location === "integrations:daily";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -385,7 +369,7 @@ export default class EventManager {
|
||||||
* @param event
|
* @param event
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private static processLocation(event: CalendarEvent): CalendarEvent {
|
private static processLocation<T extends CalendarEvent>(event: T): T {
|
||||||
// If location is set to an integration location
|
// If location is set to an integration location
|
||||||
// Build proper transforms for evt object
|
// Build proper transforms for evt object
|
||||||
// Extend evt object with those transformations
|
// Extend evt object with those transformations
|
||||||
|
|
131
lib/integrations/Daily/DailyVideoApiAdapter.ts
Normal file
131
lib/integrations/Daily/DailyVideoApiAdapter.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { Credential } from "@prisma/client";
|
||||||
|
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
import { handleErrorsJson } from "@lib/errors";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { VideoApiAdapter } from "@lib/videoClient";
|
||||||
|
|
||||||
|
export interface DailyReturnType {
|
||||||
|
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
|
||||||
|
id: string;
|
||||||
|
/** Not a real name, just a random generated string ie: "ePR84NQ1bPigp79dDezz" */
|
||||||
|
name: string;
|
||||||
|
api_created: boolean;
|
||||||
|
privacy: "private" | "public";
|
||||||
|
/** https://api-demo.daily.co/ePR84NQ1bPigp79dDezz */
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
config: {
|
||||||
|
nbf: number;
|
||||||
|
exp: number;
|
||||||
|
enable_chat: boolean;
|
||||||
|
enable_knocking: boolean;
|
||||||
|
enable_prejoin_ui: boolean;
|
||||||
|
enable_new_call_ui: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyEventResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api_created: boolean;
|
||||||
|
privacy: string;
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyVideoCallData {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyKey = {
|
||||||
|
apikey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FAKE_DAILY_CREDENTIAL: Credential = {
|
||||||
|
id: +new Date().getTime(),
|
||||||
|
type: "daily_video",
|
||||||
|
key: { apikey: process.env.DAILY_API_KEY },
|
||||||
|
userId: +new Date().getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
|
||||||
|
const dailyApiToken = (credential.key as DailyKey).apikey;
|
||||||
|
|
||||||
|
function postToDailyAPI(endpoint: string, body: Record<string, any>) {
|
||||||
|
return fetch("https://api.daily.co/v1" + endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + dailyApiToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent) {
|
||||||
|
if (!event.uid) {
|
||||||
|
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||||
|
}
|
||||||
|
const response = await postToDailyAPI(endpoint, translateEvent(event));
|
||||||
|
const dailyEvent: DailyReturnType = await handleErrorsJson(response);
|
||||||
|
const res = await postToDailyAPI("/meeting-tokens", {
|
||||||
|
properties: { room_name: dailyEvent.name, is_owner: true },
|
||||||
|
});
|
||||||
|
const meetingToken: { token: string } = await handleErrorsJson(res);
|
||||||
|
await prisma.dailyEventReference.create({
|
||||||
|
data: {
|
||||||
|
dailyurl: dailyEvent.url,
|
||||||
|
dailytoken: meetingToken.token,
|
||||||
|
booking: {
|
||||||
|
connect: {
|
||||||
|
uid: event.uid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return dailyEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
|
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||||
|
// added a 1 hour buffer for room expiration and room entry
|
||||||
|
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
|
||||||
|
const nbf = Math.round(new Date(event.startTime).getTime() / 1000) - 60 * 60;
|
||||||
|
return {
|
||||||
|
privacy: "private",
|
||||||
|
properties: {
|
||||||
|
enable_new_call_ui: true,
|
||||||
|
enable_prejoin_ui: true,
|
||||||
|
enable_knocking: true,
|
||||||
|
enable_screenshare: true,
|
||||||
|
enable_chat: true,
|
||||||
|
exp: exp,
|
||||||
|
nbf: nbf,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Daily doesn't need to return busy times, so we return empty */
|
||||||
|
getAvailability: () => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
createMeeting: async (event: CalendarEvent) => createOrUpdateMeeting("/rooms", event),
|
||||||
|
deleteMeeting: (uid: string) =>
|
||||||
|
fetch("https://api.daily.co/v1/rooms/" + uid, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + dailyApiToken,
|
||||||
|
},
|
||||||
|
}).then(handleErrorsJson),
|
||||||
|
updateMeeting: (uid: string, event: CalendarEvent) => createOrUpdateMeeting("/rooms/" + uid, event),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyVideoApiAdapter;
|
205
lib/integrations/Zoom/ZoomVideoApiAdapter.ts
Normal file
205
lib/integrations/Zoom/ZoomVideoApiAdapter.ts
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
import { Credential } from "@prisma/client";
|
||||||
|
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { VideoApiAdapter } from "@lib/videoClient";
|
||||||
|
|
||||||
|
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
|
||||||
|
export interface ZoomEventResult {
|
||||||
|
created_at: string;
|
||||||
|
duration: number;
|
||||||
|
host_id: string;
|
||||||
|
id: number;
|
||||||
|
join_url: string;
|
||||||
|
settings: {
|
||||||
|
alternative_hosts: string;
|
||||||
|
approval_type: number;
|
||||||
|
audio: string;
|
||||||
|
auto_recording: string;
|
||||||
|
close_registration: boolean;
|
||||||
|
cn_meeting: boolean;
|
||||||
|
enforce_login: boolean;
|
||||||
|
enforce_login_domains: string;
|
||||||
|
global_dial_in_countries: string[];
|
||||||
|
global_dial_in_numbers: {
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
country_name: string;
|
||||||
|
number: string;
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
breakout_room: {
|
||||||
|
enable: boolean;
|
||||||
|
rooms: {
|
||||||
|
name: string;
|
||||||
|
participants: string[];
|
||||||
|
}[];
|
||||||
|
host_video: boolean;
|
||||||
|
in_meeting: boolean;
|
||||||
|
join_before_host: boolean;
|
||||||
|
mute_upon_entry: boolean;
|
||||||
|
participant_video: boolean;
|
||||||
|
registrants_confirmation_email: boolean;
|
||||||
|
use_pmi: boolean;
|
||||||
|
waiting_room: boolean;
|
||||||
|
watermark: boolean;
|
||||||
|
registrants_email_notification: boolean;
|
||||||
|
};
|
||||||
|
start_time: string;
|
||||||
|
start_url: string;
|
||||||
|
status: string;
|
||||||
|
timezone: string;
|
||||||
|
topic: string;
|
||||||
|
type: number;
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomToken {
|
||||||
|
scope: "meeting:write";
|
||||||
|
expires_in: number;
|
||||||
|
token_type: "bearer";
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomAuth = (credential: Credential) => {
|
||||||
|
const credentialKey = credential.key as unknown as ZoomToken;
|
||||||
|
const isExpired = (expiryDate: number) => expiryDate < +new Date();
|
||||||
|
const authHeader =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
||||||
|
|
||||||
|
const refreshAccessToken = (refreshToken: string) =>
|
||||||
|
fetch("https://zoom.us/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(handleErrorsJson)
|
||||||
|
.then(async (responseBody) => {
|
||||||
|
// Store new tokens in database.
|
||||||
|
await prisma.credential.update({
|
||||||
|
where: {
|
||||||
|
id: credential.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
key: responseBody,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
credentialKey.access_token = responseBody.access_token;
|
||||||
|
credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
|
return credentialKey.access_token;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getToken: () =>
|
||||||
|
!isExpired(credentialKey.expires_in)
|
||||||
|
? Promise.resolve(credentialKey.access_token)
|
||||||
|
: refreshAccessToken(credentialKey.refresh_token),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
|
||||||
|
const auth = zoomAuth(credential);
|
||||||
|
|
||||||
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
|
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||||
|
return {
|
||||||
|
topic: event.title,
|
||||||
|
type: 2, // Means that this is a scheduled meeting
|
||||||
|
start_time: event.startTime,
|
||||||
|
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
|
||||||
|
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
||||||
|
timezone: event.attendees[0].timeZone,
|
||||||
|
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
||||||
|
agenda: event.description,
|
||||||
|
settings: {
|
||||||
|
host_video: true,
|
||||||
|
participant_video: true,
|
||||||
|
cn_meeting: false, // TODO: true if host meeting in China
|
||||||
|
in_meeting: false, // TODO: true if host meeting in India
|
||||||
|
join_before_host: true,
|
||||||
|
mute_upon_entry: false,
|
||||||
|
watermark: false,
|
||||||
|
use_pmi: false,
|
||||||
|
approval_type: 2,
|
||||||
|
audio: "both",
|
||||||
|
auto_recording: "none",
|
||||||
|
enforce_login: false,
|
||||||
|
registrants_email_notification: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAvailability: () => {
|
||||||
|
return auth
|
||||||
|
.getToken()
|
||||||
|
.then(
|
||||||
|
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
||||||
|
(accessToken) =>
|
||||||
|
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
|
||||||
|
method: "get",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + accessToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(handleErrorsJson)
|
||||||
|
.then((responseBody) => {
|
||||||
|
return responseBody.meetings.map((meeting: { start_time: string; duration: number }) => ({
|
||||||
|
start: meeting.start_time,
|
||||||
|
end: new Date(
|
||||||
|
new Date(meeting.start_time).getTime() + meeting.duration * 60000
|
||||||
|
).toISOString(),
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
/* Prevents booking failure when Zoom Token is expired */
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createMeeting: (event: CalendarEvent) =>
|
||||||
|
auth.getToken().then((accessToken) =>
|
||||||
|
fetch("https://api.zoom.us/v2/users/me/meetings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + accessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(translateEvent(event)),
|
||||||
|
}).then(handleErrorsJson)
|
||||||
|
),
|
||||||
|
deleteMeeting: (uid: string) =>
|
||||||
|
auth.getToken().then((accessToken) =>
|
||||||
|
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + accessToken,
|
||||||
|
},
|
||||||
|
}).then(handleErrorsRaw)
|
||||||
|
),
|
||||||
|
updateMeeting: (uid: string, event: CalendarEvent) =>
|
||||||
|
auth.getToken().then((accessToken: string) =>
|
||||||
|
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer " + accessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(translateEvent(event)),
|
||||||
|
}).then(handleErrorsRaw)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoomVideoApiAdapter;
|
|
@ -2,12 +2,17 @@ import { Booking } from "@prisma/client";
|
||||||
|
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
|
|
||||||
|
export type BookingConfirmBody = {
|
||||||
|
confirmed: boolean;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type BookingCreateBody = {
|
export type BookingCreateBody = {
|
||||||
email: string;
|
email: string;
|
||||||
end: string;
|
end: string;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
guests: string[];
|
guests: string[];
|
||||||
location?: LocationType;
|
location: LocationType;
|
||||||
name: string;
|
name: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
rescheduleUid?: string;
|
rescheduleUid?: string;
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
export type RequiredNotNull<T> = {
|
export type Ensure<T, K extends keyof T> = Omit<T, K> & {
|
||||||
[P in keyof T]: NonNullable<T[P]>;
|
[EK in K]-?: NonNullable<T[EK]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Ensure<T, K extends keyof T> = T & RequiredNotNull<Pick<T, K>>;
|
|
||||||
|
|
|
@ -3,29 +3,24 @@ import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import CalEventParser from "@lib/CalEventParser";
|
import CalEventParser from "@lib/CalEventParser";
|
||||||
|
import "@lib/emails/EventMail";
|
||||||
import { getIntegrationName } from "@lib/emails/helpers";
|
import { getIntegrationName } from "@lib/emails/helpers";
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
|
import { AdditionInformation, CalendarEvent, EntryPoint } from "./calendarClient";
|
||||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||||
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
|
||||||
import prisma from "./prisma";
|
import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter";
|
||||||
|
import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter";
|
||||||
|
import { Ensure } from "./types/utils";
|
||||||
|
|
||||||
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
|
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
export interface ZoomToken {
|
|
||||||
scope: "meeting:write";
|
|
||||||
expires_in: number;
|
|
||||||
token_type: "bearer";
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoCallData {
|
export interface VideoCallData {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -33,176 +28,27 @@ export interface VideoCallData {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorsJson(response: Response) {
|
type EventBusyDate = Record<"start" | "end", Date>;
|
||||||
if (!response.ok) {
|
|
||||||
response.json().then(console.log);
|
|
||||||
throw Error(response.statusText);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleErrorsRaw(response: Response) {
|
export interface VideoApiAdapter {
|
||||||
if (!response.ok) {
|
|
||||||
response.text().then(console.log);
|
|
||||||
throw Error(response.statusText);
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
const zoomAuth = (credential: Credential) => {
|
|
||||||
const credentialKey = credential.key as unknown as ZoomToken;
|
|
||||||
const isExpired = (expiryDate: number) => expiryDate < +new Date();
|
|
||||||
const authHeader =
|
|
||||||
"Basic " +
|
|
||||||
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
|
||||||
|
|
||||||
const refreshAccessToken = (refreshToken: string) =>
|
|
||||||
fetch("https://zoom.us/oauth/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(handleErrorsJson)
|
|
||||||
.then(async (responseBody) => {
|
|
||||||
// Store new tokens in database.
|
|
||||||
await prisma.credential.update({
|
|
||||||
where: {
|
|
||||||
id: credential.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
key: responseBody,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
credentialKey.access_token = responseBody.access_token;
|
|
||||||
credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
|
||||||
return credentialKey.access_token;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
getToken: () =>
|
|
||||||
!isExpired(credentialKey.expires_in)
|
|
||||||
? Promise.resolve(credentialKey.access_token)
|
|
||||||
: refreshAccessToken(credentialKey.refresh_token),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VideoApiAdapter {
|
|
||||||
createMeeting(event: CalendarEvent): Promise<any>;
|
createMeeting(event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
updateMeeting(uid: string, event: CalendarEvent): Promise<any>;
|
updateMeeting(uid: string, event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
deleteMeeting(uid: string): Promise<unknown>;
|
deleteMeeting(uid: string): Promise<unknown>;
|
||||||
|
|
||||||
getAvailability(dateFrom: string, dateTo: string): Promise<any>;
|
getAvailability(dateFrom?: string, dateTo?: string): Promise<EventBusyDate[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZoomVideo = (credential: Credential): VideoApiAdapter => {
|
|
||||||
const auth = zoomAuth(credential);
|
|
||||||
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
|
||||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
|
||||||
return {
|
|
||||||
topic: event.title,
|
|
||||||
type: 2, // Means that this is a scheduled meeting
|
|
||||||
start_time: event.startTime,
|
|
||||||
duration: (new Date(event.endTime).getTime() - new Date(event.startTime).getTime()) / 60000,
|
|
||||||
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
|
||||||
timezone: event.attendees[0].timeZone,
|
|
||||||
//password: "string", TODO: Should we use a password? Maybe generate a random one?
|
|
||||||
agenda: event.description,
|
|
||||||
settings: {
|
|
||||||
host_video: true,
|
|
||||||
participant_video: true,
|
|
||||||
cn_meeting: false, // TODO: true if host meeting in China
|
|
||||||
in_meeting: false, // TODO: true if host meeting in India
|
|
||||||
join_before_host: true,
|
|
||||||
mute_upon_entry: false,
|
|
||||||
watermark: false,
|
|
||||||
use_pmi: false,
|
|
||||||
approval_type: 2,
|
|
||||||
audio: "both",
|
|
||||||
auto_recording: "none",
|
|
||||||
enforce_login: false,
|
|
||||||
registrants_email_notification: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getAvailability: () => {
|
|
||||||
return auth
|
|
||||||
.getToken()
|
|
||||||
.then(
|
|
||||||
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
|
|
||||||
(accessToken) =>
|
|
||||||
fetch("https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300", {
|
|
||||||
method: "get",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + accessToken,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(handleErrorsJson)
|
|
||||||
.then((responseBody) => {
|
|
||||||
return responseBody.meetings.map((meeting) => ({
|
|
||||||
start: meeting.start_time,
|
|
||||||
end: new Date(
|
|
||||||
new Date(meeting.start_time).getTime() + meeting.duration * 60000
|
|
||||||
).toISOString(),
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
/* Prevents booking failure when Zoom Token is expired */
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
createMeeting: (event: CalendarEvent) =>
|
|
||||||
auth.getToken().then((accessToken) =>
|
|
||||||
fetch("https://api.zoom.us/v2/users/me/meetings", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + accessToken,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(translateEvent(event)),
|
|
||||||
}).then(handleErrorsJson)
|
|
||||||
),
|
|
||||||
deleteMeeting: (uid: string) =>
|
|
||||||
auth.getToken().then((accessToken) =>
|
|
||||||
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + accessToken,
|
|
||||||
},
|
|
||||||
}).then(handleErrorsRaw)
|
|
||||||
),
|
|
||||||
updateMeeting: (uid: string, event: CalendarEvent) =>
|
|
||||||
auth.getToken().then((accessToken) =>
|
|
||||||
fetch("https://api.zoom.us/v2/meetings/" + uid, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + accessToken,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(translateEvent(event)),
|
|
||||||
}).then(handleErrorsRaw)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// factory
|
// factory
|
||||||
const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] =>
|
const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
|
||||||
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
|
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
|
||||||
switch (cred.type) {
|
switch (cred.type) {
|
||||||
case "zoom_video":
|
case "zoom_video":
|
||||||
acc.push(ZoomVideo(cred));
|
acc.push(ZoomVideoApiAdapter(cred));
|
||||||
|
break;
|
||||||
|
case "daily_video":
|
||||||
|
acc.push(DailyVideoApiAdapter(cred));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
@ -211,11 +57,14 @@ const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] =>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) =>
|
const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) =>
|
||||||
Promise.all(videoIntegrations(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), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
const createMeeting = async (
|
||||||
|
credential: Credential,
|
||||||
|
calEvent: Ensure<CalendarEvent, "language">
|
||||||
|
): Promise<EventResult> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
|
|
||||||
|
@ -227,20 +76,35 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const creationResult = await videoIntegrations([credential])[0]
|
const videoAdapters = getVideoAdapters([credential]);
|
||||||
.createMeeting(calEvent)
|
const [firstVideoAdapter] = videoAdapters;
|
||||||
.catch((e) => {
|
const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => {
|
||||||
log.error("createMeeting failed", e, calEvent);
|
log.error("createMeeting failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!createdMeeting) {
|
||||||
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
|
uid,
|
||||||
|
originalEvent: calEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const videoCallData: VideoCallData = {
|
const videoCallData: VideoCallData = {
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
id: creationResult.id,
|
id: createdMeeting.id,
|
||||||
password: creationResult.password,
|
password: createdMeeting.password,
|
||||||
url: creationResult.join_url,
|
url: createdMeeting.join_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (credential.type === "daily_video") {
|
||||||
|
videoCallData.type = "Daily.co Video";
|
||||||
|
videoCallData.id = createdMeeting.name;
|
||||||
|
videoCallData.url = process.env.BASE_URL + "/call/" + uid;
|
||||||
|
}
|
||||||
|
|
||||||
const entryPoint: EntryPoint = {
|
const entryPoint: EntryPoint = {
|
||||||
entryPointType: getIntegrationName(videoCallData),
|
entryPointType: getIntegrationName(videoCallData),
|
||||||
uri: videoCallData.url,
|
uri: videoCallData.url,
|
||||||
|
@ -261,7 +125,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!createdMeeting || !createdMeeting.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
|
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
|
@ -274,7 +138,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
success,
|
success,
|
||||||
uid,
|
uid,
|
||||||
createdEvent: creationResult,
|
createdEvent: createdMeeting,
|
||||||
originalEvent: calEvent,
|
originalEvent: calEvent,
|
||||||
videoCallData: videoCallData,
|
videoCallData: videoCallData,
|
||||||
};
|
};
|
||||||
|
@ -289,17 +153,26 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!calEvent.uid) {
|
||||||
|
throw new Error("You can't update an meeting without it's UID.");
|
||||||
|
}
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const updateResult =
|
const [firstVideoAdapter] = getVideoAdapters([credential]);
|
||||||
credential && calEvent.uid
|
const updatedMeeting = await firstVideoAdapter.updateMeeting(calEvent.uid, calEvent).catch((e) => {
|
||||||
? await videoIntegrations([credential])[0]
|
|
||||||
.updateMeeting(calEvent.uid, calEvent)
|
|
||||||
.catch((e) => {
|
|
||||||
log.error("updateMeeting failed", e, calEvent);
|
log.error("updateMeeting failed", e, calEvent);
|
||||||
success = false;
|
success = false;
|
||||||
})
|
});
|
||||||
: null;
|
|
||||||
|
if (!updatedMeeting) {
|
||||||
|
return {
|
||||||
|
type: credential.type,
|
||||||
|
success,
|
||||||
|
uid: calEvent.uid,
|
||||||
|
originalEvent: calEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const emailEvent = { ...calEvent, uid: newUid };
|
const emailEvent = { ...calEvent, uid: newUid };
|
||||||
|
|
||||||
|
@ -310,7 +183,7 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updatedMeeting.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
|
@ -323,14 +196,14 @@ const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): P
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
success,
|
success,
|
||||||
uid: newUid,
|
uid: newUid,
|
||||||
updatedEvent: updateResult,
|
updatedEvent: updatedMeeting,
|
||||||
originalEvent: calEvent,
|
originalEvent: calEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
|
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
|
||||||
if (credential) {
|
if (credential) {
|
||||||
return videoIntegrations([credential])[0].deleteMeeting(uid);
|
return getVideoAdapters([credential])[0].deleteMeeting(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
|
|
|
@ -97,6 +97,7 @@
|
||||||
"zod": "^3.8.2"
|
"zod": "^3.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
||||||
"@trivago/prettier-plugin-sort-imports": "2.0.4",
|
"@trivago/prettier-plugin-sort-imports": "2.0.4",
|
||||||
"@types/accept-language-parser": "1.5.2",
|
"@types/accept-language-parser": "1.5.2",
|
||||||
"@types/async": "^3.2.7",
|
"@types/async": "^3.2.7",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// import { getBusyVideoTimes } from "@lib/videoClient";
|
// import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
@ -6,8 +7,6 @@ import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import { Prisma } from ".prisma/client";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = asStringOrNull(req.query.user);
|
const user = asStringOrNull(req.query.user);
|
||||||
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
|
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||||
import EventManager from "@lib/events/EventManager";
|
import EventManager from "@lib/events/EventManager";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
import { BookingConfirmBody } from "@lib/types/booking";
|
||||||
|
|
||||||
import { getTranslation } from "@server/lib/i18n";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
|
@ -18,7 +19,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookingId = req.body.id;
|
const reqBody = req.body as BookingConfirmBody;
|
||||||
|
const bookingId = reqBody.id;
|
||||||
|
|
||||||
if (!bookingId) {
|
if (!bookingId) {
|
||||||
return res.status(400).json({ message: "bookingId missing" });
|
return res.status(400).json({ message: "bookingId missing" });
|
||||||
}
|
}
|
||||||
|
@ -60,7 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking || booking.userId != currentUser.id) {
|
if (!booking || booking.userId !== currentUser.id) {
|
||||||
return res.status(404).json({ message: "booking not found" });
|
return res.status(404).json({ message: "booking not found" });
|
||||||
}
|
}
|
||||||
if (booking.confirmed) {
|
if (booking.confirmed) {
|
||||||
|
@ -75,12 +78,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: { email: currentUser.email, name: currentUser.name!, timeZone: currentUser.timeZone },
|
organizer: { email: currentUser.email, name: currentUser.name!, timeZone: currentUser.timeZone },
|
||||||
attendees: booking.attendees,
|
attendees: booking.attendees,
|
||||||
location: booking.location,
|
location: booking.location ?? "",
|
||||||
uid: booking.uid,
|
uid: booking.uid,
|
||||||
language: t,
|
language: t,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.body.confirmed) {
|
if (reqBody.confirmed) {
|
||||||
const eventManager = new EventManager(currentUser.credentials);
|
const eventManager = new EventManager(currentUser.credentials);
|
||||||
const scheduleResult = await eventManager.create(evt);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||||
import { getErrorFromUnknown } from "@lib/errors";
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import { getEventName } from "@lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
|
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { BookingCreateBody } from "@lib/types/booking";
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
|
@ -329,6 +329,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
let booking: Booking | null = null;
|
let booking: Booking | null = null;
|
||||||
try {
|
try {
|
||||||
booking = await createBooking();
|
booking = await createBooking();
|
||||||
|
evt.uid = booking.uid;
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
const err = getErrorFromUnknown(_err);
|
const err = getErrorFromUnknown(_err);
|
||||||
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
|
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
|
||||||
|
@ -431,7 +432,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid };
|
const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid };
|
||||||
const updateResults: CreateUpdateResult = await eventManager.update(eventManagerCalendarEvent);
|
const updateResults = await eventManager.update(eventManagerCalendarEvent);
|
||||||
|
|
||||||
results = updateResults.results;
|
results = updateResults.results;
|
||||||
referencesToCreate = updateResults.referencesToCreate;
|
referencesToCreate = updateResults.referencesToCreate;
|
||||||
|
@ -444,9 +445,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
log.error(`Booking ${user.name} failed`, error, results);
|
log.error(`Booking ${user.name} failed`, error, results);
|
||||||
}
|
}
|
||||||
|
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||||
|
// Create a booking
|
||||||
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
const createResults = await eventManager.create(evt);
|
||||||
|
|
||||||
results = createResults.results;
|
results = createResults.results;
|
||||||
referencesToCreate = createResults.referencesToCreate;
|
referencesToCreate = createResults.referencesToCreate;
|
||||||
|
@ -461,46 +464,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//for Daily.co video calls will grab the meeting token for the call
|
|
||||||
const isDaily = evt.location === "integrations:daily";
|
|
||||||
|
|
||||||
let dailyEvent: DailyReturnType;
|
|
||||||
|
|
||||||
if (!rescheduleUid) {
|
|
||||||
dailyEvent = results.filter((ref) => ref.type === "daily")[0]?.createdEvent as DailyReturnType;
|
|
||||||
} else {
|
|
||||||
dailyEvent = results.filter((ref) => ref.type === "daily_video")[0]?.updatedEvent as DailyReturnType;
|
|
||||||
}
|
|
||||||
|
|
||||||
let meetingToken;
|
|
||||||
if (isDaily) {
|
|
||||||
const response = await fetch("https://api.daily.co/v1/meeting-tokens", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ properties: { room_name: dailyEvent.name, is_owner: true } }),
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer " + process.env.DAILY_API_KEY,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
meetingToken = await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
//for Daily.co video calls will update the dailyEventReference table
|
|
||||||
|
|
||||||
if (isDaily) {
|
|
||||||
await prisma.dailyEventReference.create({
|
|
||||||
data: {
|
|
||||||
dailyurl: dailyEvent.url,
|
|
||||||
dailytoken: meetingToken.token,
|
|
||||||
booking: {
|
|
||||||
connect: {
|
|
||||||
uid: booking.uid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
if (eventType.requiresConfirmation && !rescheduleUid) {
|
||||||
await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail();
|
await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { refund } from "@ee/lib/stripe/server";
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
|
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
|
||||||
|
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { deleteMeeting } from "@lib/videoClient";
|
import { deleteMeeting } from "@lib/videoClient";
|
||||||
import sendPayload from "@lib/webhooks/sendPayload";
|
import sendPayload from "@lib/webhooks/sendPayload";
|
||||||
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
||||||
|
|
||||||
import { dailyDeleteMeeting } from "../../lib/dailyVideoClient";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// just bail if it not a DELETE
|
// just bail if it not a DELETE
|
||||||
if (req.method !== "DELETE" && req.method !== "POST") {
|
if (req.method !== "DELETE" && req.method !== "POST") {
|
||||||
return res.status(405).end();
|
return res.status(405).end();
|
||||||
|
@ -48,7 +50,6 @@ export default async function handler(req, res) {
|
||||||
},
|
},
|
||||||
payment: true,
|
payment: true,
|
||||||
paid: true,
|
paid: true,
|
||||||
location: true,
|
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
|
@ -62,32 +63,45 @@ export default async function handler(req, res) {
|
||||||
return res.status(404).end();
|
return res.status(404).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!session || session.user?.id != bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) {
|
if ((!session || session.user?.id !== bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) {
|
||||||
return res.status(403).json({ message: "Cannot cancel past events" });
|
return res.status(403).json({ message: "Cannot cancel past events" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bookingToDelete.userId) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
const organizer = await prisma.user.findFirst({
|
const organizer = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: bookingToDelete.userId as number,
|
id: bookingToDelete.userId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
},
|
},
|
||||||
|
rejectOnNotFound: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
|
|
||||||
const evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: bookingToDelete?.title,
|
type: bookingToDelete?.title,
|
||||||
title: bookingToDelete?.title,
|
title: bookingToDelete?.title,
|
||||||
description: bookingToDelete?.description || "",
|
description: bookingToDelete?.description || "",
|
||||||
startTime: bookingToDelete?.startTime.toString(),
|
startTime: bookingToDelete?.startTime.toString(),
|
||||||
endTime: bookingToDelete?.endTime.toString(),
|
endTime: bookingToDelete?.endTime.toString(),
|
||||||
organizer: organizer,
|
organizer: {
|
||||||
|
email: organizer.email,
|
||||||
|
name: organizer.name ?? "Nameless",
|
||||||
|
timeZone: organizer.timeZone,
|
||||||
|
},
|
||||||
attendees: bookingToDelete?.attendees.map((attendee) => {
|
attendees: bookingToDelete?.attendees.map((attendee) => {
|
||||||
const retObj = { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
|
const retObj = { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
|
||||||
return retObj;
|
return retObj;
|
||||||
}),
|
}),
|
||||||
|
uid: bookingToDelete?.uid,
|
||||||
|
language: t,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hook up the webhook logic here
|
// Hook up the webhook logic here
|
||||||
|
@ -112,6 +126,10 @@ export default async function handler(req, res) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (bookingToDelete.location === "integrations:daily") {
|
||||||
|
bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL);
|
||||||
|
}
|
||||||
|
|
||||||
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
|
||||||
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
||||||
if (bookingRefUid) {
|
if (bookingRefUid) {
|
||||||
|
@ -121,13 +139,6 @@ export default async function handler(req, res) {
|
||||||
return await deleteMeeting(credential, bookingRefUid);
|
return await deleteMeeting(credential, bookingRefUid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//deleting a Daily meeting
|
|
||||||
|
|
||||||
const isDaily = bookingToDelete.location === "integrations:daily";
|
|
||||||
const bookingUID = bookingToDelete.references.filter((ref) => ref.type === "daily")[0]?.uid;
|
|
||||||
if (isDaily) {
|
|
||||||
return await dailyDeleteMeeting(credential, bookingUID);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bookingToDelete && bookingToDelete.paid) {
|
if (bookingToDelete && bookingToDelete.paid) {
|
||||||
|
@ -144,6 +155,8 @@ export default async function handler(req, res) {
|
||||||
},
|
},
|
||||||
attendees: bookingToDelete.attendees,
|
attendees: bookingToDelete.attendees,
|
||||||
location: bookingToDelete.location ?? "",
|
location: bookingToDelete.location ?? "",
|
||||||
|
uid: bookingToDelete.uid ?? "",
|
||||||
|
language: t,
|
||||||
};
|
};
|
||||||
await refund(bookingToDelete, evt);
|
await refund(bookingToDelete, evt);
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail";
|
import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||||
if (process.env.CRON_API_KEY !== apiKey) {
|
if (process.env.CRON_API_KEY !== apiKey) {
|
||||||
|
@ -31,6 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
select: {
|
select: {
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
location: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
|
@ -59,10 +62,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user });
|
console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
const evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: booking.title,
|
type: booking.title,
|
||||||
title: booking.title,
|
title: booking.title,
|
||||||
description: booking.description || undefined,
|
description: booking.description || undefined,
|
||||||
|
location: booking.location ?? "",
|
||||||
startTime: booking.startTime.toISOString(),
|
startTime: booking.startTime.toISOString(),
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: {
|
organizer: {
|
||||||
|
@ -71,9 +76,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
},
|
},
|
||||||
attendees: booking.attendees,
|
attendees: booking.attendees,
|
||||||
|
uid: booking.uid,
|
||||||
|
language: t,
|
||||||
};
|
};
|
||||||
|
|
||||||
await new EventOrganizerRequestReminderMail(evt, booking.uid).sendEmail();
|
await new EventOrganizerRequestReminderMail(evt).sendEmail();
|
||||||
await prisma.reminderMail.create({
|
await prisma.reminderMail.create({
|
||||||
data: {
|
data: {
|
||||||
referenceId: booking.id,
|
referenceId: booking.id,
|
||||||
|
|
|
@ -1113,6 +1113,11 @@
|
||||||
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
|
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
|
||||||
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
|
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
|
||||||
|
|
||||||
|
"@microsoft/microsoft-graph-types-beta@0.15.0-preview":
|
||||||
|
version "0.15.0-preview"
|
||||||
|
resolved "https://registry.yarnpkg.com/@microsoft/microsoft-graph-types-beta/-/microsoft-graph-types-beta-0.15.0-preview.tgz#fed0a99be4e1151d566cf063f024913fb48640cd"
|
||||||
|
integrity sha512-M0zC4t3pmkDz7Qsjx/iZcS+zRuckzsbHESvT9qjLFv64RUgkRmDdmhcvPMiUqUzw/h3YxfYAq9MU+XWjROk/dg==
|
||||||
|
|
||||||
"@napi-rs/triples@^1.0.3":
|
"@napi-rs/triples@^1.0.3":
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.0.3.tgz#76d6d0c3f4d16013c61e45dfca5ff1e6c31ae53c"
|
resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.0.3.tgz#76d6d0c3f4d16013c61e45dfca5ff1e6c31ae53c"
|
||||||
|
|
Loading…
Reference in a new issue