Tandem Video (#1671)

* Tandem Video

* Updating some copy

* adding some instructions for getting client id + secret

* PR Feedback

* removing spurious tsconfig file
This commit is contained in:
Ken Miller 2022-02-04 10:30:52 -08:00 committed by GitHub
parent dedf001237
commit ae5d5e1261
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 336 additions and 3 deletions

View File

@ -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

View File

@ -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.

View File

@ -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 = () => {

3
environment.d.ts vendored
View File

@ -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;
}
}

View File

@ -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);

View File

@ -16,6 +16,8 @@ export function getIntegrationName(name: string) {
return "Daily";
case "huddle01_video":
return "Huddle01";
case "tandem_video":
return "Tandem";
}
}

View File

@ -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<VideoCallData> => {
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<void> => {
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<VideoCallData> => {
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;

View File

@ -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",

View File

@ -5,4 +5,5 @@ export enum LocationType {
Zoom = "integrations:zoom",
Daily = "integrations:daily",
Huddle01 = "integrations:huddle01",
Tandem = "integrations:tandem",
}

View File

@ -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;
}

View File

@ -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 });
}
}

View File

@ -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");
}

View File

@ -263,6 +263,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return <p className="text-sm">{t("cal_provide_video_meeting_url")}</p>;
case LocationType.Huddle01:
return <p className="text-sm">{t("cal_provide_huddle01_meeting_url")}</p>;
case LocationType.Tandem:
return <p className="text-sm">{t("cal_provide_tandem_meeting_url")}</p>;
default:
return null;
}
@ -522,6 +524,30 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<span className="ltr:ml-2 rtl:mr-2text-sm">Zoom Video</span>
</div>
)}
{location.type === LocationType.Tandem && (
<div className="flex items-center flex-grow">
<svg
width="1.25em"
height="1.25em"
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M167.928 256.163L64 324V143.835L167.928 76V256.163Z"
fill="#4341DC"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M335.755 256.163L231.827 324V143.835L335.755 76V256.163Z"
fill="#00B6B6"
/>
</svg>
<span className="ml-2 text-sm">Tandem Video</span>
</div>
)}
<div className="flex">
<button
type="button"
@ -1592,6 +1618,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (hasIntegration(integrations, "huddle01_video")) {
locationOptions.push({ value: LocationType.Huddle01, label: "Huddle01 Video" });
}
if (hasIntegration(integrations, "tandem_video")) {
locationOptions.push({ value: LocationType.Tandem, label: "Tandem Video" });
}
const currency =
(credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData)
?.default_currency || "usd";

View File

@ -0,0 +1,4 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M167.928 256.163L64 324V143.835L167.928 76V256.163Z" fill="#4341DC"/>
<path fillRule="evenodd" clipRule="evenodd" d="M335.755 256.163L231.827 324V143.835L335.755 76V256.163Z" fill="#00B6B6"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -543,6 +543,7 @@
"cal_invitee_phone_number_scheduling": "Cal wird Ihren Teilnehmer bitten, vor der Planung eine Telefonnummer einzugeben.",
"cal_provide_google_meet_location": "Cal wird einen Google Meet Termin zur Verfügung stellen.",
"cal_provide_zoom_meeting_url": "Cal stellt eine Zoom Meeting-URL zur Verfügung.",
"cal_provide_tandem_meeting_url": "Cal stellt eine Tandem Meeting-URL zur Verfügung.",
"cal_provide_video_meeting_url": "Cal stellt eine tägliche Video-Meeting-URL zur Verfügung.",
"cal_provide_huddle01_meeting_url": "Cal stellt eine tägliche Huddle01-Web3-Meeting-URL zur Verfügung.",
"require_payment": "Zahlung erforderlich",

View File

@ -543,6 +543,7 @@
"cal_invitee_phone_number_scheduling": "Cal will ask your invitee to enter a phone number before scheduling.",
"cal_provide_google_meet_location": "Cal will provide a Google Meet location.",
"cal_provide_zoom_meeting_url": "Cal will provide a Zoom meeting URL.",
"cal_provide_tandem_meeting_url": "Cal will provide a Tandem meeting URL.",
"cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.",
"cal_provide_huddle01_meeting_url": "Cal will provide a Huddle01 web3 video meeting URL.",
"require_payment": "Require Payment",

View File

@ -523,6 +523,7 @@
"cal_invitee_phone_number_scheduling": "Cal le pedirá a su invitado que introduzca un número de teléfono antes de programar.",
"cal_provide_google_meet_location": "Cal proporcionará una URL de reunión de Google Meet.",
"cal_provide_zoom_meeting_url": "Cal proporcionará una URL de reunión de Zoom.",
"cal_provide_tandem_meeting_url": "Cal proporcionará una URL de reunión de Tandem.",
"cal_provide_video_meeting_url": "Cal proporcionará una URL de reunión de Daily Video.",
"cal_provide_huddle01_meeting_url": "Cal proporcionará una URL de reunión de Huddle01 Web3 Video.",
"require_payment": "Requiere Pago",

View File

@ -489,6 +489,7 @@
"cal_invitee_phone_number_scheduling": "Cal demandera à votre invité. e d'entrer un numéro de téléphone avant de faire une réservation.",
"cal_provide_google_meet_location": "Cal fournira un lien Google Meet.",
"cal_provide_zoom_meeting_url": "Cal fournira une URL de réunion Zoom.",
"cal_provide_tandem_meeting_url": "Cal fournira une URL de réunion Tandem.",
"cal_provide_video_meeting_url": "Cal fournira une URL de réunion Daily video.",
"cal_provide_huddle01_meeting_url": "Cal fournira une URL de réunion Huddle01 web3 video.",
"require_payment": "Exiger un paiement",

View File

@ -517,6 +517,7 @@
"cal_provide_zoom_meeting_url": "Cal fornirà un URL di riunione Zoom.",
"cal_provide_video_meeting_url": "Cal fornirà un URL di riunione Daily video.",
"cal_provide_huddle01_meeting_url": "Cal fornirà un URL di riunione Huddle01 web3 video.",
"cal_provide_tandem_meeting_url": "Cal fornirà un URL di riunione Tandem.",
"require_payment": "Richiedi Pagamento",
"commission_per_transaction": "commissione per transazione",
"event_type_updated_successfully_description": "Il tuo team è stato aggiornato con successo.",

View File

@ -487,6 +487,7 @@
"cal_invitee_phone_number_scheduling": "Calは、予約する前に招待者に電話番号を入力するように依頼します。",
"cal_provide_google_meet_location": "CalはGoogleMeetの場所を提供します。",
"cal_provide_zoom_meeting_url": "カルはZoomミーティングURLを提供します。",
"cal_provide_tandem_meeting_url": "カルはTandemミーティングURLを提供します。",
"cal_provide_video_meeting_url": "カルは毎日のビデオミーティングのURLを提供します。",
"cal_provide_huddle01_meeting_url": "カルはHuddle01 Web3ミーティングURLを提供します。",
"require_payment": "お支払いが必要です",

View File

@ -510,6 +510,7 @@
"cal_invitee_phone_number_scheduling": "Cal은 일정을 잡기 전에 초대받은 사람에게 전화번호를 요청합니다.",
"cal_provide_google_meet_location": "Cal은 Google Meet 위치를 제공합니다.",
"cal_provide_zoom_meeting_url": "Cal은 Zoom 회의 URL을 제공합니다.",
"cal_provide_tandem_meeting_url": "Cal은 Tandem 회의 URL을 제공합니다.",
"cal_provide_video_meeting_url": "Cal은 일일 화상 회의 URL을 제공합니다.",
"cal_provide_huddle01_meeting_url": "Cal은 Huddle01 Web3 회의 URL을 제공합니다.",
"require_payment": "지불 요청",

View File

@ -480,6 +480,7 @@
"cal_invitee_phone_number_scheduling": "Cal zal de genodigde om een telefoonnummer vragen.",
"cal_provide_google_meet_location": "Cal zal een Google Meet meeting-URL meegeven in de afspraak bevestiging.",
"cal_provide_zoom_meeting_url": "Cal zal een Zoom meeting-URL meegeven in de afspraak bevestiging.",
"cal_provide_tandem_meeting_url": "Cal zal een Tandem meeting-URL meegeven in de afspraak bevestiging.",
"cal_provide_video_meeting_url": "Cal zal een Daily meeting-URL meegeven in de afspraak bevestiging.",
"cal_provide_huddle01_meeting_url": "Cal zal een Huddle01 web3 meeting-URL meegeven in de afspraak bevestiging.",
"require_payment": "Betaling vereisen",

View File

@ -526,6 +526,7 @@
"cal_invitee_phone_number_scheduling": "Cal solicitará ao seu convidado que insira um número de telefone antes de agendar.",
"cal_provide_google_meet_location": "Cal fornecerá um link para o Google Meet.",
"cal_provide_zoom_meeting_url": "Cal fornecerá uma URL de reunião do Zoom.",
"cal_provide_tandem_meeting_url": "Cal fornecerá uma URL de reunião do Tandem.",
"cal_provide_video_meeting_url": "O Cal irá fornecer um URL de reunião do Daily video.",
"cal_provide_huddle01_meeting_url": "O Cal irá fornecer um URL de reunião do Huddle01 Web3 video.",
"require_payment": "Requerer Pagamento",

View File

@ -543,6 +543,7 @@
"cal_invitee_phone_number_scheduling": "Cal solicitará ao seu convidado que insira um número de telefone antes de agendar.",
"cal_provide_google_meet_location": "Cal fornecerá um local para o Google Meet.",
"cal_provide_zoom_meeting_url": "Cal fornecerá uma URL de reunião Zoom .",
"cal_provide_tandem_meeting_url": "Cal fornecerá uma URL de reunião Tandem .",
"cal_provide_video_meeting_url": "O Cal irá fornecer um URL de reunião do Daily video.",
"cal_provide_huddle01_meeting_url": "O Cal irá fornecer um URL de reunião vídeo do Huddle01 Web3.",
"require_payment": "Requer Pagamento",

View File

@ -489,6 +489,7 @@
"cal_provide_zoom_meeting_url": "Cal va oferi un URL pentru ședința de Zoom.",
"cal_provide_video_meeting_url": "Cal va oferi un URL pentru ședința de Daily.",
"cal_provide_huddle01_meeting_url": "Cal va oferi un URL pentru ședința de Huddle01 Web3.",
"cal_provide_tandem_meeting_url": "Cal va oferi un URL pentru ședința de Tandem.",
"require_payment": "Solicită plata",
"commission_per_transaction": "comision per tranzacție",
"event_type_updated_successfully_description": "Tipul de eveniment a fost actualizat cu succes.",

View File

@ -527,6 +527,7 @@
"cal_invitee_phone_number_scheduling": "Cal попросит участника указать номер телефона перед началом бронирования.",
"cal_provide_google_meet_location": "Cal создаст ссылку на встречу в Google Meet.",
"cal_provide_zoom_meeting_url": "Cal создаст ссылку на встречу в Zoom.",
"cal_provide_tandem_meeting_url": "Cal создаст ссылку на встречу в Tandem.",
"cal_provide_video_meeting_url": "Cal создаст ссылку на встречу в Daily.",
"cal_provide_huddle01_meeting_url": "Cal создаст ссылку на встречу в Huddle01 Web3.",
"require_payment": "Требуется оплата",

File diff suppressed because one or more lines are too long