diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index edc85d04d13b12..c15c794e356d6c 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -22,6 +22,7 @@ import { appKeysSchema as insihts_zod_ts } from "./insihts/zod"; import { appKeysSchema as intercom_zod_ts } from "./intercom/zod"; import { appKeysSchema as jelly_zod_ts } from "./jelly/zod"; import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; +import { appKeysSchema as kyzonspacevideo_zod_ts } from "./kyzonspacevideo/zod"; import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appKeysSchema as make_zod_ts } from "./make/zod"; import { appKeysSchema as matomo_zod_ts } from "./matomo/zod"; @@ -73,6 +74,7 @@ export const appKeysSchemas = { intercom: intercom_zod_ts, jelly: jelly_zod_ts, jitsivideo: jitsivideo_zod_ts, + kyzonspacevideo: kyzonspacevideo_zod_ts, larkcalendar: larkcalendar_zod_ts, make: make_zod_ts, matomo: matomo_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index be4cfb6cb9c5de..4001daff775539 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -52,6 +52,7 @@ import insihts_config_json from "./insihts/config.json"; import intercom_config_json from "./intercom/config.json"; import jelly_config_json from "./jelly/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; +import kyzonspacevideo_config_json from "./kyzonspacevideo/config.json"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; import lindy_config_json from "./lindy/config.json"; import linear_config_json from "./linear/config.json"; @@ -163,6 +164,7 @@ export const appStoreMetadata = { intercom: intercom_config_json, jelly: jelly_config_json, jitsivideo: jitsivideo__metadata_ts, + kyzonspacevideo: kyzonspacevideo_config_json, larkcalendar: larkcalendar__metadata_ts, lindy: lindy_config_json, linear: linear_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 28310ace22f84e..f0445880fd4528 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -22,6 +22,7 @@ import { appDataSchema as insihts_zod_ts } from "./insihts/zod"; import { appDataSchema as intercom_zod_ts } from "./intercom/zod"; import { appDataSchema as jelly_zod_ts } from "./jelly/zod"; import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; +import { appDataSchema as kyzonspacevideo_zod_ts } from "./kyzonspacevideo/zod"; import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appDataSchema as make_zod_ts } from "./make/zod"; import { appDataSchema as matomo_zod_ts } from "./matomo/zod"; @@ -73,6 +74,7 @@ export const appDataSchemas = { intercom: intercom_zod_ts, jelly: jelly_zod_ts, jitsivideo: jitsivideo_zod_ts, + kyzonspacevideo: kyzonspacevideo_zod_ts, larkcalendar: larkcalendar_zod_ts, make: make_zod_ts, matomo: matomo_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 61349f361cac2b..b8e03892ee5004 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -38,6 +38,7 @@ export const apiHandlers = { intercom: import("./intercom/api"), jelly: import("./jelly/api"), jitsivideo: import("./jitsivideo/api"), + kyzonspacevideo: import("./kyzonspacevideo/api"), larkcalendar: import("./larkcalendar/api"), linear: import("./linear/api"), make: import("./make/api"), diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts index 9c1623fe6b1960..72a67891173c6e 100644 --- a/packages/app-store/bookerApps.metadata.generated.ts +++ b/packages/app-store/bookerApps.metadata.generated.ts @@ -20,6 +20,7 @@ import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadat import insihts_config_json from "./insihts/config.json"; import jelly_config_json from "./jelly/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; +import kyzonspacevideo_config_json from "./kyzonspacevideo/config.json"; import matomo_config_json from "./matomo/config.json"; import metapixel_config_json from "./metapixel/config.json"; import mirotalk_config_json from "./mirotalk/config.json"; @@ -65,6 +66,7 @@ export const appStoreMetadata = { insihts: insihts_config_json, jelly: jelly_config_json, jitsivideo: jitsivideo__metadata_ts, + kyzonspacevideo: kyzonspacevideo_config_json, matomo: matomo_config_json, metapixel: metapixel_config_json, mirotalk: mirotalk_config_json, diff --git a/packages/app-store/kyzonspacevideo/DESCRIPTION.md b/packages/app-store/kyzonspacevideo/DESCRIPTION.md new file mode 100644 index 00000000000000..32f7004f60aefc --- /dev/null +++ b/packages/app-store/kyzonspacevideo/DESCRIPTION.md @@ -0,0 +1,9 @@ +--- +items: + - 1.png + - 2.png + - 3.png + - 4.png +--- + +{DESCRIPTION} diff --git a/packages/app-store/kyzonspacevideo/api/add.ts b/packages/app-store/kyzonspacevideo/api/add.ts new file mode 100644 index 00000000000000..67c707c9d03db6 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/api/add.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler } from "@calcom/lib/server/defaultHandler"; +import { defaultResponder } from "@calcom/lib/server/defaultResponder"; + +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; +import config from "../config.json"; +import { kyzonBaseUrl } from "../lib/axios"; +import { getKyzonAppKeys } from "../lib/getKyzonAppKeys"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + // Get user + const user = req?.session?.user; + if (!user) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { client_id } = await getKyzonAppKeys(); + const state = encodeOAuthState(req); + + const query = new URLSearchParams(); + query.set("response_type", "code"); + query.set("client_id", client_id); + query.set("redirect_uri", `${WEBAPP_URL}/api/integrations/${config.slug}/callback`); + query.set("scope", "meetings:write calendar:write profile:read"); + if (state) { + query.set("state", state); + } + + const url = `${kyzonBaseUrl}/oauth/authorize?${query.toString()}`; + + return res.status(200).json({ url }); +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/kyzonspacevideo/api/callback.test.ts b/packages/app-store/kyzonspacevideo/api/callback.test.ts new file mode 100644 index 00000000000000..81c99f683e2e9e --- /dev/null +++ b/packages/app-store/kyzonspacevideo/api/callback.test.ts @@ -0,0 +1,576 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; + +import type { NextApiRequest, NextApiResponse } from "next"; +import { expect, test, vi, describe, beforeEach } from "vitest"; + +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; + +import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +// Import mocked functions and the callback handler +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import setDefaultConferencingApp from "../../_utils/setDefaultConferencingApp"; +// Import mocked axios functions +import { kyzonAxiosInstance } from "../lib/axios"; +import callbackHandler from "./callback"; + +// Mock external dependencies +vi.mock("@calcom/lib/constants", () => ({ + WEBAPP_URL: "https://app.cal.com", +})); + +vi.mock("@calcom/lib/getSafeRedirectUrl", () => ({ + getSafeRedirectUrl: vi.fn((url: string) => url), +})); + +vi.mock("../../_utils/getInstalledAppPath", () => ({ + default: vi.fn(() => "/apps/kyzonspacevideo"), +})); + +vi.mock("../../_utils/oauth/createOAuthAppCredential", () => ({ + default: vi.fn(), +})); + +vi.mock("../../_utils/oauth/decodeOAuthState", () => ({ + decodeOAuthState: vi.fn(), +})); + +vi.mock("../../_utils/setDefaultConferencingApp", () => ({ + default: vi.fn(), +})); + +// Mock axios +vi.mock("../lib/axios", () => ({ + kyzonAxiosInstance: { + post: vi.fn(), + get: vi.fn(), + }, +})); + +const mockPost = vi.mocked(kyzonAxiosInstance.post); +const mockGet = vi.mocked(kyzonAxiosInstance.get); + +// Mock app keys +vi.mock("../lib/getKyzonAppKeys", () => ({ + getKyzonAppKeys: vi.fn(() => ({ + client_id: "mock_client_id", + client_secret: "mock_client_secret", + api_key: "mock_api_key", + })), +})); + +// Mock credential key +vi.mock("../lib/KyzonCredentialKey", () => ({ + getKyzonCredentialKey: vi.fn((data) => ({ + ...data, + expiry_date: Date.now() + 3600000, + })), +})); + +const createMockRequest = (overrides: Partial = {}): NextApiRequest => + ({ + method: "GET", + url: "/api/integrations/kyzonspacevideo/callback", + headers: {}, + body: {}, + cookies: {}, + query: {}, + session: { user: { id: 1 } }, + ...overrides, + } as NextApiRequest); + +const createMockResponse = (): NextApiResponse => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + redirect: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis(), + } as unknown as NextApiResponse; + return res; +}; + +describe("OAuth Callback Handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: "/error", + fromApp: false, + }); + vi.mocked(getSafeRedirectUrl).mockImplementation((url?: string) => url || null); + }); + + describe("successful authorization flow", () => { + test("successfully processes authorization code", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + const mockTokenResponse = { + access_token: "access_token_123", + refresh_token: "refresh_token_123", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const mockProfileResponse = { + id: "user_123", + email: "user@example.com", + firstName: "John", + lastName: "Doe", + teamId: "team_123", + }; + + mockPost.mockResolvedValueOnce({ data: mockTokenResponse }); + mockGet.mockResolvedValueOnce({ data: mockProfileResponse }); + + await callbackHandler(req, res); + + expect(mockPost).toHaveBeenCalledWith( + "/oauth/token", + { + grant_type: "authorization_code", + code: "auth_code_123", + client_id: "mock_client_id", + client_secret: "mock_client_secret", + redirect_uri: "https://app.cal.com/api/integrations/kyzonspacevideo/callback", + }, + { + headers: { + "X-API-Key": "mock_api_key", + }, + } + ); + + expect(mockGet).toHaveBeenCalledWith("/v1/oauth/me", { + headers: { + Authorization: "Bearer access_token_123", + }, + }); + + expect(prismaMock.credential.deleteMany).toHaveBeenCalledWith({ + where: { + type: "kyzonspace_video", + userId: 1, + appId: "kyzonspacevideo", + }, + }); + + expect(createOAuthAppCredential).toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith("/apps/kyzonspacevideo"); + }); + + test("sets default conferencing app when defaultInstall is true", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: "/error", + fromApp: false, + defaultInstall: true, + }); + + const mockTokenResponse = { + access_token: "access_token_123", + refresh_token: "refresh_token_123", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const mockProfileResponse = { + id: "user_123", + email: "user@example.com", + firstName: "John", + lastName: "Doe", + teamId: "team_123", + }; + + mockPost.mockResolvedValueOnce({ data: mockTokenResponse }); + mockGet.mockResolvedValueOnce({ data: mockProfileResponse }); + + await callbackHandler(req, res); + + expect(setDefaultConferencingApp).toHaveBeenCalledWith(1, "kyzonspacevideo"); + expect(res.redirect).toHaveBeenCalledWith("/apps/kyzonspacevideo"); + }); + + test("redirects to custom returnTo URL from state", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: "/error", + fromApp: false, + returnTo: "https://app.cal.com/custom-redirect", + }); + + const mockTokenResponse = { + access_token: "access_token_123", + refresh_token: "refresh_token_123", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const mockProfileResponse = { + id: "user_123", + email: "user@example.com", + firstName: "John", + lastName: "Doe", + teamId: "team_123", + }; + + mockPost.mockResolvedValueOnce({ data: mockTokenResponse }); + mockGet.mockResolvedValueOnce({ data: mockProfileResponse }); + + await callbackHandler(req, res); + + expect(res.redirect).toHaveBeenCalledWith("https://app.cal.com/custom-redirect"); + }); + }); + + describe("error handling", () => { + test("handles missing user session", async () => { + const req = createMockRequest({ + session: null, + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: "No user found" }); + }); + + test("handles OAuth error from query parameters", async () => { + const req = createMockRequest({ + query: { + error: "access_denied", + error_description: "User denied access", + }, + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: "User denied access" }); + }); + + test("redirects on error when returnTo state is provided", async () => { + const req = createMockRequest({ + query: { + error: "access_denied", + error_description: "User denied access", + }, + }); + const res = createMockResponse(); + + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: "/error", + fromApp: false, + returnTo: "https://app.cal.com/error-redirect", + }); + + // Mock getSafeRedirectUrl to return the onErrorReturnTo first + vi.mocked(getSafeRedirectUrl).mockImplementation((url?: string) => { + if (url === "/error") return "/error"; + if (url === "https://app.cal.com/error-redirect") return "https://app.cal.com/error-redirect"; + return null; + }); + + await callbackHandler(req, res); + + expect(res.redirect).toHaveBeenCalledWith("/error"); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + test("redirects on error when onErrorReturnTo state is provided", async () => { + const req = createMockRequest({ + query: { + error: "access_denied", + }, + }); + const res = createMockResponse(); + + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: "https://app.cal.com/error-specific-redirect", + fromApp: false, + returnTo: "https://app.cal.com/normal-redirect", + }); + + await callbackHandler(req, res); + + expect(res.redirect).toHaveBeenCalledWith("https://app.cal.com/error-specific-redirect"); + }); + + test("handles missing authorization code", async () => { + const req = createMockRequest({ + query: {}, // No code + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "No authorization code was received from KYZON Space. Please try connecting again.", + }); + }); + + test("handles non-string authorization code", async () => { + const req = createMockRequest({ + query: { code: ["array_code"] }, // Non-string code + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "No authorization code was received from KYZON Space. Please try connecting again.", + }); + }); + + test("handles token exchange API error", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + const apiError = { + isAxiosError: true, + response: { + data: { + error: "invalid_grant", + error_description: "The authorization code is invalid", + }, + }, + }; + + mockPost.mockRejectedValueOnce(apiError); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "The authorization code is invalid", + }); + }); + + test("handles token exchange API error without description", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + const apiError = { + isAxiosError: true, + response: { + data: { + error: "server_error", + }, + }, + }; + + mockPost.mockRejectedValueOnce(apiError); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "KYZON Space is temporarily unavailable. Please try again in a few minutes.", + }); + }); + + test("handles generic network error during token exchange", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + const networkError = new Error("Network error"); + mockPost.mockRejectedValueOnce(networkError); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "Unable to connect to KYZON Space. Please try again.", + }); + }); + + test("handles error response from token endpoint", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + const mockErrorResponse = { + error: "invalid_client", + error_description: "Client authentication failed", + }; + + mockPost.mockResolvedValueOnce({ data: mockErrorResponse }); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "Client authentication failed", + }); + }); + + test("handles error response without description from token endpoint", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + const mockErrorResponse = { + error: "invalid_client", + }; + + mockPost.mockResolvedValueOnce({ data: mockErrorResponse }); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "invalid_client", + }); + }); + + test("handles profile fetch error", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + // Mock state to return no redirect URLs, so it uses JSON response + vi.mocked(decodeOAuthState).mockReturnValue({ + onErrorReturnTo: undefined as unknown as string, + fromApp: false, + returnTo: undefined, + }); + + const mockTokenResponse = { + access_token: "access_token_123", + refresh_token: "refresh_token_123", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const profileError = new Error("Profile fetch failed"); + + mockPost.mockResolvedValueOnce({ data: mockTokenResponse }); + mockGet.mockRejectedValueOnce(profileError); + + await callbackHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "Unable to connect to KYZON Space. Please try again.", + }); + }); + }); + + describe("database operations", () => { + test("deletes existing credentials before creating new ones", async () => { + const req = createMockRequest({ + query: { code: "auth_code_123" }, + }); + const res = createMockResponse(); + + const mockTokenResponse = { + access_token: "access_token_123", + refresh_token: "refresh_token_123", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const mockProfileResponse = { + id: "user_123", + email: "user@example.com", + firstName: "John", + lastName: "Doe", + teamId: "team_123", + }; + + mockPost.mockResolvedValueOnce({ data: mockTokenResponse }); + mockGet.mockResolvedValueOnce({ data: mockProfileResponse }); + + await callbackHandler(req, res); + + expect(prismaMock.credential.deleteMany).toHaveBeenCalledWith({ + where: { + type: "kyzonspace_video", + userId: 1, + appId: "kyzonspacevideo", + }, + }); + + expect(createOAuthAppCredential).toHaveBeenCalledWith( + { appId: "kyzonspacevideo", type: "kyzonspace_video" }, + expect.objectContaining({ + access_token: "access_token_123", + user_id: "user_123", + team_id: "team_123", + }), + req + ); + }); + }); +}); diff --git a/packages/app-store/kyzonspacevideo/api/callback.ts b/packages/app-store/kyzonspacevideo/api/callback.ts new file mode 100644 index 00000000000000..e5afc428129096 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/api/callback.ts @@ -0,0 +1,252 @@ +import type { AxiosError } from "axios"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import prisma from "@calcom/prisma"; + +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import setDefaultConferencingApp from "../../_utils/setDefaultConferencingApp"; +import config from "../config.json"; +import { getKyzonCredentialKey, type KyzonCredentialKey } from "../lib/KyzonCredentialKey"; +import { kyzonAxiosInstance } from "../lib/axios"; +import { getKyzonAppKeys } from "../lib/getKyzonAppKeys"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const state = decodeOAuthState(req); + const userId = req.session?.user.id; + if (!userId) { + return res.status(404).json({ message: "No user found" }); + } + const { code, error, error_description } = req.query; + + if (error) { + console.error("KYZON OAuth error from query parameters:", { + error, + error_description, + hasState: !!(state?.onErrorReturnTo || state?.returnTo), + }); + + if (state?.onErrorReturnTo || state?.returnTo) { + res.redirect( + getSafeRedirectUrl(state.onErrorReturnTo) ?? + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: config.variant, slug: config.slug }) + ); + return; + } + + let friendlyErrorMessage = "Connection to KYZON Space was cancelled or failed."; + if (typeof error_description === "string") { + friendlyErrorMessage = error_description; + } else if (error === "access_denied") { + friendlyErrorMessage = + "Access to KYZON Space was denied. You can try connecting again if you'd like to use KYZON Space for your meetings."; + } else if (typeof error === "string") { + // Map common OAuth error codes to friendly messages + switch (error) { + case "invalid_request": + friendlyErrorMessage = "There was an issue with the connection request. Please try again."; + break; + case "unauthorized_client": + friendlyErrorMessage = + "The KYZON Space integration is not properly configured. Please contact support."; + break; + case "invalid_scope": + friendlyErrorMessage = "The requested permissions are not available. Please contact support."; + break; + case "server_error": + friendlyErrorMessage = + "KYZON Space is experiencing technical difficulties. Please try again later."; + break; + case "temporarily_unavailable": + friendlyErrorMessage = "KYZON Space is temporarily unavailable. Please try again in a few minutes."; + break; + default: + friendlyErrorMessage = `Connection failed: ${error}`; + } + } + + res.status(400).json({ message: friendlyErrorMessage }); + return; + } + + if (typeof code !== "string") { + console.error("KYZON OAuth callback missing authorization code:", { + codeType: typeof code, + hasState: !!(state?.onErrorReturnTo || state?.returnTo), + }); + + if (state?.onErrorReturnTo || state?.returnTo) { + res.redirect( + getSafeRedirectUrl(state.onErrorReturnTo) ?? + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: config.variant, slug: config.slug }) + ); + return; + } + + const friendlyErrorMessage = + "No authorization code was received from KYZON Space. Please try connecting again."; + res.status(400).json({ message: friendlyErrorMessage }); + return; + } + + const { client_id, client_secret, api_key } = await getKyzonAppKeys(); + + let credentialKey: KyzonCredentialKey; + + try { + const { data: authorizeResult } = await kyzonAxiosInstance.post<{ + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + error?: string; + error_description?: string; + error_uri?: string; + }>( + "/oauth/token", + { + grant_type: "authorization_code", + code, + client_id, + client_secret, + redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`, + }, + { + headers: { + "X-API-Key": api_key, + }, + } + ); + + if (authorizeResult.error_description) { + res.status(400).json({ message: authorizeResult.error_description }); + return; + } + + if (authorizeResult.error) { + res.status(400).json({ message: authorizeResult.error }); + return; + } + + const { data: profile } = await kyzonAxiosInstance.get<{ + id: string; + email: string; + firstName: string; + lastName: string; + teamId: string; + }>("/v1/oauth/me", { + headers: { + Authorization: `Bearer ${authorizeResult.access_token}`, + }, + }); + + credentialKey = getKyzonCredentialKey({ + ...authorizeResult, + user_id: profile.id, + team_id: profile.teamId, + }); + } catch (error) { + const axiosError = error as AxiosError<{ + error: string; + error_description: string; + error_uri?: string; + }>; + + console.error("KYZON OAuth callback error:", { + status: axiosError?.response?.status, + message: axiosError?.message, + code: axiosError?.code, + hasState: !!(state?.onErrorReturnTo || state?.returnTo), + }); + + if (state?.onErrorReturnTo || state?.returnTo) { + res.redirect( + getSafeRedirectUrl(state.onErrorReturnTo) ?? + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: config.variant, slug: config.slug }) + ); + return; + } + + let friendlyErrorMessage = "Unable to connect to KYZON Space. Please try again."; + + try { + // Check for specific error descriptions from the API + const apiErrorDescription = axiosError.response?.data?.error_description; + const apiError = axiosError.response?.data?.error; + + if (apiErrorDescription) { + friendlyErrorMessage = apiErrorDescription; + } else if (apiError) { + // Map common OAuth errors to user-friendly messages + switch (apiError) { + case "access_denied": + friendlyErrorMessage = "Access was denied. Please try connecting to KYZON Space again."; + break; + case "invalid_grant": + friendlyErrorMessage = + "The authorization code is invalid or has expired. Please try connecting again."; + break; + case "invalid_client": + friendlyErrorMessage = + "There's a configuration issue with the KYZON Space integration. Please contact support."; + break; + case "invalid_request": + friendlyErrorMessage = "There was an issue with the connection request. Please try again."; + break; + case "server_error": + friendlyErrorMessage = + "KYZON Space is temporarily unavailable. Please try again in a few minutes."; + break; + case "temporarily_unavailable": + friendlyErrorMessage = "KYZON Space is currently undergoing maintenance. Please try again later."; + break; + default: + friendlyErrorMessage = `Connection failed: ${apiError}`; + } + } + + // Handle network-level errors + if (axiosError.code === "ECONNREFUSED" || axiosError.code === "ENOTFOUND") { + friendlyErrorMessage = + "Cannot reach KYZON Space servers. Please check your internet connection and try again."; + } else if (axiosError.response?.status === 429) { + friendlyErrorMessage = "Too many connection attempts. Please wait a moment and try again."; + } else if (axiosError.response?.status === 503) { + friendlyErrorMessage = "KYZON Space is temporarily unavailable. Please try again in a few minutes."; + } + } catch (e) { + // Fallback to generic message if error parsing fails + console.warn("Failed to parse KYZON OAuth error:", e); + } + + res.status(400).json({ message: friendlyErrorMessage }); + return; + } + + // With this we take care of no duplicate kyzonspacevideo key for a single user + // when creating a room using deleteMany if there is already a kyzonspacevideo key + await prisma.credential.deleteMany({ + where: { + type: config.type, + userId, + appId: config.slug, + }, + }); + + await createOAuthAppCredential({ appId: config.slug, type: config.type }, credentialKey, req); + + if (state?.defaultInstall) { + await setDefaultConferencingApp(userId, config.slug); + } + + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: config.variant, slug: config.slug }) + ); +} diff --git a/packages/app-store/kyzonspacevideo/api/index.ts b/packages/app-store/kyzonspacevideo/api/index.ts new file mode 100644 index 00000000000000..eb12c1b4ed2c4f --- /dev/null +++ b/packages/app-store/kyzonspacevideo/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/kyzonspacevideo/config.json b/packages/app-store/kyzonspacevideo/config.json new file mode 100644 index 00000000000000..43ca198fb8ac0b --- /dev/null +++ b/packages/app-store/kyzonspacevideo/config.json @@ -0,0 +1,24 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "KYZON Space", + "slug": "kyzonspacevideo", + "type": "kyzonspace_video", + "logo": "icon.svg", + "url": "https://kyzonsolutions.com", + "variant": "conferencing", + "categories": ["conferencing"], + "publisher": "KYZON Solutions", + "email": "support@kyzonsolutions.com", + "appData": { + "location": { + "type": "integrations:{SLUG}_video", + "label": "{TITLE}", + "linkType": "dynamic" + } + }, + "description": "KYZON Space is a browser-based platform that makes it easy to present, collaborate, and edit documents on video calls.", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "event-type-location-video-static", + "isOAuth": true +} diff --git a/packages/app-store/kyzonspacevideo/index.ts b/packages/app-store/kyzonspacevideo/index.ts new file mode 100644 index 00000000000000..e2e9d7b029c031 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/kyzonspacevideo/lib/KyzonCredentialKey.ts b/packages/app-store/kyzonspacevideo/lib/KyzonCredentialKey.ts new file mode 100644 index 00000000000000..85401ccdb09576 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/KyzonCredentialKey.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const kyzonCredentialKeySchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + token_type: z + .string() + .regex(/^bearer$/i) + .transform(() => "Bearer" as const) + .optional(), + scope: z.string().optional(), + /* store ms since epoch, > now */ + expiry_date: z.number().int().positive(), + user_id: z + .union([z.string(), z.number()]) + .transform((val) => String(val)) + .pipe(z.string().min(1)), + team_id: z + .union([z.string(), z.number()]) + .transform((val) => String(val)) + .pipe(z.string().min(1)), +}); + +export type KyzonCredentialKey = z.infer; + +export function getKyzonCredentialKey(payload: { + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + user_id: string; + team_id: string; +}): KyzonCredentialKey { + const { expires_in, ...rest } = payload; + const skewMs = 60_000; // refresh 1 min early + const ms = Math.max(5_000, expires_in * 1000 - skewMs); // clamp to ≥5 s + const candidate = { + ...rest, + expiry_date: Date.now() + ms, + }; + // Ensure shape is valid at runtime + return kyzonCredentialKeySchema.parse(candidate); +} diff --git a/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.test.ts b/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.test.ts new file mode 100644 index 00000000000000..d2d2c1d9ab705b --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.test.ts @@ -0,0 +1,564 @@ +import { expect, test, vi, describe, beforeEach } from "vitest"; + +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +import config from "../config.json"; +import type { KyzonCredentialKey } from "./KyzonCredentialKey"; +import KyzonVideoApiAdapter from "./VideoApiAdapter"; +// Import mocked functions after they're mocked +import { kyzonAxiosInstance } from "./axios"; +import { refreshKyzonToken, isTokenExpired } from "./tokenManager"; + +// Mock axios +vi.mock("./axios", () => ({ + kyzonAxiosInstance: { + post: vi.fn(), + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +// Mock token manager +vi.mock("./tokenManager", () => ({ + refreshKyzonToken: vi.fn(), + isTokenExpired: vi.fn(), +})); + +const mockPost = vi.mocked(kyzonAxiosInstance.post); +const mockGet = vi.mocked(kyzonAxiosInstance.get); +const mockPut = vi.mocked(kyzonAxiosInstance.put); +const mockDelete = vi.mocked(kyzonAxiosInstance.delete); +const mockRefreshKyzonToken = vi.mocked(refreshKyzonToken); +const mockIsTokenExpired = vi.mocked(isTokenExpired); + +const mockCredentialKey: KyzonCredentialKey = { + access_token: "mock_access_token", + refresh_token: "mock_refresh_token", + token_type: "Bearer", + scope: "meetings:write calendar:write profile:read", + expiry_date: Date.now() + 3600000, + user_id: "mock_user_id", + team_id: "mock_team_id", +}; + +const testCredential: CredentialPayload = { + appId: config.slug, + id: 1, + invalid: false, + key: mockCredentialKey, + type: config.type, + userId: 1, + user: { email: "test@example.com" }, + teamId: 1, + delegationCredentialId: null, +}; + +const mockCalendarEvent: CalendarEvent = { + type: "event", + uid: "test-event-uid", + title: "Test Meeting", + description: "Test meeting description", + startTime: "2024-01-15T10:00:00Z", + endTime: "2024-01-15T11:00:00Z", + organizer: { + email: "organizer@example.com", + name: "Test Organizer", + timeZone: "America/New_York", + language: { + locale: "en", + translate: vi.fn() as any, + }, + }, + attendees: [ + { + email: "attendee1@example.com", + name: "Attendee 1", + timeZone: "America/New_York", + language: { + locale: "en", + translate: vi.fn() as any, + }, + }, + { + email: "attendee2@example.com", + name: "Attendee 2", + timeZone: "America/New_York", + language: { + locale: "en", + translate: vi.fn() as any, + }, + }, + ], +}; + +describe("KyzonVideoApiAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsTokenExpired.mockReturnValue(false); + }); + + describe("createMeeting", () => { + test("successfully creates meeting", async () => { + const mockSpaceCallResponse = { + id: "space_call_123", + password: "meeting_password", + url: "https://space.kyzon.com/call/123", + }; + + const mockCalendarEventResponse = { + id: "calendar_event_456", + meetingPassword: "calendar_password", + meetingLink: "https://calendar.kyzon.com/meeting/456", + }; + + mockPost + .mockResolvedValueOnce({ data: mockSpaceCallResponse }) + .mockResolvedValueOnce({ data: mockCalendarEventResponse }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const result = await videoApi!.createMeeting(mockCalendarEvent); + + expect(mockPost).toHaveBeenCalledTimes(2); + expect(mockPost).toHaveBeenNthCalledWith( + 1, + `/v1/teams/${mockCredentialKey.team_id}/space/calls`, + { + name: mockCalendarEvent.title, + isScheduled: true, + }, + { + headers: { + Authorization: `Bearer ${mockCredentialKey.access_token}`, + }, + } + ); + + expect(result).toEqual({ + type: config.type, + id: mockCalendarEventResponse.id, + password: mockCalendarEventResponse.meetingPassword, + url: mockCalendarEventResponse.meetingLink, + }); + }); + + test("creates meeting with recurring event", async () => { + const mockSpaceCallResponse = { + id: "space_call_123", + password: "meeting_password", + url: "https://space.kyzon.com/call/123", + }; + + const mockCalendarEventResponse = { + id: "calendar_event_456", + meetingPassword: "calendar_password", + meetingLink: "https://calendar.kyzon.com/meeting/456", + }; + + mockPost + .mockResolvedValueOnce({ data: mockSpaceCallResponse }) + .mockResolvedValueOnce({ data: mockCalendarEventResponse }); + + const recurringEvent = { + ...mockCalendarEvent, + recurringEvent: { + freq: 2, // WEEKLY + interval: 1, + count: 5, + }, + }; + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const result = await videoApi!.createMeeting(recurringEvent); + + expect(mockPost).toHaveBeenNthCalledWith( + 2, + `/v1/teams/${mockCredentialKey.team_id}/calendar-events`, + expect.objectContaining({ + recurrence: { + frequency: "WEEKLY", + interval: 1, + count: 5, + untilDateUtcISOString: undefined, + }, + }), + expect.any(Object) + ); + + expect(result).toEqual({ + type: config.type, + id: mockCalendarEventResponse.id, + password: mockCalendarEventResponse.meetingPassword, + url: mockCalendarEventResponse.meetingLink, + }); + }); + + test("handles space call creation failure", async () => { + const mockError = new Error("Space call creation failed"); + mockPost.mockRejectedValueOnce(mockError); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + + await expect(videoApi!.createMeeting(mockCalendarEvent)).rejects.toThrow( + "Unable to create KYZON Space meeting. Please try again." + ); + + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + test("handles calendar event creation failure", async () => { + const mockSpaceCallResponse = { + id: "space_call_123", + password: "meeting_password", + url: "https://space.kyzon.com/call/123", + }; + + const mockError = new Error("Calendar event creation failed"); + mockPost.mockResolvedValueOnce({ data: mockSpaceCallResponse }).mockRejectedValueOnce(mockError); + + const videoApi = KyzonVideoApiAdapter(testCredential); + + await expect(videoApi?.createMeeting(mockCalendarEvent)).rejects.toThrow( + "Unable to create KYZON Space meeting. Please try again." + ); + + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + test("refreshes token on 401 error and retries", async () => { + const refreshedToken = { + ...mockCredentialKey, + access_token: "new_access_token", + }; + + const axiosError = { + isAxiosError: true, + response: { status: 401 }, + }; + + const mockSpaceCallResponse = { + id: "space_call_123", + password: "meeting_password", + url: "https://space.kyzon.com/call/123", + }; + + mockRefreshKyzonToken.mockResolvedValueOnce(refreshedToken); + mockPost + .mockRejectedValueOnce(axiosError) + .mockResolvedValueOnce({ data: mockSpaceCallResponse }) + .mockResolvedValueOnce({ data: { id: "calendar_456" } }); + + vi.doMock("axios", () => ({ + isAxiosError: vi.fn(() => true), + })); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + await videoApi!.createMeeting(mockCalendarEvent); + + expect(mockRefreshKyzonToken).toHaveBeenCalledWith(testCredential.id); + expect(mockPost).toHaveBeenCalledTimes(3); // First failed call, then retry with new token, then calendar event + }); + }); + + describe("updateMeeting", () => { + test("successfully updates existing meeting", async () => { + const mockUpdatedResponse = { + id: "calendar_event_456", + meetingPassword: "updated_password", + meetingLink: "https://updated.kyzon.com/meeting/456", + }; + + mockPut.mockResolvedValueOnce({ data: mockUpdatedResponse }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const bookingRef = { meetingId: "existing_meeting_123" }; + const result = await videoApi!.updateMeeting(bookingRef, mockCalendarEvent); + + expect(mockPut).toHaveBeenCalledWith( + `/v1/teams/${mockCredentialKey.team_id}/calendar-events/${bookingRef.meetingId}`, + expect.objectContaining({ + title: mockCalendarEvent.title, + description: mockCalendarEvent.description, + }), + { + headers: { + Authorization: `Bearer ${mockCredentialKey.access_token}`, + }, + } + ); + + expect(result).toEqual({ + type: config.type, + id: mockUpdatedResponse.id, + password: mockUpdatedResponse.meetingPassword, + url: mockUpdatedResponse.meetingLink, + }); + }); + + test("creates new meeting when no meetingId provided", async () => { + const mockSpaceCallResponse = { + id: "space_call_123", + password: "meeting_password", + url: "https://space.kyzon.com/call/123", + }; + + const mockCalendarEventResponse = { + id: "calendar_event_456", + meetingPassword: "calendar_password", + meetingLink: "https://calendar.kyzon.com/meeting/456", + }; + + mockPost + .mockResolvedValueOnce({ data: mockSpaceCallResponse }) + .mockResolvedValueOnce({ data: mockCalendarEventResponse }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const bookingRef = {}; // No meetingId + const result = await videoApi!.updateMeeting(bookingRef, mockCalendarEvent); + + expect(mockPost).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + type: config.type, + id: mockCalendarEventResponse.id, + password: mockCalendarEventResponse.meetingPassword, + url: mockCalendarEventResponse.meetingLink, + }); + }); + + test("returns existing meeting data on update failure", async () => { + const mockError = new Error("Update failed"); + mockPut.mockRejectedValueOnce(mockError); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const bookingRef = { + meetingId: "existing_meeting_123", + meetingPassword: "existing_password", + meetingUrl: "https://existing.kyzon.com/meeting/123", + }; + const result = await videoApi!.updateMeeting(bookingRef, mockCalendarEvent); + + expect(result).toEqual({ + type: config.type, + id: bookingRef.meetingId, + password: bookingRef.meetingPassword, + url: bookingRef.meetingUrl, + }); + }); + }); + + describe("deleteMeeting", () => { + test("successfully deletes meeting", async () => { + mockDelete.mockResolvedValueOnce({ status: 200 }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + await videoApi!.deleteMeeting("meeting_123"); + + expect(mockDelete).toHaveBeenCalledWith( + `/v1/teams/${mockCredentialKey.team_id}/calendar-events/meeting_123`, + { + headers: { + Authorization: `Bearer ${mockCredentialKey.access_token}`, + }, + } + ); + }); + + test("handles deletion failure gracefully", async () => { + const mockError = new Error("Deletion failed"); + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { + // Mock implementation + }); + mockDelete.mockRejectedValueOnce(mockError); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + + // Should not throw error + await expect(videoApi!.deleteMeeting("meeting_123")).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to delete KYZON calendar event meeting_123:", + expect.objectContaining({ + message: "Deletion failed", + }) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe("getAvailability", () => { + test("successfully retrieves availability", async () => { + const mockSpaceCalls = [ + { + eventTime: { + startTimeUtcISOString: "2024-01-15T10:00:00Z", + endTimeUtcISOString: "2024-01-15T11:00:00Z", + }, + }, + { + eventTime: { + startTimeUtcISOString: "2024-01-15T14:00:00Z", + endTimeUtcISOString: "2024-01-15T15:00:00Z", + }, + }, + ]; + + mockGet.mockResolvedValueOnce({ data: mockSpaceCalls }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const result = await videoApi!.getAvailability("2024-01-15T00:00:00Z", "2024-01-16T00:00:00Z"); + + expect(mockGet).toHaveBeenCalledWith(`/v1/teams/${mockCredentialKey.team_id}/space/calls`, { + params: { + startDateUtcISOString: "2024-01-15T00:00:00Z", + endDateUtcISOString: "2024-01-16T00:00:00Z", + }, + headers: { + Authorization: `Bearer ${mockCredentialKey.access_token}`, + }, + }); + + expect(result).toEqual([ + { + start: "2024-01-15T10:00:00Z", + end: "2024-01-15T11:00:00Z", + source: "KYZON Space", + }, + { + start: "2024-01-15T14:00:00Z", + end: "2024-01-15T15:00:00Z", + source: "KYZON Space", + }, + ]); + }); + + test("filters out ongoing events without end time", async () => { + const mockSpaceCalls = [ + { + eventTime: { + startTimeUtcISOString: "2024-01-15T10:00:00Z", + endTimeUtcISOString: "2024-01-15T11:00:00Z", + }, + }, + { + eventTime: { + startTimeUtcISOString: "2024-01-15T14:00:00Z", + endTimeUtcISOString: null, // Ongoing event + }, + }, + ]; + + mockGet.mockResolvedValueOnce({ data: mockSpaceCalls }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const result = await videoApi!.getAvailability("2024-01-15T00:00:00Z", "2024-01-16T00:00:00Z"); + + // Should only include the event with end time + expect(result).toEqual([ + { + start: "2024-01-15T10:00:00Z", + end: "2024-01-15T11:00:00Z", + source: "KYZON Space", + }, + ]); + }); + + test("returns empty array when no date range provided", async () => { + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + + const result1 = await videoApi!.getAvailability(); + const result2 = await videoApi!.getAvailability("2024-01-15T00:00:00Z"); + const result3 = await videoApi!.getAvailability(undefined, "2024-01-16T00:00:00Z"); + + expect(result1).toEqual([]); + expect(result2).toEqual([]); + expect(result3).toEqual([]); + }); + + test("handles availability fetch failure gracefully", async () => { + const mockError = new Error("Availability fetch failed"); + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { + // Mock implementation + }); + mockGet.mockRejectedValueOnce(mockError); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + const result = await videoApi!.getAvailability("2024-01-15T00:00:00Z", "2024-01-16T00:00:00Z"); + + expect(result).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to get KYZON Space availability:", + expect.objectContaining({ + message: "Availability fetch failed", + }) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe("token management integration", () => { + test("refreshes expired token before making requests", async () => { + const refreshedToken = { + ...mockCredentialKey, + access_token: "refreshed_access_token", + expiry_date: Date.now() + 3600000, + }; + + mockIsTokenExpired.mockReturnValue(true); + mockRefreshKyzonToken.mockResolvedValueOnce(refreshedToken); + mockPost.mockResolvedValueOnce({ data: { id: "space_call_123" } }); + mockPost.mockResolvedValueOnce({ data: { id: "calendar_event_456" } }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + await videoApi!.createMeeting(mockCalendarEvent); + + expect(mockRefreshKyzonToken).toHaveBeenCalledWith(testCredential.id); + expect(mockPost).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: { + Authorization: `Bearer ${refreshedToken.access_token}`, + }, + }) + ); + }); + + test("uses current token when not expired", async () => { + mockIsTokenExpired.mockReturnValue(false); + mockPost.mockResolvedValueOnce({ data: { id: "space_call_123" } }); + mockPost.mockResolvedValueOnce({ data: { id: "calendar_event_456" } }); + + const videoApi = KyzonVideoApiAdapter(testCredential); + expect(videoApi).toBeDefined(); + await videoApi!.createMeeting(mockCalendarEvent); + + expect(mockRefreshKyzonToken).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: { + Authorization: `Bearer ${mockCredentialKey.access_token}`, + }, + }) + ); + }); + }); +}); diff --git a/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.ts b/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.ts new file mode 100644 index 00000000000000..66a01cb255ffb8 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/VideoApiAdapter.ts @@ -0,0 +1,326 @@ +import axios from "axios"; + +import { Frequency as CalFrequency } from "@calcom/prisma/zod-utils"; +import type { CalendarEvent, EventBusyDate, RecurringEvent } 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 config from "../config.json"; +import { kyzonCredentialKeySchema, type KyzonCredentialKey } from "./KyzonCredentialKey"; +import type { + KyzonGetCalendarEventResponse, + KyzonCreateOrPutCalendarEventRequestBody, + KyzonCreateSpaceCallRequestBody, + KyzonGetSpaceCallsWithinRangeRequestQuery, + KyzonSpaceCallResponse, + KyzonSingleSpaceCallWithinRangeResponse, + KyzonCalendarEventRecurrence, +} from "./apiTypes"; +import { kyzonAxiosInstance } from "./axios"; +import { refreshKyzonToken, isTokenExpired } from "./tokenManager"; + +const KyzonVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { + const getRefreshedKey = async (): Promise => { + let key = kyzonCredentialKeySchema.parse(credential.key); + + // Check if token needs refresh and has refresh capability + if (isTokenExpired(key)) { + const refreshedToken = await refreshKyzonToken(credential.id); + if (refreshedToken) { + key = refreshedToken; + } + } + + return key; + }; + + const authenticatedRequest = async (operation: (key: KyzonCredentialKey) => Promise): Promise => { + const key = await getRefreshedKey(); + + try { + return await operation(key); + } catch (error) { + // Only retry on 401 with a different token + if (axios.isAxiosError(error) && error.response?.status === 401) { + const refreshedToken = await refreshKyzonToken(credential.id); + if (refreshedToken && refreshedToken.access_token !== key.access_token) { + return await operation(refreshedToken); + } + } + throw error; + } + }; + + const createMeeting = async (event: CalendarEvent): Promise => { + try { + const { data: spaceCallData } = await authenticatedRequest((key) => + kyzonAxiosInstance.post( + `/v1/teams/${key.team_id}/space/calls`, + { + name: event.title, + isScheduled: true, + } satisfies KyzonCreateSpaceCallRequestBody, + { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + } + ) + ); + + const { data: calendarData } = await authenticatedRequest((key) => + kyzonAxiosInstance.post( + `/v1/teams/${key.team_id}/calendar-events`, + { + title: event.title, + description: event.description ?? undefined, + location: { + spaceCallId: spaceCallData.id, + }, + isAllDay: false, + timezone: event.organizer?.timeZone || "Etc/UTC", + startDateUtcISOString: new Date(event.startTime).toISOString(), + endDateUtcISOString: new Date(event.endTime).toISOString(), + recurrence: event.recurringEvent ? convertCalRecurrenceToKyzon(event.recurringEvent) : undefined, + invitees: event.attendees?.map((attendee) => ({ + email: attendee.email, + })), + thirdPartySource: { + calendarSource: "Cal.com", + eventId: event.uid || "", + }, + hasWaitRoom: true, + meetingFilesInWaitRoom: true, + } satisfies KyzonCreateOrPutCalendarEventRequestBody, + { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + } + ) + ); + + return { + type: config.type, + id: calendarData.id, + password: calendarData.meetingPassword || spaceCallData.password, + url: calendarData.meetingLink || spaceCallData.url, + }; + } catch (error) { + const err = error as any; + + console.error("Failed to create KYZON Space meeting:", { + status: err?.response?.status, + message: err?.message, + code: err?.code, + eventTitle: event.title, + }); + + let friendlyErrorMessage = "Unable to create KYZON Space meeting. Please try again."; + + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + friendlyErrorMessage = + "You don't have permission to create meetings in KYZON Space. Please check your account permissions."; + } else if (err.response?.status === 429) { + friendlyErrorMessage = + "KYZON Space is currently experiencing high traffic. Please wait a moment and try again."; + } else if (typeof err.response?.status === "number" && err.response?.status >= 500) { + friendlyErrorMessage = "KYZON Space is temporarily unavailable. Please try again in a few minutes."; + } else if (err.code === "ECONNREFUSED" || err.code === "ENOTFOUND") { + friendlyErrorMessage = + "Cannot connect to KYZON Space. Please check your internet connection and try again."; + } + } + + throw new Error(friendlyErrorMessage); + } + }; + + return { + createMeeting, + + updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise => { + if (!bookingRef.meetingId) { + return await createMeeting(event); + } + + try { + const { data: updatedCalendarEvent } = await authenticatedRequest((key) => + kyzonAxiosInstance.put( + `/v1/teams/${key.team_id}/calendar-events/${bookingRef.meetingId}`, + { + title: event.title, + description: event.description ?? undefined, + isAllDay: false, + timezone: event.organizer?.timeZone || "Etc/UTC", + startDateUtcISOString: new Date(event.startTime).toISOString(), + endDateUtcISOString: new Date(event.endTime).toISOString(), + recurrence: event.recurringEvent + ? convertCalRecurrenceToKyzon(event.recurringEvent) + : undefined, + invitees: event.attendees?.map((attendee) => ({ + email: attendee.email, + })), + thirdPartySource: { + calendarSource: "Cal.com", + eventId: event.uid || "", + }, + hasWaitRoom: true, + meetingFilesInWaitRoom: true, + } satisfies KyzonCreateOrPutCalendarEventRequestBody, + { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + } + ) + ); + + return { + type: config.type, + id: updatedCalendarEvent.id, + password: updatedCalendarEvent.meetingPassword || "", + url: updatedCalendarEvent.meetingLink || "", + }; + } catch (error) { + const err = error as any; + + console.warn("Failed to update KYZON Space meeting:", { + status: err?.response?.status, + message: err?.message, + code: err?.code, + meetingId: bookingRef.meetingId, + eventTitle: event.title, + }); + + // For 404 errors, the meeting might have been deleted - try creating a new one + if (axios.isAxiosError(err) && err.response?.status === 404) { + console.info("Meeting not found, creating a new KYZON Space meeting instead"); + return await createMeeting(event); + } + + // For other errors, return existing meeting data to maintain functionality + // The booking will still work with the original meeting details + return { + type: config.type, + id: bookingRef.meetingId, + password: bookingRef.meetingPassword || "", + url: bookingRef.meetingUrl || "", + }; + } + }, + + deleteMeeting: async (meetingId: string): Promise => { + try { + await authenticatedRequest((key) => + kyzonAxiosInstance.delete(`/v1/teams/${key.team_id}/calendar-events/${meetingId}`, { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + }) + ); + } catch (error) { + // Don't throw error if calendar event deletion fails + // as it might have already been deleted or expired + const err = error as any; + console.warn(`Failed to delete KYZON calendar event ${meetingId}:`, { + status: err?.response?.status, + message: err?.message, + code: err?.code, + }); + } + }, + + getAvailability: async (dateFrom?: string, dateTo?: string): Promise => { + if (!dateFrom || !dateTo) { + return []; + } + + try { + const response = await authenticatedRequest((key) => + kyzonAxiosInstance.get( + `/v1/teams/${key.team_id}/space/calls`, + { + params: { + startDateUtcISOString: dateFrom, + endDateUtcISOString: dateTo, + } satisfies KyzonGetSpaceCallsWithinRangeRequestQuery, + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + } + ) + ); + + const spaceCalls = response.data; + + return spaceCalls.reduce((acc, call) => { + if (!call.eventTime.endTimeUtcISOString) { + // ongoing / all-day event, don't count it as a busy date + return acc; + } + + acc.push({ + start: call.eventTime.startTimeUtcISOString, + end: call.eventTime.endTimeUtcISOString, + source: "KYZON Space", + }); + + return acc; + }, []); + } catch (error) { + const err = error as any; + console.warn("Failed to get KYZON Space availability:", { + status: err?.response?.status, + message: err?.message, + code: err?.code, + }); + return []; + } + }, + }; +}; + +export default KyzonVideoApiAdapter; + +/** + * Converts Cal.com's RecurringEvent format to KYZON Space's Recurrence format + */ +function convertCalRecurrenceToKyzon( + calRecurrence: RecurringEvent +): KyzonCalendarEventRecurrence | undefined { + if (!calRecurrence) return undefined; + + let frequency: KyzonCalendarEventRecurrence["frequency"]; + + switch (calRecurrence.freq) { + case CalFrequency.DAILY: { + frequency = "DAILY"; + break; + } + case CalFrequency.WEEKLY: { + frequency = "WEEKLY"; + break; + } + case CalFrequency.MONTHLY: { + frequency = "MONTHLY"; + break; + } + case CalFrequency.YEARLY: { + frequency = "YEARLY"; + break; + } + default: + // KYZON doesn't support HOURLY, MINUTELY, SECONDLY + return undefined; + } + + return { + frequency, + interval: calRecurrence.interval || 1, + count: calRecurrence.count > 0 ? calRecurrence.count : undefined, + untilDateUtcISOString: calRecurrence.until ? calRecurrence.until.toISOString() : undefined, + }; +} diff --git a/packages/app-store/kyzonspacevideo/lib/apiTypes.ts b/packages/app-store/kyzonspacevideo/lib/apiTypes.ts new file mode 100644 index 00000000000000..bff00b1202d9b8 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/apiTypes.ts @@ -0,0 +1,93 @@ +export interface KyzonCreateSpaceCallRequestBody { + name?: string; + isScheduled: boolean; +} + +export interface KyzonSpaceCallResponse { + id: string; + name: string; + url: string; + password: string; +} + +export interface KyzonCalendarEventRecurrence { + frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + interval: number; + count?: number; + untilDateUtcISOString?: string; +} + +export interface KyzonCreateOrPutCalendarEventRequestBody { + title: string; + description?: string; + location?: { + spaceCallId: string; + }; + isAllDay: boolean; + timezone: string; + startDateUtcISOString: string; + endDateUtcISOString: string; + recurrence?: KyzonCalendarEventRecurrence; + invitees?: { + email: string; + }[]; + thirdPartySource?: { + calendarSource: "Google Calendar" | "Cal.com"; + eventId: string; + viewUrl?: string; + editUrl?: string; + }; + hasWaitRoom: boolean; + meetingFilesInWaitRoom?: boolean; +} + +type MakeRequired = T & { [P in K]-?: T[P] }; + +export type KyzonGetCalendarEventResponse = MakeRequired< + Omit, + "invitees" | "meetingFilesInWaitRoom" +> & { + id: string; + formattedDate: string; + formattedTime: string; + formattedTimezone: string; + meetingId?: string; + meetingPassword?: string; + meetingLink?: string; + formattedInvitees: string[]; + organiserUserId: string | null; +}; + +export interface KyzonGetSpaceCallsWithinRangeRequestQuery { + startDateUtcISOString: string; + endDateUtcISOString: string; +} + +export interface KyzonSingleSpaceCallWithinRangeResponse { + id: string; + name: string; + url: string; + cloudFolderId: string | null; + flowFolderShareToken: string | null; + eventTime: + | { + isOngoing: true; + isAllDay: boolean; + startTimeUtcISOString: string; + endTimeUtcISOString?: never; + } + | { + isOngoing: boolean; + isAllDay: true; + startTimeUtcISOString: string; + endTimeUtcISOString?: never; + } + | { + isOngoing: false; + isAllDay: false; + startTimeUtcISOString: string; + endTimeUtcISOString: string; + }; + password: string; + calendarEventId: string; +} diff --git a/packages/app-store/kyzonspacevideo/lib/axios.ts b/packages/app-store/kyzonspacevideo/lib/axios.ts new file mode 100644 index 00000000000000..9508e426cc2f61 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/axios.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +export const kyzonBaseUrl = "https://kyzonsolutions.com/api/cloud"; + +export const kyzonAxiosInstance = axios.create({ + baseURL: kyzonBaseUrl, + timeout: 10000, // align with other axios instances to enforce a 10 s timeout + headers: { "User-Agent": "Cal.com-KYZON-Space/1.0" }, +}); diff --git a/packages/app-store/kyzonspacevideo/lib/getKyzonAppKeys.ts b/packages/app-store/kyzonspacevideo/lib/getKyzonAppKeys.ts new file mode 100644 index 00000000000000..edb66ef40ab788 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/getKyzonAppKeys.ts @@ -0,0 +1,12 @@ +import type { z } from "zod"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import config from "../config.json"; +import { appKeysSchema as kyzonSpaceAppKeysSchema } from "../zod"; + +type KyzonSpaceAppKeys = z.infer; + +export const getKyzonAppKeys = async (): Promise => { + const appKeys = await getAppKeysFromSlug(config.slug); + return kyzonSpaceAppKeysSchema.parse(appKeys); +}; diff --git a/packages/app-store/kyzonspacevideo/lib/index.ts b/packages/app-store/kyzonspacevideo/lib/index.ts new file mode 100644 index 00000000000000..dc61768d6007df --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/index.ts @@ -0,0 +1 @@ +export { default as VideoApiAdapter } from "./VideoApiAdapter"; diff --git a/packages/app-store/kyzonspacevideo/lib/tokenManager.test.ts b/packages/app-store/kyzonspacevideo/lib/tokenManager.test.ts new file mode 100644 index 00000000000000..29b71be7d6b1fe --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/tokenManager.test.ts @@ -0,0 +1,402 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; + +import { expect, test, vi, describe, beforeEach } from "vitest"; + +import type { KyzonCredentialKey } from "./KyzonCredentialKey"; +import { getKyzonCredentialKey } from "./KyzonCredentialKey"; +// Import mocked functions after they're mocked +import { kyzonAxiosInstance } from "./axios"; +import { getKyzonAppKeys } from "./getKyzonAppKeys"; +import { refreshKyzonToken, isTokenExpired } from "./tokenManager"; + +// Mock axios +vi.mock("./axios", () => ({ + kyzonAxiosInstance: { + post: vi.fn(), + }, +})); + +// Mock getKyzonAppKeys +vi.mock("./getKyzonAppKeys", () => ({ + getKyzonAppKeys: vi.fn(), +})); + +// Mock getKyzonCredentialKey +vi.mock("./KyzonCredentialKey", () => ({ + kyzonCredentialKeySchema: { + parse: vi.fn((data) => data), + }, + getKyzonCredentialKey: vi.fn(), +})); + +const mockPost = vi.mocked(kyzonAxiosInstance.post); +const mockGetKyzonAppKeys = vi.mocked(getKyzonAppKeys); +const mockGetKyzonCredentialKey = vi.mocked(getKyzonCredentialKey); + +const mockCredentialKey: KyzonCredentialKey = { + access_token: "mock_access_token", + refresh_token: "mock_refresh_token", + token_type: "Bearer", + scope: "meetings:write calendar:write profile:read", + expiry_date: Date.now() + 3600000, + user_id: "mock_user_id", + team_id: "mock_team_id", +}; + +const mockAppKeys = { + client_id: "mock_client_id", + client_secret: "mock_client_secret", + api_key: "mock_api_key", +}; + +describe("tokenManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetKyzonAppKeys.mockResolvedValue(mockAppKeys); + }); + + describe("isTokenExpired", () => { + test("returns true for expired token", () => { + const expiredToken: KyzonCredentialKey = { + ...mockCredentialKey, + expiry_date: Date.now() - 1000, // 1 second ago + }; + + expect(isTokenExpired(expiredToken)).toBe(true); + }); + + test("returns false for valid token", () => { + const validToken: KyzonCredentialKey = { + ...mockCredentialKey, + expiry_date: Date.now() + 3600000, // 1 hour from now + }; + + expect(isTokenExpired(validToken)).toBe(false); + }); + + test("returns true for token without expiry_date", () => { + const tokenWithoutExpiry = { + ...mockCredentialKey, + expiry_date: undefined as unknown as number, + }; + + expect(isTokenExpired(tokenWithoutExpiry)).toBe(true); + }); + + test("returns true for token with invalid expiry_date", () => { + const tokenWithInvalidExpiry = { + ...mockCredentialKey, + expiry_date: "invalid" as unknown as number, + }; + + expect(isTokenExpired(tokenWithInvalidExpiry)).toBe(true); + }); + + test("returns true for token with NaN expiry_date", () => { + const tokenWithNaNExpiry = { + ...mockCredentialKey, + expiry_date: NaN, + }; + + expect(isTokenExpired(tokenWithNaNExpiry)).toBe(true); + }); + }); + + describe("refreshKyzonToken", () => { + test("successfully refreshes token", async () => { + const credentialId = 123; + const newTokens = { + access_token: "new_access_token", + refresh_token: "new_refresh_token", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const updatedCredentialKey: KyzonCredentialKey = { + ...mockCredentialKey, + ...newTokens, + }; + + // Mock database responses + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: mockCredentialKey as any, + userId: 1, + teamId: null, + type: "kyzonspace_video", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + invalid: false, + appId: "kyzonspacevideo", + delegationCredentialId: null, + }); + + prismaMock.credential.update.mockResolvedValue({ + id: credentialId, + key: updatedCredentialKey as any, + userId: 1, + teamId: null, + type: "kyzonspace_video", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + invalid: false, + appId: "kyzonspacevideo", + delegationCredentialId: null, + }); + + // Mock API response + mockPost.mockResolvedValue({ data: newTokens }); + mockGetKyzonCredentialKey.mockReturnValue(updatedCredentialKey); + + const result = await refreshKyzonToken(credentialId); + + expect(prismaMock.credential.findUnique).toHaveBeenCalledWith({ + where: { id: credentialId }, + select: { key: true }, + }); + + expect(mockPost).toHaveBeenCalledWith( + "/oauth/token", + { + grant_type: "refresh_token", + refresh_token: mockCredentialKey.refresh_token, + client_id: mockAppKeys.client_id, + client_secret: mockAppKeys.client_secret, + }, + { + headers: { + "X-API-Key": mockAppKeys.api_key, + }, + } + ); + + expect(prismaMock.credential.update).toHaveBeenCalledWith({ + where: { id: credentialId }, + data: { key: updatedCredentialKey }, + }); + + expect(result).toEqual(updatedCredentialKey); + }); + + test("returns null when credential not found", async () => { + const credentialId = 123; + + prismaMock.credential.findUnique.mockResolvedValue(null); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(mockPost).not.toHaveBeenCalled(); + expect(prismaMock.credential.update).not.toHaveBeenCalled(); + }); + + test("returns null when credential has no key", async () => { + const credentialId = 123; + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: null, + }); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(mockPost).not.toHaveBeenCalled(); + expect(prismaMock.credential.update).not.toHaveBeenCalled(); + }); + + test("returns null and logs warning when no refresh token", async () => { + const credentialId = 123; + const credentialWithoutRefresh = { + ...mockCredentialKey, + refresh_token: undefined, + }; + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: credentialWithoutRefresh, + }); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + `KYZON token refresh failed: No refresh token available for credential ${credentialId}. User may need to reconnect to KYZON Space.` + ); + expect(mockPost).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + test("handles API error and logs it", async () => { + const credentialId = 123; + const apiError = new Error("Refresh token expired"); + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: mockCredentialKey, + }); + + mockPost.mockRejectedValue(apiError); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to refresh KYZON token", + expect.objectContaining({ + message: "Refresh token expired", + }) + ); + expect(prismaMock.credential.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + test("handles database error during update", async () => { + const credentialId = 123; + const newTokens = { + access_token: "new_access_token", + refresh_token: "new_refresh_token", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const dbError = new Error("Database connection failed"); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: mockCredentialKey, + }); + + mockPost.mockResolvedValue({ data: newTokens }); + mockGetKyzonCredentialKey.mockReturnValue({ + ...mockCredentialKey, + ...newTokens, + }); + + prismaMock.credential.update.mockRejectedValue(dbError); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to refresh KYZON token", + expect.objectContaining({ + message: "Database connection failed", + }) + ); + + consoleSpy.mockRestore(); + }); + + test("prevents concurrent refresh requests for same credential", async () => { + const credentialId = 123; + const newTokens = { + access_token: "new_access_token", + refresh_token: "new_refresh_token", + token_type: "Bearer" as const, + expires_in: 3600, + scope: "meetings:write calendar:write profile:read", + }; + + const updatedCredentialKey: KyzonCredentialKey = { + ...mockCredentialKey, + ...newTokens, + }; + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: mockCredentialKey as any, + userId: 1, + teamId: null, + type: "kyzonspace_video", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + invalid: false, + appId: "kyzonspacevideo", + delegationCredentialId: null, + }); + + mockPost.mockResolvedValue({ data: newTokens }); + mockGetKyzonCredentialKey.mockReturnValue(updatedCredentialKey); + + prismaMock.credential.update.mockResolvedValue({ + id: credentialId, + key: updatedCredentialKey as any, + userId: 1, + teamId: null, + type: "kyzonspace_video", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + invalid: false, + appId: "kyzonspacevideo", + delegationCredentialId: null, + }); + + // Start multiple refresh requests concurrently + const promise1 = refreshKyzonToken(credentialId); + const promise2 = refreshKyzonToken(credentialId); + const promise3 = refreshKyzonToken(credentialId); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + // All should return the same result + expect(result1).toEqual(updatedCredentialKey); + expect(result2).toEqual(updatedCredentialKey); + expect(result3).toEqual(updatedCredentialKey); + + // But the actual refresh should only happen once + expect(prismaMock.credential.findUnique).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(prismaMock.credential.update).toHaveBeenCalledTimes(1); + }); + + test("handles axios error with response details", async () => { + const credentialId = 123; + const axiosError = { + message: "Request failed", + response: { + status: 400, + data: { error: "invalid_grant" }, + }, + code: "BAD_REQUEST", + }; + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + prismaMock.credential.findUnique.mockResolvedValue({ + id: credentialId, + key: mockCredentialKey, + }); + + mockPost.mockRejectedValue(axiosError); + + const result = await refreshKyzonToken(credentialId); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + "KYZON refresh token is invalid or expired. User needs to reconnect.", + expect.objectContaining({ + credentialId: 123, + status: 400, + message: "Request failed", + code: "BAD_REQUEST", + }) + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/app-store/kyzonspacevideo/lib/tokenManager.ts b/packages/app-store/kyzonspacevideo/lib/tokenManager.ts new file mode 100644 index 00000000000000..866cfc59f59383 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/lib/tokenManager.ts @@ -0,0 +1,131 @@ +import prisma from "@calcom/prisma"; + +import { + getKyzonCredentialKey, + kyzonCredentialKeySchema, + type KyzonCredentialKey, +} from "./KyzonCredentialKey"; +import { kyzonAxiosInstance } from "./axios"; +import { getKyzonAppKeys } from "./getKyzonAppKeys"; + +const inFlightRefresh = new Map>(); + +export async function refreshKyzonToken(credentialId: number): Promise { + const existing = inFlightRefresh.get(credentialId); + if (existing) return existing; + + const refreshRequest = _refreshKyzonToken(credentialId); + + inFlightRefresh.set(credentialId, refreshRequest); + return await refreshRequest; +} + +async function _refreshKyzonToken(credentialId: number): Promise { + try { + const credential = await prisma.credential.findUnique({ + where: { id: credentialId }, + select: { + key: true, + }, + }); + + if (!credential?.key) { + return null; + } + + const currentKey = kyzonCredentialKeySchema.parse(credential.key); + + if (!currentKey.refresh_token) { + console.warn( + `KYZON token refresh failed: No refresh token available for credential ${credentialId}. User may need to reconnect to KYZON Space.` + ); + return null; + } + + const { client_id, client_secret, api_key } = await getKyzonAppKeys(); + + const { data: newTokens } = await kyzonAxiosInstance.post<{ + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; + scope: string; + }>( + "/oauth/token", + { + grant_type: "refresh_token", + refresh_token: currentKey.refresh_token, + client_id, + client_secret, + }, + { + headers: { + "X-API-Key": api_key, + }, + } + ); + + const newCredentialKey = getKyzonCredentialKey({ ...currentKey, ...newTokens }); + + // Update the credential with new tokens + await prisma.credential.update({ + where: { id: credentialId }, + data: { + key: newCredentialKey, + }, + }); + + return newCredentialKey; + } catch (error) { + const err = error as any; + + // Provide more detailed logging based on error type + let logLevel = "error"; + let errorContext = "Failed to refresh KYZON token"; + + if (err?.response?.status === 400) { + errorContext = "KYZON refresh token is invalid or expired. User needs to reconnect."; + } else if (err?.response?.status === 401) { + errorContext = "KYZON authentication failed during token refresh. User needs to reconnect."; + } else if (err?.response?.status === 403) { + errorContext = "KYZON token refresh not allowed. Check app permissions."; + } else if (err?.response?.status >= 500) { + errorContext = "KYZON server error during token refresh. Will retry on next request."; + logLevel = "warn"; // Server errors are temporary + } else if (err?.code === "ECONNREFUSED" || err?.code === "ENOTFOUND") { + errorContext = "Cannot connect to KYZON servers for token refresh. Will retry on next request."; + logLevel = "warn"; // Network errors are temporary + } + + if (logLevel === "error") { + console.error(errorContext, { + credentialId, + status: err?.response?.status, + message: err?.message, + code: err?.code, + }); + } else { + console.warn(errorContext, { + credentialId, + status: err?.response?.status, + message: err?.message, + code: err?.code, + }); + } + + return null; + } finally { + inFlightRefresh.delete(credentialId); + } +} + +export function isTokenExpired(token: KyzonCredentialKey): boolean { + const now = Date.now(); + + if (!token || typeof token.expiry_date !== "number" || !Number.isFinite(token.expiry_date)) { + return true; + } + + // expiry_date already includes a 60s skew; no extra buffer here + return token.expiry_date <= now; +} diff --git a/packages/app-store/kyzonspacevideo/package.json b/packages/app-store/kyzonspacevideo/package.json new file mode 100644 index 00000000000000..2f31d4dce1ae76 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/kyzonspacevideo", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "KYZON Space is a browser-based platform that makes it easy to present, collaborate, and edit documents on video calls." +} diff --git a/packages/app-store/kyzonspacevideo/static/1.png b/packages/app-store/kyzonspacevideo/static/1.png new file mode 100644 index 00000000000000..41794436f696a0 Binary files /dev/null and b/packages/app-store/kyzonspacevideo/static/1.png differ diff --git a/packages/app-store/kyzonspacevideo/static/2.png b/packages/app-store/kyzonspacevideo/static/2.png new file mode 100644 index 00000000000000..e2130468f7f34d Binary files /dev/null and b/packages/app-store/kyzonspacevideo/static/2.png differ diff --git a/packages/app-store/kyzonspacevideo/static/3.png b/packages/app-store/kyzonspacevideo/static/3.png new file mode 100644 index 00000000000000..3db076b335320c Binary files /dev/null and b/packages/app-store/kyzonspacevideo/static/3.png differ diff --git a/packages/app-store/kyzonspacevideo/static/4.png b/packages/app-store/kyzonspacevideo/static/4.png new file mode 100644 index 00000000000000..1fd02beb901fb2 Binary files /dev/null and b/packages/app-store/kyzonspacevideo/static/4.png differ diff --git a/packages/app-store/kyzonspacevideo/static/icon.svg b/packages/app-store/kyzonspacevideo/static/icon.svg new file mode 100644 index 00000000000000..9752ab94330a42 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/static/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app-store/kyzonspacevideo/zod.ts b/packages/app-store/kyzonspacevideo/zod.ts new file mode 100644 index 00000000000000..fb96bd1d0137e6 --- /dev/null +++ b/packages/app-store/kyzonspacevideo/zod.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({ + client_id: z.string().min(1), + client_secret: z.string().min(1), + api_key: z.string().min(1), +}); diff --git a/packages/app-store/video.adapters.generated.ts b/packages/app-store/video.adapters.generated.ts index 4df261a206b380..e04a808c4166f7 100644 --- a/packages/app-store/video.adapters.generated.ts +++ b/packages/app-store/video.adapters.generated.ts @@ -10,6 +10,7 @@ export const VideoApiAdapterMap = huddle01video: import("./huddle01video/lib/VideoApiAdapter"), jelly: import("./jelly/lib/VideoApiAdapter"), jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"), + kyzonspacevideo: import("./kyzonspacevideo/lib/VideoApiAdapter"), nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"), office365video: import("./office365video/lib/VideoApiAdapter"), shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"), diff --git a/yarn.lock b/yarn.lock index 02df090f68b93c..96d82b4f5defde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2710,6 +2710,15 @@ __metadata: languageName: unknown linkType: soft +"@calcom/kyzonspacevideo@workspace:packages/app-store/kyzonspacevideo": + version: 0.0.0-use.local + resolution: "@calcom/kyzonspacevideo@workspace:packages/app-store/kyzonspacevideo" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + languageName: unknown + linkType: soft + "@calcom/larkcalendar@workspace:packages/app-store/larkcalendar": version: 0.0.0-use.local resolution: "@calcom/larkcalendar@workspace:packages/app-store/larkcalendar"