diff --git a/.env.example b/.env.example index 3bb28336..de63c520 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,11 @@ ZOOM_CLIENT_SECRET= DAILY_API_KEY= DAILY_SCALE_PLAN='' +# Used for the Tandem integration -- contact support@tandem.chat to for API access. +TANDEM_CLIENT_ID="" +TANDEM_CLIENT_SECRET="" +TANDEM_BASE_URL="https://tandem.chat" + # E-mail settings # Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to diff --git a/calendso.yaml b/calendso.yaml index 9d942b30..f27bc5c1 100644 --- a/calendso.yaml +++ b/calendso.yaml @@ -590,6 +590,12 @@ paths: title: Zoom imageSrc: integrations/zoom.svg description: Video Conferencing + - installed: true + type: tandem_video + credential: null + title: Tandem + imageSrc: integrations/tandem.svg + description: Virtual Office | Video Conferencing - installed: true type: caldav_calendar credential: null @@ -753,6 +759,18 @@ paths: summary: Gets and stores the OAuth token tags: - Integrations + /api/integrations/tandemvideo/add: + get: + description: Gets the OAuth URL for a Tandem integration. + summary: Gets the OAuth URL + tags: + - Integrations + /api/integrations/tandemvideo/callback: + post: + description: Gets and stores the OAuth token for a Tandem integration. + summary: Gets and stores the OAuth token + tags: + - Integrations /api/user/profile: patch: description: Updates a user's profile. diff --git a/components/booking/pages/BookingPage.tsx b/components/booking/pages/BookingPage.tsx index 2f9ab9f5..46d4ef85 100644 --- a/components/booking/pages/BookingPage.tsx +++ b/components/booking/pages/BookingPage.tsx @@ -145,6 +145,7 @@ const BookingPage = (props: BookingPageProps) => { [LocationType.Zoom]: "Zoom Video", [LocationType.Daily]: "Daily.co Video", [LocationType.Huddle01]: "Huddle01 Video", + [LocationType.Tandem]: "Tandem Video", }; const defaultValues = () => { diff --git a/environment.d.ts b/environment.d.ts index c5a965c9..f0d36bee 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -26,5 +26,8 @@ declare namespace NodeJS { readonly PAYMENT_FEE_FIXED: number | undefined; readonly CALENDSO_ENCRYPTION_KEY: string | undefined; readonly NEXT_PUBLIC_INTERCOM_APP_ID: string | undefined; + readonly TANDEM_CLIENT_ID: string | undefined; + readonly TANDEM_CLIENT_SECRET: string | undefined; + readonly TANDEM_BASE_URL: string | undefined; } } diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index 379bdcda..14cf3238 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -53,8 +53,12 @@ export const isHuddle01 = (location: string): boolean => { return location === "integrations:huddle01"; }; +export const isTandem = (location: string): boolean => { + return location === "integrations:tandem"; +}; + export const isDedicatedIntegration = (location: string): boolean => { - return isZoom(location) || isDaily(location) || isHuddle01(location); + return isZoom(location) || isDaily(location) || isHuddle01(location) || isTandem(location); }; export const getLocationRequestFromIntegration = (location: string) => { @@ -62,7 +66,8 @@ export const getLocationRequestFromIntegration = (location: string) => { location === LocationType.GoogleMeet.valueOf() || location === LocationType.Zoom.valueOf() || location === LocationType.Daily.valueOf() || - location === LocationType.Huddle01.valueOf() + location === LocationType.Huddle01.valueOf() || + location === LocationType.Tandem.valueOf() ) { const requestId = uuidv5(location, uuidv5.URL); diff --git a/lib/integrations.ts b/lib/integrations.ts index 3c086197..03bb519c 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -16,6 +16,8 @@ export function getIntegrationName(name: string) { return "Daily"; case "huddle01_video": return "Huddle01"; + case "tandem_video": + return "Tandem"; } } diff --git a/lib/integrations/Tandem/TandemVideoApiAdapter.ts b/lib/integrations/Tandem/TandemVideoApiAdapter.ts new file mode 100644 index 00000000..f85d5c73 --- /dev/null +++ b/lib/integrations/Tandem/TandemVideoApiAdapter.ts @@ -0,0 +1,143 @@ +import { Credential } from "@prisma/client"; + +import { handleErrorsJson, handleErrorsRaw } from "@lib/errors"; +import { PartialReference } from "@lib/events/EventManager"; +import prisma from "@lib/prisma"; +import { VideoApiAdapter, VideoCallData } from "@lib/videoClient"; + +import { CalendarEvent } from "../calendar/interfaces/Calendar"; + +interface TandemToken { + expires_in?: number; + expiry_date: number; + refresh_token: string; + token_type: "Bearer"; + access_token: string; +} + +const client_id = process.env.TANDEM_CLIENT_ID as string; +const client_secret = process.env.TANDEM_CLIENT_SECRET as string; +const TANDEM_BASE_URL = process.env.TANDEM_BASE_URL as string; + +const tandemAuth = (credential: Credential) => { + const credentialKey = credential.key as unknown as TandemToken; + const isTokenValid = (token: TandemToken) => token && token.access_token && token.expiry_date < Date.now(); + + const refreshAccessToken = (refreshToken: string) => { + fetch(`${TANDEM_BASE_URL}/api/v1/oauth/v2/token`, { + method: "POST", + body: new URLSearchParams({ + client_id, + client_secret, + code: refreshToken, + }), + }) + .then(handleErrorsJson) + .then(async (responseBody) => { + // set expiry date as offset from current time. + responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); + delete responseBody.expires_in; + // Store new tokens in database. + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: responseBody, + }, + }); + credentialKey.expiry_date = responseBody.expiry_date; + credentialKey.access_token = responseBody.access_token; + credentialKey.refresh_token = responseBody.refresh_token; + return credentialKey.access_token; + }); + }; + + return { + getToken: () => + !isTokenValid(credentialKey) + ? Promise.resolve(credentialKey.access_token) + : refreshAccessToken(credentialKey.refresh_token), + }; +}; + +const TandemVideoApiAdapter = (credential: Credential): VideoApiAdapter => { + const auth = tandemAuth(credential); + + const _parseDate = (date: string) => { + return Date.parse(date) / 1000; + }; + + const _translateEvent = (event: CalendarEvent, param: string): string => { + return JSON.stringify({ + [param]: { + title: event.title, + starts_at: _parseDate(event.startTime), + ends_at: _parseDate(event.endTime), + description: event.description || "", + conference_solution: "tandem", + type: 3, + }, + }); + }; + + const _translateResult = (result: { data: { id: string; event_link: string } }) => { + return { + type: "tandem_video", + id: result.data.id as string, + password: "", + url: result.data.event_link, + }; + }; + + return { + /** Tandem doesn't need to return busy times, so we return empty */ + getAvailability: () => { + return Promise.resolve([]); + }, + createMeeting: async (event: CalendarEvent): Promise => { + const accessToken = await auth.getToken(); + + const result = await fetch(`${TANDEM_BASE_URL}/api/v1/meetings`, { + method: "POST", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: _translateEvent(event, "meeting"), + }).then(handleErrorsJson); + + return Promise.resolve(_translateResult(result)); + }, + + deleteMeeting: async (uid: string): Promise => { + const accessToken = await auth.getToken(); + + await fetch(`${TANDEM_BASE_URL}/api/v1/meetings/${uid}`, { + method: "DELETE", + headers: { + Authorization: "Bearer " + accessToken, + }, + }).then(handleErrorsRaw); + + return Promise.resolve(); + }, + + updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise => { + const accessToken = await auth.getToken(); + + const result = await fetch(`${TANDEM_BASE_URL}/api/v1/meetings/${bookingRef.meetingId}`, { + method: "PUT", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + }, + body: _translateEvent(event, "updates"), + }).then(handleErrorsJson); + + return Promise.resolve(_translateResult(result)); + }, + }; +}; + +export default TandemVideoApiAdapter; diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts index 24631340..fce6deb4 100644 --- a/lib/integrations/getIntegrations.ts +++ b/lib/integrations/getIntegrations.ts @@ -20,6 +20,7 @@ export type Integration = { | "office365_calendar" | "zoom_video" | "daily_video" + | "tandem_video" | "caldav_calendar" | "apple_calendar" | "stripe_payment" @@ -72,6 +73,14 @@ export const ALL_INTEGRATIONS = [ description: "Video Conferencing", variant: "conferencing", }, + { + installed: !!(process.env.TANDEM_CLIENT_ID && process.env.TANDEM_CLIENT_SECRET), + type: "tandem_video", + title: "Tandem Video", + imageSrc: "integrations/tandem.svg", + description: "Virtual Office | Video Conferencing", + variant: "conferencing", + }, { installed: true, type: "caldav_calendar", diff --git a/lib/location.ts b/lib/location.ts index 6f69fab5..c3070051 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -5,4 +5,5 @@ export enum LocationType { Zoom = "integrations:zoom", Daily = "integrations:daily", Huddle01 = "integrations:huddle01", + Tandem = "integrations:tandem", } diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 4bc2b428..dfcf642c 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -9,6 +9,7 @@ import Huddle01VideoApiAdapter from "@lib/integrations/Huddle01/Huddle01VideoApi import logger from "@lib/logger"; import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter"; +import TandemVideoApiAdapter from "./integrations/Tandem/TandemVideoApiAdapter"; import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter"; import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar"; @@ -48,6 +49,9 @@ const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] => case "huddle01_video": acc.push(Huddle01VideoApiAdapter()); break; + case "tandem_video": + acc.push(TandemVideoApiAdapter(cred)); + break; default: break; } diff --git a/pages/api/integrations/tandemvideo/add.ts b/pages/api/integrations/tandemvideo/add.ts new file mode 100644 index 00000000..0519f563 --- /dev/null +++ b/pages/api/integrations/tandemvideo/add.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { stringify } from "querystring"; + +import { getSession } from "@lib/auth"; +import { BASE_URL } from "@lib/config/constants"; +import prisma from "@lib/prisma"; + +const client_id = process.env.TANDEM_CLIENT_ID; +const TANDEM_BASE_URL = process.env.TANDEM_BASE_URL; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") { + // Check that user is authenticated + const session = await getSession({ req }); + + if (!session?.user?.id) { + res.status(401).json({ message: "You must be logged in to do this" }); + return; + } + + // Get user + await prisma.user.findFirst({ + rejectOnNotFound: true, + where: { + id: session?.user?.id, + }, + select: { + id: true, + }, + }); + + const redirect_uri = encodeURI(BASE_URL + "/api/integrations/tandemvideo/callback"); + + const params = { + client_id, + redirect_uri, + }; + const query = stringify(params); + const url = `${TANDEM_BASE_URL}/oauth/approval?${query}`; + res.status(200).json({ url }); + } +} diff --git a/pages/api/integrations/tandemvideo/callback.ts b/pages/api/integrations/tandemvideo/callback.ts new file mode 100644 index 00000000..0a130a52 --- /dev/null +++ b/pages/api/integrations/tandemvideo/callback.ts @@ -0,0 +1,56 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getSession } from "@lib/auth"; +import prisma from "@lib/prisma"; + +const client_id = process.env.TANDEM_CLIENT_ID as string; +const client_secret = process.env.TANDEM_CLIENT_SECRET as string; +const TANDEM_BASE_URL = (process.env.TANDEM_BASE_URL as string) || "https://tandem.chat"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.query.code) { + res.status(401).json({ message: "Missing code" }); + return; + } + + const code = req.query.code as string; + + // Check that user is authenticated + const session = await getSession({ req }); + + if (!session?.user?.id) { + res.status(401).json({ message: "You must be logged in to do this" }); + return; + } + + const result = await fetch(`${TANDEM_BASE_URL}/api/v1/oauth/v2/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code, client_id, client_secret }), + }); + + const responseBody = await result.json(); + + if (result.ok) { + responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); + delete responseBody.expires_in; + + await prisma.user.update({ + where: { + id: session.user.id, + }, + data: { + credentials: { + create: { + type: "tandem_video", + key: responseBody, + }, + }, + }, + }); + } + + res.redirect("/integrations"); +} diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index 6ad3d798..6a9e9db6 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -263,6 +263,8 @@ const EventTypePage = (props: inferSSRProps) => { return

{t("cal_provide_video_meeting_url")}

; case LocationType.Huddle01: return

{t("cal_provide_huddle01_meeting_url")}

; + case LocationType.Tandem: + return

{t("cal_provide_tandem_meeting_url")}

; default: return null; } @@ -522,6 +524,30 @@ const EventTypePage = (props: inferSSRProps) => { Zoom Video )} + {location.type === LocationType.Tandem && ( +
+ + + + + Tandem Video +
+ )}