From 9382b862e4c96fe267e834e8a36930fe2578219f Mon Sep 17 00:00:00 2001 From: Ponikar <54766861+ponikar@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:20:16 +0530 Subject: [PATCH] feat: Updated Huddle01 App Flow (#16907) * fix: migrated to new flow * feat: apis added * fix: meetingId * fix: working with apis * fix: appName * fix: WEB APP URL * fix: update url * fix: remove logs * feat: remove lock file * fix: lock file * fix: override buildErrors * fix: removed lock file * fix: migrated to new flow * feat: apis added * fix: meetingId * fix: working with apis * fix: appName * fix: WEB APP URL * fix: update url * fix: remove logs * feat: remove lock file * fix: lock file * fix: override buildErrors * fix: yarn install, lock file * temp: debugging issue * feat: api updated * fix: updated endpoints * fix: reverted changes * revert: yarn.lock * feat: readme * Update yarn.lock remove spacing * fix: spacing --------- Co-authored-by: Akash Mondal --- .env.appStore.example | 5 + packages/app-store/huddle01video/README.md | 5 + packages/app-store/huddle01video/_metadata.ts | 6 +- packages/app-store/huddle01video/api/add.ts | 69 +++----- .../app-store/huddle01video/api/callback.ts | 35 ++++ packages/app-store/huddle01video/api/index.ts | 1 + .../huddle01video/lib/VideoApiAdapter.ts | 167 ++++++++++++++---- .../app-store/huddle01video/utils/storage.ts | 69 ++++++++ packages/prisma/seed-app-store.ts | 7 +- turbo.json | 3 +- 10 files changed, 278 insertions(+), 89 deletions(-) create mode 100644 packages/app-store/huddle01video/README.md create mode 100644 packages/app-store/huddle01video/api/callback.ts create mode 100644 packages/app-store/huddle01video/utils/storage.ts diff --git a/.env.appStore.example b/.env.appStore.example index b799fc707e..cec38afb9a 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -5,6 +5,7 @@ # - DAILY.CO VIDEO # - GOOGLE CALENDAR/MEET/LOGIN # - HUBSPOT +# - HUDDLE01 # - OFFICE 365 # - SLACK # - STRIPE @@ -137,3 +138,7 @@ REVERT_PUBLIC_TOKEN= # NOTE: If you're self hosting Revert, update this URL to point to your own instance. REVERT_API_URL=https://api.revert.dev/ # ********************************************************************************************************* + +# - Huddle01 +# Used for the huddle01 integration +HUDDLE01_API_TOKEN= diff --git a/packages/app-store/huddle01video/README.md b/packages/app-store/huddle01video/README.md new file mode 100644 index 0000000000..6042ac408d --- /dev/null +++ b/packages/app-store/huddle01video/README.md @@ -0,0 +1,5 @@ +### Obtaining Huddle01 API Token + +- As of now, we haven't made OAuth authentication support public. +- Please contact us at support@huddle01.com with the subject line "Require Huddle01 API Token" and mention - details like app name and purpose of token. +- We will share token details via email. \ No newline at end of file diff --git a/packages/app-store/huddle01video/_metadata.ts b/packages/app-store/huddle01video/_metadata.ts index 65f90fd31a..99f2765ba1 100644 --- a/packages/app-store/huddle01video/_metadata.ts +++ b/packages/app-store/huddle01video/_metadata.ts @@ -1,4 +1,3 @@ -import { randomString } from "@calcom/lib/random"; import type { AppMeta } from "@calcom/types/App"; import _package from "./package.json"; @@ -9,7 +8,7 @@ export const metadata = { installed: true, type: "huddle01_video", variant: "conferencing", - categories: ["conferencing"], + categories: ["video", "conferencing"], logo: "icon.svg", publisher: "huddle01.com", url: "https://huddle01.com", @@ -21,11 +20,10 @@ export const metadata = { appData: { location: { linkType: "dynamic", - type: "integrations:huddle01", + type: "integrations:huddle01_video", label: "Huddle01 Video", }, }, - key: { apikey: randomString(12) }, dirName: "huddle01video", concurrentMeetings: true, isOAuth: false, diff --git a/packages/app-store/huddle01video/api/add.ts b/packages/app-store/huddle01video/api/add.ts index 62708dee6c..7f20cdb85a 100644 --- a/packages/app-store/huddle01video/api/add.ts +++ b/packages/app-store/huddle01video/api/add.ts @@ -1,54 +1,27 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import type { NextApiRequest } from "next"; +import { stringify } from "querystring"; -import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; -import prisma from "@calcom/prisma"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; -import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; -/** - * This is an example endpoint for an app, these will run under `/api/integrations/[...args]` - * @param req - * @param res - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!req.session?.user?.id) { - return res.status(401).json({ message: "You must be logged in to do this" }); - } - const { teamId, returnTo } = req.query; +async function handler(req: NextApiRequest) { + const state = encodeOAuthState(req); - await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: req.session.user.id }); - const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id }; + const params = { + response_type: "code", + redirect_uri: `${WEBAPP_URL}/api/integrations/huddle01video/callback`, + state, + appName: "Calcom", + }; + const query = stringify(params); - const appType = "huddle01_video"; - try { - const alreadyInstalled = await prisma.credential.findFirst({ - where: { - type: appType, - ...installForObject, - }, - }); - if (alreadyInstalled) { - throw new Error("Already installed"); - } - const installation = await prisma.credential.create({ - data: { - type: appType, - key: {}, - ...installForObject, - appId: "huddle01", - }, - }); - if (!installation) { - throw new Error("Unable to create user credential for huddle01video"); - } - } catch (error: unknown) { - if (error instanceof Error) { - return res.status(500).json({ message: error.message }); - } - return res.status(500); - } - // need to return a json object with the response status - return res - .status(200) - .json({ url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "huddle01" }) }); + const url = `https://huddle01.app/thirdparty_auth?${query}`; + + return { url }; } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/huddle01video/api/callback.ts b/packages/app-store/huddle01video/api/callback.ts new file mode 100644 index 0000000000..f4ce62616d --- /dev/null +++ b/packages/app-store/huddle01video/api/callback.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; + +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import { storeHuddle01Credential } from "../utils/storage"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getServerSession({ req, res }); + + const state = decodeOAuthState(req); + + if (!session) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = session.user.id; + + const { identityToken } = req.query; + + const token = Array.isArray(identityToken) ? identityToken[0] : identityToken; + if (!token) { + return res.status(401).json({ + message: "Something went wrong", + }); + } + + await storeHuddle01Credential(userId, token); + + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "huddle01" }) + ); +} diff --git a/packages/app-store/huddle01video/api/index.ts b/packages/app-store/huddle01video/api/index.ts index 4c0d2ead01..eb12c1b4ed 100644 --- a/packages/app-store/huddle01video/api/index.ts +++ b/packages/app-store/huddle01video/api/index.ts @@ -1 +1,2 @@ export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/huddle01video/lib/VideoApiAdapter.ts b/packages/app-store/huddle01video/lib/VideoApiAdapter.ts index e1900d8853..84a983c063 100644 --- a/packages/app-store/huddle01video/lib/VideoApiAdapter.ts +++ b/packages/app-store/huddle01video/lib/VideoApiAdapter.ts @@ -1,46 +1,143 @@ -import z from "zod"; - -import { handleErrorsJson } from "@calcom/lib/errors"; -import { randomString } from "@calcom/lib/random"; +import logger from "@calcom/lib/logger"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; import type { PartialReference } from "@calcom/types/EventManager"; -import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; +import type { VideoApiAdapter } from "@calcom/types/VideoApiAdapter"; -const huddle01Schema = z.object({ url: z.string().url(), roomId: z.string() }); +import { getHuddle01APIKey, getHuddle01Credential } from "../utils/storage"; -const Huddle01VideoApiAdapter = (): VideoApiAdapter => { +const API_END_POINT = "https://platform-api.huddle01.workers.dev/api/v2/calendar"; + +const fetchHuddleAPI = async (userId: number) => { + const { identityToken } = await getHuddle01Credential(userId); + const { apiKey } = await getHuddle01APIKey(); + + const headers = { + "x-api-key": apiKey, + "x-identity-token": identityToken, + }; + return ( + endpoint: "subdomains" | "createMeeting" | "deleteMeeting" | "updateMeeting", + option?: RequestInit + ) => { + return fetch(`${API_END_POINT}/${endpoint}`, { + ...option, + headers: { + ...option?.headers, + ...headers, + }, + }); + }; +}; + +const log = logger.getSubLogger({ prefix: ["app-store/huddle01video/lib/VideoApiAdapter"] }); + +const Huddle01ApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { return { - getAvailability: () => { - return Promise.resolve([]); - }, - createMeeting: async (): Promise => { - const res = await fetch( - "https://wpss2zlpb9.execute-api.us-east-1.amazonaws.com/new-meeting?utmCampaign=cal.com&utmSource=partner&utmMedium=calendar" - ); - - const json = await handleErrorsJson<{ url: string }>(res); - const { url } = huddle01Schema.parse(json); - if (url) { - return Promise.resolve({ - type: "huddle01_video", - id: randomString(21), - password: "", - url, - }); + createMeeting: async (e: CalendarEvent) => { + if (!credential.userId) { + log.error("[Huddle01 Error] -> User is not logged in"); + throw new Error("User is not logged in"); + } + + try { + const fetch = await fetchHuddleAPI(credential.userId); + + const res = await fetch("createMeeting", { + method: "POST", + body: JSON.stringify({ + title: e.title, + startTime: e.startTime, + endTime: e.endTime, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = (await res.json()) as { + roomId: string; + meetingLink: string; + }; + + return { + type: "huddle01_video", + id: data.roomId, + password: "", + url: data.meetingLink, + }; + } catch (e) { + log.error("[Huddle01 Error] -> Error while creating meeeting", e); + throw Error("Error while creating meeting"); } - return Promise.reject("Url was not received in response body."); }, - deleteMeeting: async (): Promise => { - Promise.resolve(); - }, - updateMeeting: (bookingRef: PartialReference): Promise => { - return Promise.resolve({ - type: "huddle01_video", - id: bookingRef.meetingId as string, - password: bookingRef.meetingPassword as string, - url: bookingRef.meetingUrl as string, + updateMeeting: async (bookingRef: PartialReference, e: CalendarEvent) => { + if (!credential.userId) { + log.error("[Huddle01 Error] -> User is not logged in"); + throw new Error("User is not logged in"); + } + + const fetch = await fetchHuddleAPI(credential.userId); + + const res = await fetch("updateMeeting", { + method: "PUT", + body: JSON.stringify({ + title: e.title, + startTime: e.startTime, + endTime: e.endTime, + meetingId: bookingRef.uid, + }), + headers: { + "Content-Type": "application/json", + }, }); + + const data = (await res.json()) as { + roomId: string; + meetingLink: string; + }; + + return { + type: "huddle01_video", + id: data.roomId, + password: "", + url: data.meetingLink, + }; + }, + deleteMeeting: async (meetingId: string) => { + if (!credential.userId) { + log.error("[Huddle01 Error] -> User is not logged in"); + throw new Error("User is not logged in"); + } + + const fetch = await fetchHuddleAPI(credential.userId); + + const res = await fetch("deleteMeeting", { + method: "DELETE", + body: JSON.stringify({ + meetingId, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = (await res.json()) as { + roomId: string; + meetingLink: string; + }; + + return { + type: "huddle01_video", + id: data.roomId, + password: "", + url: data.meetingLink, + }; + }, + getAvailability: async () => { + return []; }, }; }; -export default Huddle01VideoApiAdapter; +export default Huddle01ApiAdapter; diff --git a/packages/app-store/huddle01video/utils/storage.ts b/packages/app-store/huddle01video/utils/storage.ts new file mode 100644 index 0000000000..1921740a38 --- /dev/null +++ b/packages/app-store/huddle01video/utils/storage.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { prisma } from "@calcom/prisma"; + +const huddle01AppKeySchema = z.object({ + identityToken: z.string(), +}); + +export async function storeHuddle01Credential(userId: number, identityToken: string) { + const existingCredential = await prisma.credential.findFirst({ + where: { + type: "huddle01_video", + userId: userId, + appId: "huddle01", + }, + }); + + if (existingCredential) { + // Update the existing credential with the new identityToken + await prisma.credential.update({ + where: { id: existingCredential.id }, + data: { key: { identityToken } }, + }); + } else { + // Create a new credential if it doesn't exist + await prisma.credential.create({ + data: { + type: "huddle01_video", + key: { identityToken }, + userId: userId, + appId: "huddle01", + }, + }); + } +} + +export async function getHuddle01Credential(userId: number) { + const credential = await prisma.credential.findFirst({ + where: { + type: "huddle01_video", + userId: userId, + appId: "huddle01", + }, + }); + + if (!credential) { + throw new Error("Huddle01 credential not found"); + } + + return huddle01AppKeySchema.parse(credential?.key); +} + +const huddle01APIKeySchema = z.object({ + apiKey: z.string(), +}); + +export async function getHuddle01APIKey() { + const appCreds = await prisma.app.findFirst({ + where: { + slug: "huddle01", + }, + }); + + if (!appCreds) { + throw new Error("Huddle01 app credential not found"); + } + + return huddle01APIKeySchema.parse(appCreds?.keys); +} diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index d5ca9fed3a..b29853c7d3 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -359,7 +359,12 @@ export default async function main() { await createApp("make", "make", ["automation"], "make_automation", { invite_link: "https://make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510", }); - await createApp("huddle01", "huddle01video", ["conferencing"], "huddle01_video"); + + if (process.env.HUDDLE01_API_TOKEN) { + await createApp("huddle01", "huddle01video", ["conferencing"], "huddle01_video", { + apiKey: process.env.HUDDLE01_API_TOKEN, + }); + } // Payment apps if ( diff --git a/turbo.json b/turbo.json index 83feb1792d..f4f7b5120e 100644 --- a/turbo.json +++ b/turbo.json @@ -454,6 +454,7 @@ "NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED", "NEXT_PUBLIC_API_V2_ROOT_URL", "NEXT_PUBLIC_VAPID_PUBLIC_KEY", - "VAPID_PRIVATE_KEY" + "VAPID_PRIVATE_KEY", + "HUDDLE01_API_TOKEN" ] }