From ebbb1dcce3289329a2c2d7fa242dc51126cbd86b Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Thu, 25 Apr 2024 16:51:03 +0300 Subject: [PATCH 1/8] fix: oAuthClient Creation Form (#14750) --- .../organizations/platform/oauth-clients/OAuthClientForm.tsx | 3 ++- .../organizations/platform/oauth-clients/useOAuthClients.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx index 7225896d9d2f3..119c66805a685 100644 --- a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx +++ b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx @@ -71,10 +71,11 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => { data.bookingRescheduleRedirectUri && setValue("bookingRescheduleRedirectUri", data.bookingRescheduleRedirectUri); setValue("areEmailsEnabled", data?.areEmailsEnabled); - data?.redirectUris.forEach((uri: string, index: number) => { + data.redirectUris?.forEach?.((uri: string, index: number) => { index === 0 && setValue(`redirectUris.${index}.uri`, uri); index !== 0 && append({ uri }); }); + if (!data.permissions) return; if (hasAppsReadPermission(data.permissions)) setValue("appsRead", true); if (hasAppsWritePermission(data.permissions)) setValue("appsWrite", true); if (hasBookingReadPermission(data.permissions)) setValue("bookingRead", true); diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts index 0ef3758e2b8e8..bef11e934223b 100644 --- a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts +++ b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts @@ -36,7 +36,7 @@ export const useOAuthClient = (clientId?: string) => { headers: { "Content-type": "application/json" }, }).then((res) => res.json()); }, - enabled: clientId !== undefined, + enabled: Boolean(clientId), staleTime: Infinity, }); From d47c6b3fdb6f1d66314438fd5194e425e5a6494a Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 25 Apr 2024 22:17:17 +0530 Subject: [PATCH 2/8] fix: Credential Syncing Improvements (#14588) * Add example app to test credential sync * Fixes * Changes to normalize flow of GoogleCalendar and Zoom * Add unit tests * PR Feedback * credential-sync-more-tests-and-more-apps * Fix yarn.lock * Clear cache * Add test * Fix yarn.lock * Fix 204 handling * Fix yarn.lock --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .env.example | 1 + .../v1/pages/api/credential-sync/_patch.ts | 4 +- .../api/v1/pages/api/credential-sync/_post.ts | 4 +- apps/web/pages/api/webhook/app-credential.ts | 7 +- example-apps/credential-sync/.env.example | 15 + example-apps/credential-sync/README.md | 9 + example-apps/credential-sync/constants.ts | 13 + .../credential-sync/lib/integrations.ts | 89 ++ example-apps/credential-sync/next-env.d.ts | 5 + example-apps/credential-sync/next.config.js | 9 + example-apps/credential-sync/package.json | 31 + .../credential-sync/pages/api/getToken.ts | 41 + .../pages/api/setTokenInCalCom.ts | 67 + example-apps/credential-sync/pages/index.tsx | 57 + example-apps/credential-sync/tsconfig.json | 19 + package.json | 3 +- .../_utils/getParsedAppKeysFromSlug.ts | 6 +- .../app-store/_utils/invalidateCredential.ts | 21 + .../oauth/AxiosLikeResponseToFetchResponse.ts | 22 + .../_utils/oauth/OAuthManager.test.ts | 1410 +++++++++++++++++ .../app-store/_utils/oauth/OAuthManager.ts | 559 +++++++ .../oauth/getTokenObjectFromCredential.ts | 24 + .../_utils/oauth/markTokenAsExpired.ts | 21 + .../_utils/oauth/oAuthManagerHelper.ts | 26 + .../app-store/_utils/oauth/universalSchema.ts | 45 + .../_utils/oauth/updateTokenObject.ts | 22 + packages/app-store/_utils/testUtils.ts | 43 + .../googlecalendar/lib/CalendarService.ts | 201 ++- .../app-store/office365calendar/api/add.ts | 4 +- .../office365calendar/api/callback.ts | 4 +- .../office365calendar/lib/CalendarService.ts | 128 +- packages/app-store/office365video/api/add.ts | 4 +- .../app-store/office365video/api/callback.ts | 4 +- .../lib/VideoApiAdapter.test.ts | 282 ++++ .../office365video/lib/VideoApiAdapter.ts | 180 +-- packages/app-store/zoomvideo/api/callback.ts | 4 +- .../zoomvideo/lib/VideoApiAdapter.ts | 249 ++- packages/core/CalendarManager.ts | 2 +- packages/core/videoClient.ts | 6 +- packages/lib/constants.ts | 2 + packages/lib/errors.ts | 3 +- packages/lib/safeStringify.ts | 7 + packages/prisma/schema.prisma | 2 +- yarn.lock | 548 +++++-- 44 files changed, 3610 insertions(+), 593 deletions(-) create mode 100644 example-apps/credential-sync/.env.example create mode 100644 example-apps/credential-sync/README.md create mode 100644 example-apps/credential-sync/constants.ts create mode 100644 example-apps/credential-sync/lib/integrations.ts create mode 100644 example-apps/credential-sync/next-env.d.ts create mode 100644 example-apps/credential-sync/next.config.js create mode 100644 example-apps/credential-sync/package.json create mode 100644 example-apps/credential-sync/pages/api/getToken.ts create mode 100644 example-apps/credential-sync/pages/api/setTokenInCalCom.ts create mode 100644 example-apps/credential-sync/pages/index.tsx create mode 100644 example-apps/credential-sync/tsconfig.json create mode 100644 packages/app-store/_utils/invalidateCredential.ts create mode 100644 packages/app-store/_utils/oauth/AxiosLikeResponseToFetchResponse.ts create mode 100644 packages/app-store/_utils/oauth/OAuthManager.test.ts create mode 100644 packages/app-store/_utils/oauth/OAuthManager.ts create mode 100644 packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts create mode 100644 packages/app-store/_utils/oauth/markTokenAsExpired.ts create mode 100644 packages/app-store/_utils/oauth/oAuthManagerHelper.ts create mode 100644 packages/app-store/_utils/oauth/universalSchema.ts create mode 100644 packages/app-store/_utils/oauth/updateTokenObject.ts create mode 100644 packages/app-store/_utils/testUtils.ts create mode 100644 packages/app-store/office365video/lib/VideoApiAdapter.test.ts diff --git a/.env.example b/.env.example index 644c8b2462163..dcc04ef5d7ddc 100644 --- a/.env.example +++ b/.env.example @@ -299,6 +299,7 @@ E2E_TEST_CALCOM_GCAL_KEYS= CALCOM_CREDENTIAL_SYNC_SECRET="" # This is the header name that will be used to verify the webhook secret. Should be in lowercase CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret" +# This the endpoint from which the token is fetched CALCOM_CREDENTIAL_SYNC_ENDPOINT="" # Key should match on Cal.com and your application # must be 24 bytes for AES256 encryption algorithm diff --git a/apps/api/v1/pages/api/credential-sync/_patch.ts b/apps/api/v1/pages/api/credential-sync/_patch.ts index ac7aac2ecb5ed..c4cc8109afd21 100644 --- a/apps/api/v1/pages/api/credential-sync/_patch.ts +++ b/apps/api/v1/pages/api/credential-sync/_patch.ts @@ -1,6 +1,6 @@ import type { NextApiRequest } from "next"; -import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; @@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) { symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") ); - const key = minimumTokenResponseSchema.parse(decryptedKey); + const key = OAuth2UniversalSchema.parse(decryptedKey); const credential = await prisma.credential.update({ where: { diff --git a/apps/api/v1/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts index 6a6b7aebd982b..32d74da2c10a6 100644 --- a/apps/api/v1/pages/api/credential-sync/_post.ts +++ b/apps/api/v1/pages/api/credential-sync/_post.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; -import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { HttpError } from "@calcom/lib/http-error"; @@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) { symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") ); - const key = minimumTokenResponseSchema.parse(decryptedKey); + const key = OAuth2UniversalSchema.parse(decryptedKey); // Need to get app type const app = await prisma.app.findUnique({ diff --git a/apps/web/pages/api/webhook/app-credential.ts b/apps/web/pages/api/webhook/app-credential.ts index cf12409688389..527b9232c3933 100644 --- a/apps/web/pages/api/webhook/app-credential.ts +++ b/apps/web/pages/api/webhook/app-credential.ts @@ -24,7 +24,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(403).json({ message: "Invalid credential sync secret" }); } - const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body); + const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(req.body); + if (!reqBodyParsed.success) { + return res.status(400).json({ error: reqBodyParsed.error.issues }); + } + + const reqBody = reqBodyParsed.data; const user = await prisma.user.findUnique({ where: { id: reqBody.userId } }); diff --git a/example-apps/credential-sync/.env.example b/example-apps/credential-sync/.env.example new file mode 100644 index 0000000000000..5710087bfc38d --- /dev/null +++ b/example-apps/credential-sync/.env.example @@ -0,0 +1,15 @@ +CALCOM_TEST_USER_ID=1 + +GOOGLE_REFRESH_TOKEN= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +ZOOM_REFRESH_TOKEN= +ZOOM_CLIENT_ID= +ZOOM_CLIENT_SECRET= +CALCOM_ADMIN_API_KEY= + +# Refer to Cal.com env variables as these are set in their env +CALCOM_CREDENTIAL_SYNC_SECRET=""; +CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"; +CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""; \ No newline at end of file diff --git a/example-apps/credential-sync/README.md b/example-apps/credential-sync/README.md new file mode 100644 index 0000000000000..54803fe5eef2c --- /dev/null +++ b/example-apps/credential-sync/README.md @@ -0,0 +1,9 @@ +# README + +This is an example app that acts as the source of truth for Cal.com Apps credentials. This app is capable of generating the access_token itself and then sync those to Cal.com app. + +## How to start +`yarn dev` starts the server on port 5100. After this open http://localhost:5100 and from there you would be able to manage the tokens for various Apps. + +## Endpoints +http://localhost:5100/api/getToken should be set as the value of env variable CALCOM_CREDENTIAL_SYNC_ENDPOINT in Cal.com \ No newline at end of file diff --git a/example-apps/credential-sync/constants.ts b/example-apps/credential-sync/constants.ts new file mode 100644 index 0000000000000..983b68eb31c02 --- /dev/null +++ b/example-apps/credential-sync/constants.ts @@ -0,0 +1,13 @@ +// How to get it? -> Establish a connection with Google(e.g. through cal.com app) and then copy the refresh_token from there. +export const GOOGLE_REFRESH_TOKEN = process.env.GOOGLE_REFRESH_TOKEN; +export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; + +export const ZOOM_REFRESH_TOKEN = process.env.ZOOM_REFRESH_TOKEN; +export const ZOOM_CLIENT_ID = process.env.ZOOM_CLIENT_ID; +export const ZOOM_CLIENT_SECRET = process.env.ZOOM_CLIENT_SECRET; +export const CALCOM_ADMIN_API_KEY = process.env.CALCOM_ADMIN_API_KEY; + +export const CALCOM_CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET; +export const CALCOM_CREDENTIAL_SYNC_HEADER_NAME = process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME; +export const CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY = process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY; diff --git a/example-apps/credential-sync/lib/integrations.ts b/example-apps/credential-sync/lib/integrations.ts new file mode 100644 index 0000000000000..78ced89729890 --- /dev/null +++ b/example-apps/credential-sync/lib/integrations.ts @@ -0,0 +1,89 @@ +import { + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + GOOGLE_REFRESH_TOKEN, + ZOOM_CLIENT_ID, + ZOOM_CLIENT_SECRET, + ZOOM_REFRESH_TOKEN, +} from "../constants"; + +export async function generateGoogleCalendarAccessToken() { + const keys = { + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + redirect_uris: [ + "http://localhost:3000/api/integrations/googlecalendar/callback", + "http://localhost:3000/api/auth/callback/google", + ], + }; + const clientId = keys.client_id; + const clientSecret = keys.client_secret; + const refresh_token = GOOGLE_REFRESH_TOKEN; + + const url = "https://oauth2.googleapis.com/token"; + const data = { + client_id: clientId, + client_secret: clientSecret, + refresh_token: refresh_token, + grant_type: "refresh_token", + }; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(data), + }); + + const json = await response.json(); + if (json.access_token) { + console.log("Access Token:", json.access_token); + return json.access_token; + } else { + console.error("Failed to retrieve access token:", json); + return null; + } + } catch (error) { + console.error("Error fetching access token:", error); + return null; + } +} + +export async function generateZoomAccessToken() { + const client_id = ZOOM_CLIENT_ID; // Replace with your client ID + const client_secret = ZOOM_CLIENT_SECRET; // Replace with your client secret + const refresh_token = ZOOM_REFRESH_TOKEN; // Replace with your refresh token + + const url = "https://zoom.us/oauth/token"; + const auth = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); + + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("refresh_token", refresh_token); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + + const json = await response.json(); + if (json.access_token) { + console.log("New Access Token:", json.access_token); + console.log("New Refresh Token:", json.refresh_token); // Save this refresh token securely + return json.access_token; // You might also want to return the new refresh token if applicable + } else { + console.error("Failed to refresh access token:", json); + return null; + } + } catch (error) { + console.error("Error refreshing access token:", error); + return null; + } +} diff --git a/example-apps/credential-sync/next-env.d.ts b/example-apps/credential-sync/next-env.d.ts new file mode 100644 index 0000000000000..4f11a03dc6cc3 --- /dev/null +++ b/example-apps/credential-sync/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/example-apps/credential-sync/next.config.js b/example-apps/credential-sync/next.config.js new file mode 100644 index 0000000000000..e5c4d88c70fc7 --- /dev/null +++ b/example-apps/credential-sync/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +require("dotenv").config({ path: "../../.env" }); + +const nextConfig = { + reactStrictMode: true, + transpilePackages: ["@calcom/lib"], +}; + +module.exports = nextConfig; diff --git a/example-apps/credential-sync/package.json b/example-apps/credential-sync/package.json new file mode 100644 index 0000000000000..80fe44d47e80b --- /dev/null +++ b/example-apps/credential-sync/package.json @@ -0,0 +1,31 @@ +{ + "name": "@calcom/example-app-credential-sync", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "PORT=5100 next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@calcom/atoms": "*", + "@prisma/client": "5.4.2", + "next": "14.0.4", + "prisma": "^5.7.1", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20.3.1", + "@types/react": "^18", + "@types/react-dom": "^18", + "autoprefixer": "^10.0.1", + "dotenv": "^16.3.1", + "eslint": "^8", + "eslint-config-next": "14.0.4", + "postcss": "^8", + "tailwindcss": "^3.3.0", + "typescript": "^4.9.4" + } +} diff --git a/example-apps/credential-sync/pages/api/getToken.ts b/example-apps/credential-sync/pages/api/getToken.ts new file mode 100644 index 0000000000000..3956a2ecdbed4 --- /dev/null +++ b/example-apps/credential-sync/pages/api/getToken.ts @@ -0,0 +1,41 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { CALCOM_CREDENTIAL_SYNC_HEADER_NAME, CALCOM_CREDENTIAL_SYNC_SECRET } from "../../constants"; +import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const secret = req.headers[CALCOM_CREDENTIAL_SYNC_HEADER_NAME]; + console.log("getToken hit"); + try { + if (!secret) { + return res.status(403).json({ message: "secret header not set" }); + } + if (secret !== CALCOM_CREDENTIAL_SYNC_SECRET) { + return res.status(403).json({ message: "Invalid secret" }); + } + + const calcomUserId = req.body.calcomUserId; + const appSlug = req.body.appSlug; + console.log("getToken Params", { + calcomUserId, + appSlug, + }); + let accessToken; + if (appSlug === "google-calendar") { + accessToken = await generateGoogleCalendarAccessToken(); + } else if (appSlug === "zoom") { + accessToken = await generateZoomAccessToken(); + } else { + throw new Error("Unhandled values"); + } + if (!accessToken) { + throw new Error("Unable to generate token"); + } + res.status(200).json({ + _1: true, + access_token: accessToken, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +} diff --git a/example-apps/credential-sync/pages/api/setTokenInCalCom.ts b/example-apps/credential-sync/pages/api/setTokenInCalCom.ts new file mode 100644 index 0000000000000..ac957b6a1dfbe --- /dev/null +++ b/example-apps/credential-sync/pages/api/setTokenInCalCom.ts @@ -0,0 +1,67 @@ +import type { NextApiRequest } from "next"; + +import { symmetricEncrypt } from "@calcom/lib/crypto"; + +import { + CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY, + CALCOM_CREDENTIAL_SYNC_SECRET, + CALCOM_CREDENTIAL_SYNC_HEADER_NAME, + CALCOM_ADMIN_API_KEY, +} from "../../constants"; +import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations"; + +export default async function handler(req: NextApiRequest, res) { + const isInvalid = req.query["invalid"] === "1"; + const userId = parseInt(req.query["userId"] as string); + const appSlug = req.query["appSlug"]; + + try { + let accessToken; + if (appSlug === "google-calendar") { + accessToken = await generateGoogleCalendarAccessToken(); + } else if (appSlug === "zoom") { + accessToken = await generateZoomAccessToken(); + } else { + throw new Error(`Unhandled appSlug: ${appSlug}`); + } + + if (!accessToken) { + return res.status(500).json({ error: "Could not get access token" }); + } + + const result = await fetch( + `http://localhost:3002/api/v1/credential-sync?apiKey=${CALCOM_ADMIN_API_KEY}&userId=${userId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + [CALCOM_CREDENTIAL_SYNC_HEADER_NAME]: CALCOM_CREDENTIAL_SYNC_SECRET, + }, + body: JSON.stringify({ + appSlug, + encryptedKey: symmetricEncrypt( + JSON.stringify({ + access_token: isInvalid ? "1233231231231" : accessToken, + }), + CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY + ), + }), + } + ); + + const clonedResult = result.clone(); + try { + if (result.ok) { + const json = await result.json(); + return res.status(200).json(json); + } else { + return res.status(400).json({ error: await clonedResult.text() }); + } + } catch (e) { + return res.status(400).json({ error: await clonedResult.text() }); + } + } catch (error) { + console.error(error); + return res.status(400).json({ message: "Internal Server Error", error: error.message }); + } +} diff --git a/example-apps/credential-sync/pages/index.tsx b/example-apps/credential-sync/pages/index.tsx new file mode 100644 index 0000000000000..824b1af04d7cd --- /dev/null +++ b/example-apps/credential-sync/pages/index.tsx @@ -0,0 +1,57 @@ +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Index() { + const [data, setData] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const appSlug = searchParams.get("appSlug"); + const userId = searchParams.get("userId"); + + useEffect(() => { + let isRedirectNeeded = false; + const newSearchParams = new URLSearchParams(new URL(document.URL).searchParams); + if (!userId) { + newSearchParams.set("userId", "1"); + isRedirectNeeded = true; + } + + if (!appSlug) { + newSearchParams.set("appSlug", "google-calendar"); + isRedirectNeeded = true; + } + + if (isRedirectNeeded) { + router.push(`${pathname}?${newSearchParams.toString()}`); + } + }, [router, pathname, userId, appSlug]); + + async function updateToken({ invalid } = { invalid: false }) { + const res = await fetch( + `/api/setTokenInCalCom?invalid=${invalid ? 1 : 0}&userId=${userId}&appSlug=${appSlug}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + + const data = await res.json(); + setData(JSON.stringify(data)); + } + + return ( +
+

Welcome to Credential Sync Playground

+

+ You are managing credentials for cal.com userId={userId} for{" "} + appSlug={appSlug}. Update query params to manage a different user or app{" "} +

+ + +
{data}
+
+ ); +} diff --git a/example-apps/credential-sync/tsconfig.json b/example-apps/credential-sync/tsconfig.json new file mode 100644 index 0000000000000..093985aafb4ab --- /dev/null +++ b/example-apps/credential-sync/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index ccc3771f3c3a5..5bfff32c1a05d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "packages/app-store/*", "packages/app-store/ee/*", "packages/platform/*", - "packages/platform/examples/base" + "packages/platform/examples/base", + "example-apps/*" ], "scripts": { "app-store-cli": "yarn workspace @calcom/app-store-cli", diff --git a/packages/app-store/_utils/getParsedAppKeysFromSlug.ts b/packages/app-store/_utils/getParsedAppKeysFromSlug.ts index 1c8d8b99fa0bd..4a56cd9b2949b 100644 --- a/packages/app-store/_utils/getParsedAppKeysFromSlug.ts +++ b/packages/app-store/_utils/getParsedAppKeysFromSlug.ts @@ -1,8 +1,12 @@ import type Zod from "zod"; +import type z from "zod"; import getAppKeysFromSlug from "./getAppKeysFromSlug"; -export async function getParsedAppKeysFromSlug(slug: string, schema: Zod.Schema) { +export async function getParsedAppKeysFromSlug( + slug: string, + schema: T +): Promise> { const appKeys = await getAppKeysFromSlug(slug); return schema.parse(appKeys); } diff --git a/packages/app-store/_utils/invalidateCredential.ts b/packages/app-store/_utils/invalidateCredential.ts new file mode 100644 index 0000000000000..82bce801fcf39 --- /dev/null +++ b/packages/app-store/_utils/invalidateCredential.ts @@ -0,0 +1,21 @@ +import prisma from "@calcom/prisma"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +export const invalidateCredential = async (credentialId: CredentialPayload["id"]) => { + const credential = await prisma.credential.findUnique({ + where: { + id: credentialId, + }, + }); + + if (credential) { + await prisma.credential.update({ + where: { + id: credentialId, + }, + data: { + invalid: true, + }, + }); + } +}; diff --git a/packages/app-store/_utils/oauth/AxiosLikeResponseToFetchResponse.ts b/packages/app-store/_utils/oauth/AxiosLikeResponseToFetchResponse.ts new file mode 100644 index 0000000000000..19c8f73b6e452 --- /dev/null +++ b/packages/app-store/_utils/oauth/AxiosLikeResponseToFetchResponse.ts @@ -0,0 +1,22 @@ +/** + * This class is used to convert axios like response to fetch response + */ +export class AxiosLikeResponseToFetchResponse< + T extends { + status: number; + statusText: string; + data: unknown; + } +> extends Response { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any; + constructor(axiomResponse: T) { + super(JSON.stringify(axiomResponse.data), { + status: axiomResponse.status, + statusText: axiomResponse.statusText, + }); + } + async json() { + return super.json() as unknown as T["data"]; + } +} diff --git a/packages/app-store/_utils/oauth/OAuthManager.test.ts b/packages/app-store/_utils/oauth/OAuthManager.test.ts new file mode 100644 index 0000000000000..e8e8b3dbd5b6e --- /dev/null +++ b/packages/app-store/_utils/oauth/OAuthManager.test.ts @@ -0,0 +1,1410 @@ +// import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; +import { afterEach, expect, test, vi, describe } from "vitest"; +import "vitest-fetch-mock"; + +import { + generateJsonResponse, + successResponse, + internalServerErrorResponse, + generateTextResponse, +} from "../testUtils"; +import { OAuthManager, TokenStatus } from "./OAuthManager"; + +afterEach(() => { + vi.resetAllMocks(); +}); + +const credentialSyncVariables = { + APP_CREDENTIAL_SHARING_ENABLED: false, + CREDENTIAL_SYNC_SECRET: "SECRET", + CREDENTIAL_SYNC_SECRET_HEADER_NAME: "calcom-credential-sync-secret", + CREDENTIAL_SYNC_ENDPOINT: "https://example.com/getToken", +}; + +function getDummyTokenObject( + token: { refresh_token?: string; expiry_date?: number; expires_in?: number } | null = null +) { + return { + access_token: "ACCESS_TOKEN", + ...token, + }; +} + +function getExpiredTokenObject() { + return getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() - 10 * 1000, + }); +} + +describe("Credential Sync Disabled", () => { + const useCredentialSyncVariables = credentialSyncVariables; + describe("API: `getTokenObjectOrFetch`", () => { + describe("`fetchNewTokenObject` gets called with refresh_token arg", async () => { + test('refresh_token argument would be null if "refresh_token" is not present in the currentTokenObject', async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(successResponse({ json: getDummyTokenObject() })); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + await auth.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: null }); + }); + + test('refresh_token would be the value if "refresh_token" is present in the currentTokenObject', async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + }); + }); + + describe("expiry_date based token refresh", () => { + describe("checking using expiry_date", () => { + test("fetchNewTokenObject is not called if token has not expired", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).not.toHaveBeenCalled(); + expect(updateTokenObject).not.toHaveBeenCalled(); + }); + + test("`fetchNewTokenObject` is called if token has expired. Also, `updateTokenObject` is called with currentTokenObject and newTokenObject merged", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const currentTokenObject = getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expiry_date: Date.now() - 10 * 1000, + }); + const newTokenObjectInResponse = getDummyTokenObject(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: newTokenObjectInResponse })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: currentTokenObject, + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + expect(updateTokenObject).toHaveBeenCalledWith({ + ...currentTokenObject, + ...newTokenObjectInResponse, + // Consider the token as expired as newTokenObjectInResponse didn't have expiry + expiry_date: 0, + }); + }); + }); + + describe("checking using expires_in", () => { + // eslint-disable-next-line playwright/max-nested-describe + describe("expires_in(relative to current time)", () => { + test("fetchNewTokenObject is called if expires_in is 0", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expires_in: 0, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + }); + + test("`fetchNewTokenObject` is not called even if expires_in is any non zero positive value(that is not 'time since epoch')", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expires_in: 5, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + }); + }); + + // eslint-disable-next-line playwright/max-nested-describe + describe("expires_in(relative to epoch time)", () => { + test("fetchNewTokenObject is not called if token has not expired", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expires_in: Date.now() / 1000 + 5, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).not.toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + }); + + test("fetchNewTokenObject is called if token has expired", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expires_in: Date.now() / 1000 + 0, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" }); + }); + }); + }); + }); + + test("If fetchNewTokenObject returns null then auth.getTokenObjectOrFetch would throw error", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject: async () => { + return null; + }, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + expect(async () => { + return auth.getTokenObjectOrFetch(); + }).rejects.toThrowError("could not refresh the token"); + }); + + test("if fetchNewTokenObject throws error that's not handled by isTokenObjectUnusable and isAccessTokenUnusable then auth.getTokenObjectOrFetch would still not throw error", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject: async () => { + throw new Error("testError"); + }, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + expect(async () => { + return auth.getTokenObjectOrFetch(); + }).rejects.toThrowError("Invalid token response"); + }); + + test("if fetchNewTokenObject throws error that's handled by isTokenObjectUnusable then auth.getTokenObjectOrFetch would still throw error but a different one as access_token won't be available", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject: async () => { + throw new Error("testError"); + }, + isTokenObjectUnusable: async () => { + return { + reason: "some reason", + }; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + expect(async () => { + return auth.getTokenObjectOrFetch(); + }).rejects.toThrowError("Invalid token response"); + }); + }); + + describe("API: `request`", () => { + test("It would call fetch by adding Authorization and content header automatically", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(generateJsonResponse({ json: { key: "value" } }))); + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ tokenStatus: TokenStatus.VALID, json: { key: "value" } }); + const fetchCallArguments = fetchMock.mock.calls[0]; + expect(fetchCallArguments[0]).toBe("https://example.com"); + // Verify that Authorization header is added automatically + // Along with other passed headers and other options + expect(fetchCallArguments[1]).toEqual( + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer ACCESS_TOKEN", + }, + body: JSON.stringify({ + key: "value", + }), + }) + ); + }); + + test("If `isTokenObjectUnusable` marks the response invalid, then `invalidateTokenObject` function is called", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + autoCheckTokenExpiryOnRequest: false, + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async (response) => { + const jsonRes = await response.json(); + expect(jsonRes).toEqual(fakedFetchJsonResult); + return { + reason: "some reason", + }; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT, + json: fakedFetchJsonResult, + }); + expect(invalidateTokenObject).toHaveBeenCalled(); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + + test("If `isAccessTokenUnusable` marks the response invalid, then `expireAccessToken` function is called", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + autoCheckTokenExpiryOnRequest: false, + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async (response) => { + const jsonRes = await response.json(); + expect(jsonRes).toEqual(fakedFetchJsonResult); + return { + reason: "some reason", + }; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN, + json: fakedFetchJsonResult, + }); + + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).toHaveBeenCalled(); + }); + + test("If the response is empty string make the json null(because empty string which is usually the case with 204 status is not a valid json). There shouldn't be any error even if `isTokenObjectUnusable` and `isAccessTokenUnusable` do json()", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const fakedFetchResponse = generateTextResponse({ text: "", status: 204 }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async (response) => { + return await response.json(); + }, + isAccessTokenUnusable: async (response) => { + return await response.json(); + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ tokenStatus: TokenStatus.VALID, json: null }); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + + test("If status is not okay it throws error with statusText", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = generateJsonResponse({ + json: fakedFetchJsonResult, + status: 500, + statusText: "Internal Server Error", + }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + const { json, tokenStatus } = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + expect(json).toEqual(fakedFetchJsonResult); + expect(tokenStatus).toEqual(TokenStatus.INCONCLUSIVE); + }); + + test("if `customFetch` throws error that is handled by `isTokenObjectUnusable` then `request` would still throw error but also invalidate", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return { + reason: "some reason", + }; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + await expect( + auth.request(() => { + throw new Error("Internal Server Error"); + }) + ).rejects.toThrowError("Internal Server Error"); + + expect(invalidateTokenObject).toHaveBeenCalled(); + }); + }); + + describe("API: `requestRaw`", () => { + test("It would call fetch by adding Authorization and content header automatically", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + fetchMock.mockReturnValueOnce(Promise.resolve(generateJsonResponse({ json: { key: "value" } }))); + const response = await auth.requestRaw({ + url: "https://example.com", + options: { + method: "POST", + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(await response.json()).toEqual({ key: "value" }); + const fetchCallArguments = fetchMock.mock.calls[0]; + expect(fetchCallArguments[0]).toBe("https://example.com"); + // Verify that Authorization header is added automatically + // Along with other passed headers and other options + expect(fetchCallArguments[1]).toEqual( + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer ACCESS_TOKEN", + }, + body: JSON.stringify({ + key: "value", + }), + }) + ); + }); + }); +}); + +describe("Credential Sync Enabled", () => { + const useCredentialSyncVariables = { + ...credentialSyncVariables, + APP_CREDENTIAL_SHARING_ENABLED: true, + }; + describe("API: `getTokenObjectOrFetch`", () => { + test("CREDENTIAL_SYNC_ENDPOINT is hit if no expiry_date is set in the `currentTokenObject`", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + const fakedFetchResponse = generateJsonResponse({ json: getDummyTokenObject() }); + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + await auth1.getTokenObjectOrFetch(); + expectToBeTokenGetCall({ + fetchCall: fetchMock.mock.calls[0], + useCredentialSyncVariables, + userId, + appSlug: "demo-app", + }); + expect(fetchNewTokenObject).not.toHaveBeenCalled(); + }); + + describe("expiry_date based token refresh", () => { + test("CREDENTIAL_SYNC_ENDPOINT is not hit if token has not expired", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + await auth1.getTokenObjectOrFetch(); + expect(fetchNewTokenObject).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("CREDENTIAL_SYNC_ENDPOINT is hit if token has expired", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi + .fn() + .mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() })); + + const auth1 = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + refresh_token: "REFRESH_TOKEN", + expiry_date: Date.now() - 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + const fakedFetchResponse = generateJsonResponse({ json: getDummyTokenObject() }); + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + await auth1.getTokenObjectOrFetch(); + expectToBeTokenGetCall({ + fetchCall: fetchMock.mock.calls[0], + useCredentialSyncVariables, + userId, + appSlug: "demo-app", + }); + expect(fetchNewTokenObject).not.toHaveBeenCalled(); + }); + }); + }); + + describe("API: `request`", () => { + test("If `isTokenObjectUnusable` marks the response invalid, then `invalidateTokenObject` function is called", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi.fn(); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async (response) => { + const jsonRes = await response.json(); + expect(jsonRes).toEqual(fakedFetchJsonResult); + return { + reason: "some reason", + }; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the actual request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT, + json: fakedFetchJsonResult, + }); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).toHaveBeenCalled(); + }); + + test("If neither of `isTokenObjectUnusable` and `isAccessTokenInvalid` mark the response invalid, but the response is still not OK then `markTokenExpired` is still called.", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi.fn(); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = internalServerErrorResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the actual request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.INCONCLUSIVE, + json: fakedFetchJsonResult, + }); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).toHaveBeenCalled(); + }); + + test("If neither of `isTokenObjectUnusable` and `isAccessTokenInvalid` mark the response invalid, and the response is also OK then `markTokenExpired` is not called.", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi.fn(); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the actual request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.VALID, + json: fakedFetchJsonResult, + }); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + + test("If `autoCheckTokenExpiryOnRequest` is true and token is expired, then token sync endpoint is hit", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi.fn(); + const currentTokenObject = getExpiredTokenObject(); + const newTokenObjectInResponse = getDummyTokenObject(); + const fakedTokenGetResponse = generateJsonResponse({ json: newTokenObjectInResponse }); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + autoCheckTokenExpiryOnRequest: true, + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject, + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the token sync request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedTokenGetResponse)); + + // For fetch triggered by the request call fetch + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.VALID, + json: fakedFetchJsonResult, + }); + + expect(updateTokenObject).toHaveBeenCalledWith(expect.objectContaining(newTokenObjectInResponse)); + // In credential sync mode, the expiry date is set to next year as it is not explicitly set in newTokenObject + expectExpiryToBeNextYear(updateTokenObject.mock.calls[0][0].expiry_date); + + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + + test("If `autoCheckTokenExpiryOnRequest` is not set(default true is used) and token is expired, then token sync endpoint is hit", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi.fn(); + const fakedTokenGetJson = getDummyTokenObject(); + const fakedTokenGetResponse = generateJsonResponse({ json: fakedTokenGetJson }); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getExpiredTokenObject(), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the token sync request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedTokenGetResponse)); + + // For fetch triggered by the request call fetch + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.request({ + url: "https://example.com", + options: { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(response).toEqual({ + tokenStatus: TokenStatus.VALID, + json: fakedFetchJsonResult, + }); + expect(updateTokenObject).toHaveBeenCalled(); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + }); + + describe("API: `requestRaw`", () => { + test("Though `isTokenObjectUnusable` and `isAccessTokenInvalid` aren't applicable here, but if the response is not OK then `markTokenExpired` is still called.", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + + const fetchNewTokenObject = vi.fn(); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = internalServerErrorResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + autoCheckTokenExpiryOnRequest: false, + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the actual request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.requestRaw({ + url: "https://example.com", + options: { + method: "POST", + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(await response.json()).toEqual(fakedFetchJsonResult); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).toHaveBeenCalled(); + }); + + test("Though `isTokenObjectUnusable` and `isAccessTokenInvalid` aren't applicable here, and the response is also OK then `markTokenExpired` is not called.", async () => { + const userId = 1; + const invalidateTokenObject = vi.fn(); + const expireAccessToken = vi.fn(); + const updateTokenObject = vi.fn(); + const fetchNewTokenObject = vi.fn(); + const fakedFetchJsonResult = { key: "value" }; + const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult }); + + const auth = new OAuthManager({ + autoCheckTokenExpiryOnRequest: false, + credentialSyncVariables: useCredentialSyncVariables, + resourceOwner: { + type: "user", + id: userId, + }, + appSlug: "demo-app", + currentTokenObject: getDummyTokenObject({ + // To make sure that existing token is used and thus refresh token doesn't happen + expiry_date: Date.now() + 10 * 1000, + }), + fetchNewTokenObject, + isTokenObjectUnusable: async () => { + return null; + }, + isAccessTokenUnusable: async () => { + return null; + }, + invalidateTokenObject: invalidateTokenObject, + updateTokenObject: updateTokenObject, + expireAccessToken: expireAccessToken, + }); + + // For fetch triggered by the actual request + fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse)); + + const response = await auth.requestRaw({ + url: "https://example.com", + options: { + method: "POST", + body: JSON.stringify({ + key: "value", + }), + }, + }); + + expect(await response.json()).toEqual(fakedFetchJsonResult); + expect(invalidateTokenObject).not.toHaveBeenCalled(); + expect(expireAccessToken).not.toHaveBeenCalled(); + }); + }); +}); + +function expectExpiryToBeNextYear(expiry_date: number) { + expect(new Date(expiry_date).getFullYear() - new Date().getFullYear()).toBe(1); +} + +function expectToBeTokenGetCall({ + fetchCall, + useCredentialSyncVariables, + userId, + appSlug, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetchCall: any[]; + useCredentialSyncVariables: { + APP_CREDENTIAL_SHARING_ENABLED: boolean; + CREDENTIAL_SYNC_SECRET: string; + CREDENTIAL_SYNC_SECRET_HEADER_NAME: string; + CREDENTIAL_SYNC_ENDPOINT: string; + }; + userId: number; + appSlug: string; +}) { + expect(fetchCall[0]).toBe("https://example.com/getToken"); + expect(fetchCall[1]).toEqual( + expect.objectContaining({ + method: "POST", + headers: { + [useCredentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]: + useCredentialSyncVariables.CREDENTIAL_SYNC_SECRET, + }, + }) + ); + + const fetchBody = fetchCall[1]?.body as unknown as URLSearchParams; + expect(fetchBody.get("calcomUserId")).toBe(userId.toString()); + expect(fetchBody.get("appSlug")).toBe(appSlug); +} diff --git a/packages/app-store/_utils/oauth/OAuthManager.ts b/packages/app-store/_utils/oauth/OAuthManager.ts new file mode 100644 index 0000000000000..ea653dac4afcd --- /dev/null +++ b/packages/app-store/_utils/oauth/OAuthManager.ts @@ -0,0 +1,559 @@ +/** + * Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed. + * It is aware of the credential sync endpoint and can sync the token from the third party source. + * It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods + * + * For a recommended usage example, see Zoom VideoApiAdapter.ts + */ +import type { z } from "zod"; + +import { CREDENTIAL_SYNC_ENDPOINT } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import type { AxiosLikeResponseToFetchResponse } from "./AxiosLikeResponseToFetchResponse"; +import type { OAuth2TokenResponseInDbWhenExistsSchema, OAuth2UniversalSchema } from "./universalSchema"; +import { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema"; + +const log = logger.getSubLogger({ prefix: ["app-store/_utils/oauth/OAuthManager"] }); +export const enum TokenStatus { + UNUSABLE_TOKEN_OBJECT, + UNUSABLE_ACCESS_TOKEN, + INCONCLUSIVE, + VALID, +} + +type ResourceOwner = + | { + id: number | null; + type: "team"; + } + | { + id: number | null; + type: "user"; + }; + +type FetchNewTokenObject = ({ refreshToken }: { refreshToken: string | null }) => Promise; +type UpdateTokenObject = ( + token: z.infer +) => Promise; +type isTokenObjectUnusable = (response: Response) => Promise<{ reason: string } | null>; +type isAccessTokenUnusable = (response: Response) => Promise<{ reason: string } | null>; +type IsTokenExpired = (token: z.infer) => Promise | boolean; +type InvalidateTokenObject = () => Promise; +type ExpireAccessToken = () => Promise; +type CredentialSyncVariables = { + /** + * The secret required to access the credential sync endpoint + */ + CREDENTIAL_SYNC_SECRET: string | undefined; + /** + * The header name that the secret should be passed in + */ + CREDENTIAL_SYNC_SECRET_HEADER_NAME: string; + /** + * The endpoint where the credential sync should happen + */ + CREDENTIAL_SYNC_ENDPOINT: string | undefined; + + APP_CREDENTIAL_SHARING_ENABLED: boolean; +}; +/** + * Manages OAuth2.0 tokens for an app and resourceOwner + * If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled) + * If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired) + */ +export class OAuthManager { + private currentTokenObject: z.infer; + private resourceOwner: ResourceOwner; + private appSlug: string; + private fetchNewTokenObject: FetchNewTokenObject; + private updateTokenObject: UpdateTokenObject; + private isTokenObjectUnusable: isTokenObjectUnusable; + private isAccessTokenUnusable: isAccessTokenUnusable; + private isTokenExpired: IsTokenExpired; + private invalidateTokenObject: InvalidateTokenObject; + private expireAccessToken: ExpireAccessToken; + private credentialSyncVariables: CredentialSyncVariables; + private useCredentialSync: boolean; + private autoCheckTokenExpiryOnRequest: boolean; + + constructor({ + resourceOwner, + appSlug, + currentTokenObject, + fetchNewTokenObject, + updateTokenObject, + isTokenObjectUnusable, + isAccessTokenUnusable, + invalidateTokenObject, + expireAccessToken, + credentialSyncVariables, + autoCheckTokenExpiryOnRequest = true, + isTokenExpired = (token: z.infer) => { + log.debug( + "isTokenExpired called", + safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() }) + ); + + return getExpiryDate() <= Date.now(); + + function isRelativeToEpoch(relativeTimeInSeconds: number) { + return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time + } + + function getExpiryDate() { + if (token.expiry_date) { + return token.expiry_date; + } + // It is usually in "seconds since now" but due to some integrations logic converting it to "seconds since epoch"(e.g. Office365Calendar has done that) we need to confirm what is the case here. + // But we for now know that it is in seconds for sure + // If it is not relative to epoch then it would be wrong to use it as it would make the token as non-expired when it could be expired + if (token.expires_in && isRelativeToEpoch(token.expires_in)) { + return token.expires_in * 1000; + } + // 0 means it would be expired as Date.now() is greater than that + return 0; + } + }, + }: { + /** + * The resource owner for which the token is being managed + */ + resourceOwner: ResourceOwner; + /** + * Does response for any request contain information that refresh_token became invalid and thus the entire token object become unusable + * Note: Right now, the implementations of this function makes it so that the response is considered invalid(sometimes) even if just access_token is revoked or invalid. In that case, regenerating access token should work. So, we shouldn't mark the token as invalid in that case. + * We should instead mark the token as expired. We could do that by introducing isAccessTokenInvalid function + * + * @param response + * @returns + */ + isTokenObjectUnusable: isTokenObjectUnusable; + /** + * + */ + isAccessTokenUnusable: isAccessTokenUnusable; + /** + * The current token object. + */ + currentTokenObject: z.infer; + /** + * The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting + */ + appSlug: string; + /** + * + * It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened. + * If credential syncing is still enabled `fetchNewTokenObject` wouldn't be called + */ + fetchNewTokenObject: FetchNewTokenObject; + + /** + * update token object + */ + updateTokenObject: UpdateTokenObject; + /** + * Handler to invalidate the token object. It is called when the token object is invalid and credential syncing is disabled + */ + invalidateTokenObject: InvalidateTokenObject; + /* + * Handler to expire the access token. It is called when credential syncing is enabled and when the token object expires + */ + expireAccessToken: ExpireAccessToken; + /** + * The variables required for credential syncing + */ + credentialSyncVariables: CredentialSyncVariables; + /** + * If the token should be checked for expiry before sending a request + */ + autoCheckTokenExpiryOnRequest?: boolean; + /** + * If there is a different way to check if the token is expired(and not the standard way of checking expiry_date) + */ + isTokenExpired?: IsTokenExpired; + }) { + ensureValidResourceOwner(resourceOwner); + this.resourceOwner = resourceOwner; + this.currentTokenObject = currentTokenObject; + this.appSlug = appSlug; + this.fetchNewTokenObject = fetchNewTokenObject; + this.isTokenObjectUnusable = isTokenObjectUnusable; + this.isAccessTokenUnusable = isAccessTokenUnusable; + this.isTokenExpired = isTokenExpired; + this.invalidateTokenObject = invalidateTokenObject; + this.expireAccessToken = expireAccessToken; + this.credentialSyncVariables = credentialSyncVariables; + this.useCredentialSync = !!( + credentialSyncVariables.APP_CREDENTIAL_SHARING_ENABLED && + credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT && + credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME && + credentialSyncVariables.CREDENTIAL_SYNC_SECRET + ); + this.autoCheckTokenExpiryOnRequest = autoCheckTokenExpiryOnRequest; + this.updateTokenObject = updateTokenObject; + } + + private isResponseNotOkay(response: Response) { + return !response.ok || response.status < 200 || response.status >= 300; + } + + public async getTokenObjectOrFetch() { + const myLog = log.getSubLogger({ + prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`], + }); + const isExpired = await this.isTokenExpired(this.currentTokenObject); + myLog.debug( + "getTokenObjectOrFetch called", + safeStringify({ + isExpired, + resourceOwner: this.resourceOwner, + }) + ); + + if (!isExpired) { + myLog.debug("Token is not expired. Returning the current token object"); + return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false }; + } else { + const token = { + // Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar + // It also allows any other properties set to be retained. + // Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent + ...this.currentTokenObject, + ...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()), + }; + myLog.debug("Token is expired. So, returning new token object"); + this.currentTokenObject = token; + await this.updateTokenObject(token); + return { token, isUpdated: true }; + } + } + + public async request(arg: { url: string; options: RequestInit }): Promise<{ + tokenStatus: TokenStatus; + json: unknown; + }>; + public async request( + customFetch: () => Promise< + AxiosLikeResponseToFetchResponse<{ + status: number; + statusText: string; + data: T; + }> + > + ): Promise<{ + tokenStatus: TokenStatus; + json: T; + }>; + /** + * Send request automatically adding the Authorization header with the access token. More importantly, handles token invalidation + */ + public async request( + customFetchOrUrlAndOptions: + | { url: string; options: RequestInit } + | (() => Promise< + AxiosLikeResponseToFetchResponse<{ + status: number; + statusText: string; + data: T; + }> + >) + ) { + let response; + const myLog = log.getSubLogger({ prefix: ["request"] }); + + if (this.autoCheckTokenExpiryOnRequest) { + await this.getTokenObjectOrFetch(); + } + + if (typeof customFetchOrUrlAndOptions === "function") { + myLog.debug("Sending request using customFetch"); + const customFetch = customFetchOrUrlAndOptions; + try { + response = await customFetch(); + } catch (e) { + // Get response from error so that code further down can categorize it into tokenUnusable or access token unusable + // Those methods accept response only + response = handleFetchError(e); + } + } else { + const { url, options } = customFetchOrUrlAndOptions; + const headers = { + Authorization: `Bearer ${this.currentTokenObject.access_token}`, + "Content-Type": "application/json", + ...options?.headers, + }; + myLog.debug("Sending request using fetch", safeStringify({ customFetchOrUrlAndOptions, headers })); + // We don't catch fetch error here because such an error would be temporary and we shouldn't take any action on it. + response = await fetch(url, { + method: "GET", + ...options, + headers: headers, + }); + } + + myLog.debug( + "Response from request", + safeStringify({ + text: await response.clone().text(), + status: response.status, + statusText: response.statusText, + }) + ); + + const { tokenStatus, json } = await this.getAndValidateOAuth2Response({ + response, + }); + + if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) { + // In case of Credential Sync, we expire the token so that through the sync we can refresh the token + // TODO: We should consider sending a special 'reason' query param to toke sync endpoint to convey the reason for getting token + await this.invalidate(); + } else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) { + await this.expireAccessToken(); + } else if (tokenStatus === TokenStatus.INCONCLUSIVE) { + await this.onInconclusiveResponse(); + } + + // We are done categorizing the token status. Now, we can throw back + if ("myFetchError" in (json || {})) { + throw new Error(json.myFetchError); + } + + return { tokenStatus: tokenStatus, json }; + } + + /** + * It doesn't automatically detect the response for tokenObject and accessToken becoming invalid + * Could be used when you expect a possible non JSON response as well. + */ + public async requestRaw({ url, options }: { url: string; options: RequestInit }) { + const myLog = log.getSubLogger({ prefix: ["requestRaw"] }); + myLog.debug("Sending request using fetch", safeStringify({ url, options })); + if (this.autoCheckTokenExpiryOnRequest) { + await this.getTokenObjectOrFetch(); + } + const headers = { + Authorization: `Bearer ${this.currentTokenObject.access_token}`, + "Content-Type": "application/json", + ...options?.headers, + }; + + const response = await fetch(url, { + method: "GET", + ...options, + headers: headers, + }); + myLog.debug( + "Response from request", + safeStringify({ + text: await response.clone().text(), + status: response.status, + statusText: response.statusText, + }) + ); + if (this.isResponseNotOkay(response)) { + await this.onInconclusiveResponse(); + } + return response; + } + + private async onInconclusiveResponse() { + const myLog = log.getSubLogger({ prefix: ["onInconclusiveResponse"] }); + myLog.debug("Expiring the access token"); + // We can't really take any action on inconclusive response + // But in case of credential sync we should expire the token so that through the sync we can possibly fix the issue by refreshing the token + // It is important because in that cases tokens have an infinite expiry and it is possible that the token is revoked and isAccessUnusable and isTokenObjectUnusable couldn't detect the issue + if (this.useCredentialSync) { + await this.expireAccessToken(); + } + } + + private async invalidate() { + const myLog = log.getSubLogger({ prefix: ["invalidate"] }); + if (this.useCredentialSync) { + myLog.debug("Expiring the access token"); + // We are not calling it through refreshOAuthToken flow because the token is refreshed already there + // There is no point expiring the token as we will probably get the same result in that case. + await this.expireAccessToken(); + } else { + myLog.debug("Invalidating the token object"); + // In case credential sync is enabled there is no point of marking the token as invalid as user doesn't take action on that. + // The third party needs to sync the correct credential back which we get done by marking the token as expired. + await this.invalidateTokenObject(); + } + } + + private normalizeNewlyReceivedToken( + token: z.infer + ) { + if (!token.expiry_date && !token.expires_in) { + // Use a practically infinite expiry(a year) for when Credential Sync is enabled. Token is expected to be refreshed by the API request from the credential source. + // If credential sync is not enabled, we should consider the token as expired otherwise the token could be considered valid forever + token.expiry_date = this.useCredentialSync ? Date.now() + 365 * 24 * 3600 * 1000 : 0; + } else if (token.expires_in !== undefined && token.expiry_date === undefined) { + token.expiry_date = Math.round(Date.now() + token.expires_in * 1000); + + // As expires_in could be relative to current time, we can't keep it in the token object as it could endup giving wrong absolute expiry_time if outdated value is used + // That could happen if we merge token objects which we do + delete token.expires_in; + } + return token; + } + + // TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working + private async refreshOAuthToken() { + const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] }); + let response; + const refreshToken = this.currentTokenObject.refresh_token ?? null; + if (this.resourceOwner.id && this.useCredentialSync) { + if ( + !this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET || + !this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME || + !this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT + ) { + throw new Error("Credential syncing is enabled but the required env variables are not set"); + } + myLog.debug( + "Refreshing OAuth token from credential sync endpoint", + safeStringify({ + appSlug: this.appSlug, + resourceOwner: this.resourceOwner, + endpoint: CREDENTIAL_SYNC_ENDPOINT, + }) + ); + + try { + response = await fetch(`${this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT}`, { + method: "POST", + headers: { + [this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]: + this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET, + }, + body: new URLSearchParams({ + calcomUserId: this.resourceOwner.id.toString(), + appSlug: this.appSlug, + }), + }); + } catch (e) { + myLog.error("Could not refresh the token.", safeStringify(e)); + throw new Error( + `Could not refresh the token due to connection issue with the endpoint: ${CREDENTIAL_SYNC_ENDPOINT}` + ); + } + } else { + myLog.debug( + "Refreshing OAuth token", + safeStringify({ + appSlug: this.appSlug, + resourceOwner: this.resourceOwner, + }) + ); + try { + response = await this.fetchNewTokenObject({ refreshToken }); + } catch (e) { + response = handleFetchError(e); + } + if (!response) { + throw new Error("`fetchNewTokenObject` could not refresh the token"); + } + } + + const clonedResponse = response.clone(); + myLog.debug( + "Response from refreshOAuthToken", + safeStringify({ + text: await clonedResponse.text(), + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + }) + ); + + const { json, tokenStatus } = await this.getAndValidateOAuth2Response({ + response, + }); + if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) { + await this.invalidateTokenObject(); + } else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) { + await this.expireAccessToken(); + } + const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json); + if (!parsedToken.success) { + myLog.error("Token parsing error:", safeStringify(parsedToken.error.issues)); + throw new Error("Invalid token response"); + } + return parsedToken.data; + } + + private async getAndValidateOAuth2Response({ response }: { response: Response }) { + const myLog = log.getSubLogger({ prefix: ["getAndValidateOAuth2Response"] }); + const clonedResponse = response.clone(); + + // handle empty response (causes crash otherwise on doing json() as "" is invalid JSON) which is valid in some cases like PATCH calls(with 204 response) + if ((await clonedResponse.text()).trim() === "") { + return { tokenStatus: TokenStatus.VALID, json: null, invalidReason: null } as const; + } + + const tokenObjectUsabilityRes = await this.isTokenObjectUnusable(response.clone()); + const accessTokenUsabilityRes = await this.isAccessTokenUnusable(response.clone()); + const isNotOkay = this.isResponseNotOkay(response); + + const json = await response.json(); + + if (tokenObjectUsabilityRes?.reason) { + myLog.error("Token Object has become unusable"); + return { + tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT, + invalidReason: tokenObjectUsabilityRes.reason, + json, + } as const; + } + + if (accessTokenUsabilityRes?.reason) { + myLog.error("Access Token has become unusable"); + return { + tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN, + invalidReason: accessTokenUsabilityRes?.reason, + json, + }; + } + + // Any handlable not ok response should be handled through isTokenObjectUnusable or isAccessTokenUnusable but if still not handled, we should throw an error + // So, that the caller can handle it. It could be a network error or some other temporary error from the third party App itself. + if (isNotOkay) { + return { + tokenStatus: TokenStatus.INCONCLUSIVE, + invalidReason: response.statusText, + json, + }; + } + + return { tokenStatus: TokenStatus.VALID, json, invalidReason: null } as const; + } +} + +function ensureValidResourceOwner( + resourceOwner: { id: number | null; type: "team" } | { id: number | null; type: "user" } +) { + if (resourceOwner.type === "team") { + throw new Error("Teams are not supported"); + } else { + if (!resourceOwner.id) { + throw new Error("resourceOwner should have id set"); + } + } +} + +/** + * It converts error into a Response + */ +function handleFetchError(e: unknown) { + const myLog = log.getSubLogger({ prefix: ["handleFetchError"] }); + myLog.debug("Error", safeStringify(e)); + if (e instanceof Error) { + return new Response(JSON.stringify({ myFetchError: e.message }), { status: 500 }); + } + return new Response(JSON.stringify({ myFetchError: "UNKNOWN_ERROR" }), { status: 500 }); +} diff --git a/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts b/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts new file mode 100644 index 0000000000000..a0fde92305b95 --- /dev/null +++ b/packages/app-store/_utils/oauth/getTokenObjectFromCredential.ts @@ -0,0 +1,24 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +import { OAuth2TokenResponseInDbSchema } from "./universalSchema"; + +export function getTokenObjectFromCredential(credential: CredentialPayload) { + const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key); + + if (!parsedTokenResponse.success) { + logger.debug( + "GoogleCalendarService-getTokenObjectFromCredential", + safeStringify(parsedTokenResponse.error.issues) + ); + throw new Error("Could not parse credential.key"); + } + + const tokenResponse = parsedTokenResponse.data; + if (!tokenResponse) { + throw new Error("credential.key is not set"); + } + + return tokenResponse; +} diff --git a/packages/app-store/_utils/oauth/markTokenAsExpired.ts b/packages/app-store/_utils/oauth/markTokenAsExpired.ts new file mode 100644 index 0000000000000..aa6fd3f34bbef --- /dev/null +++ b/packages/app-store/_utils/oauth/markTokenAsExpired.ts @@ -0,0 +1,21 @@ +import prisma from "@calcom/prisma"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential"; + +export const markTokenAsExpired = async (credential: CredentialPayload) => { + const tokenResponse = getTokenObjectFromCredential(credential); + if (credential && credential.key) { + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: { + ...tokenResponse, + expiry_date: Date.now() - 3600 * 1000, + }, + }, + }); + } +}; diff --git a/packages/app-store/_utils/oauth/oAuthManagerHelper.ts b/packages/app-store/_utils/oauth/oAuthManagerHelper.ts new file mode 100644 index 0000000000000..83f81827b387d --- /dev/null +++ b/packages/app-store/_utils/oauth/oAuthManagerHelper.ts @@ -0,0 +1,26 @@ +import { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, +} from "@calcom/lib/constants"; + +import { invalidateCredential } from "../invalidateCredential"; +import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential"; +import { markTokenAsExpired } from "./markTokenAsExpired"; +import { updateTokenObject } from "./updateTokenObject"; + +export const credentialSyncVariables = { + APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME, +}; + +export const oAuthManagerHelper = { + updateTokenObject, + markTokenAsExpired, + invalidateCredential: invalidateCredential, + getTokenObjectFromCredential, + credentialSyncVariables, +}; diff --git a/packages/app-store/_utils/oauth/universalSchema.ts b/packages/app-store/_utils/oauth/universalSchema.ts new file mode 100644 index 0000000000000..c377c97b4ef83 --- /dev/null +++ b/packages/app-store/_utils/oauth/universalSchema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +/** + * We should be able to work with just the access token. + * access_token allows us to access the resources + */ +export const OAuth2BareMinimumUniversalSchema = z + .object({ + access_token: z.string(), + /** + * It is usually 'Bearer' + */ + token_type: z.string().optional(), + }) + // We want any other property to be passed through and stay there. + .passthrough(); + +export const OAuth2UniversalSchema = OAuth2BareMinimumUniversalSchema.extend({ + /** + * If we aren't sent refresh_token, it means that the party syncing us the credentials don't want us to ever refresh the token. + * They would be responsible to send us the access_token before it expires. + */ + refresh_token: z.string().optional(), + + /** + * It is only needed when connecting to the API for the first time. So, it is okay if the party syncing us the credentials don't send it as then it is responsible to provide us the access_token already + */ + scope: z.string().optional(), + + /** + * Absolute expiration time in milliseconds + */ + expiry_date: z.number().optional(), +}); + +export const OAuth2UniversalSchemaWithCalcomBackwardCompatibility = OAuth2UniversalSchema.extend({ + /** + * Time in seconds until the token expires + * Either this or expiry_date should be provided + */ + expires_in: z.number().optional(), +}); + +export const OAuth2TokenResponseInDbWhenExistsSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility; +export const OAuth2TokenResponseInDbSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.nullable(); diff --git a/packages/app-store/_utils/oauth/updateTokenObject.ts b/packages/app-store/_utils/oauth/updateTokenObject.ts new file mode 100644 index 0000000000000..922495e152cc0 --- /dev/null +++ b/packages/app-store/_utils/oauth/updateTokenObject.ts @@ -0,0 +1,22 @@ +import type z from "zod"; + +import prisma from "@calcom/prisma"; + +import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema"; + +export const updateTokenObject = async ({ + tokenObject, + credentialId, +}: { + tokenObject: z.infer; + credentialId: number; +}) => { + await prisma.credential.update({ + where: { + id: credentialId, + }, + data: { + key: tokenObject, + }, + }); +}; diff --git a/packages/app-store/_utils/testUtils.ts b/packages/app-store/_utils/testUtils.ts new file mode 100644 index 0000000000000..1bb2c1d1eaefb --- /dev/null +++ b/packages/app-store/_utils/testUtils.ts @@ -0,0 +1,43 @@ +export function generateJsonResponse({ + json, + status = 200, + statusText = "OK", +}: { + json: unknown; + status?: number; + statusText?: string; +}) { + return new Response(JSON.stringify(json), { + status, + statusText, + }); +} + +export function internalServerErrorResponse({ + json, +}: { + json: unknown; + status?: number; + statusText?: string; +}) { + return generateJsonResponse({ json, status: 500, statusText: "Internal Server Error" }); +} + +export function generateTextResponse({ + text, + status = 200, + statusText = "OK", +}: { + text: string; + status?: number; + statusText?: string; +}) { + return new Response(text, { + status: status, + statusText: statusText, + }); +} + +export function successResponse({ json }: { json: unknown }) { + return generateJsonResponse({ json }); +} diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 8b739ae6e224e..d3a7e638f5b22 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -9,6 +9,12 @@ import dayjs from "@calcom/dayjs"; import { getFeatureFlag } from "@calcom/features/flags/server/utils"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import type CalendarService from "@calcom/lib/CalendarService"; +import { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, +} from "@calcom/lib/constants"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -22,11 +28,16 @@ import type { } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; -import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; -import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; -import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; +import { invalidateCredential } from "../../_utils/invalidateCredential"; +import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; +import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired"; +import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema"; +import { metadata } from "../_metadata"; import { getGoogleAppKeys } from "./getGoogleAppKeys"; -import { googleCredentialSchema } from "./googleCredentialSchema"; + +const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); interface GoogleCalError extends Error { code?: number; @@ -64,80 +75,109 @@ function handleMinMax(min: string, max: string) { export default class GoogleCalendarService implements Calendar { private integrationName = ""; - private auth: { getToken: () => Promise }; + private auth: ReturnType; private log: typeof logger; private credential: CredentialPayload; - + private myGoogleAuth!: MyGoogleAuth; + private oAuthManagerInstance!: OAuthManager; constructor(credential: CredentialPayload) { this.integrationName = "google_calendar"; this.credential = credential; - this.auth = this.googleAuth(credential); - this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + this.auth = this.initGoogleAuth(credential); + this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); this.credential = credential; } - private googleAuth = (credential: CredentialPayload) => { - const googleCredentials = googleCredentialSchema.parse(credential.key); - - async function getGoogleAuth() { - const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); - const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); - myGoogleAuth.setCredentials(googleCredentials); - return myGoogleAuth; - } + private async getMyGoogleAuthSingleton() { + const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key); + const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); + this.myGoogleAuth = this.myGoogleAuth || new MyGoogleAuth(client_id, client_secret, redirect_uris[0]); + this.myGoogleAuth.setCredentials(googleCredentials); + return this.myGoogleAuth; + } - const refreshAccessToken = async (myGoogleAuth: Awaited>) => { - try { - const res = await refreshOAuthTokens( - async () => { - const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token); - return fetchTokens.res; - }, - "google-calendar", - credential.userId - ); - const token = res?.data; - googleCredentials.access_token = token.access_token; - googleCredentials.expiry_date = token.expiry_date; - const parsedKey: ParseRefreshTokenResponse = parseRefreshTokenResponse( - googleCredentials, - googleCredentialSchema - ); - await prisma.credential.update({ - where: { id: credential.id }, - data: { key: { ...parsedKey } as Prisma.InputJsonValue }, + private initGoogleAuth = (credential: CredentialPayload) => { + const currentTokenObject = getTokenObjectFromCredential(credential); + const auth = new OAuthManager({ + // Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well. + autoCheckTokenExpiryOnRequest: false, + credentialSyncVariables: { + APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME, + }, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: metadata.slug, + currentTokenObject, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + const myGoogleAuth = await this.getMyGoogleAuthSingleton(); + const fetchTokens = await myGoogleAuth.refreshToken(refreshToken); + // Create Response from fetchToken.res + const response = new Response(JSON.stringify(fetchTokens.res?.data ?? null), { + status: fetchTokens.res?.status, + statusText: fetchTokens.res?.statusText, }); - myGoogleAuth.setCredentials(googleCredentials); - } catch (err) { - this.log.error("Error Refreshing Google Token", safeStringify(err)); - let message; - if (err instanceof Error) message = err.message; - else message = String(err); - // if not invalid_grant, default behaviour (which admittedly isn't great) - if (message !== "invalid_grant") return myGoogleAuth; - // when the error is invalid grant, it's unrecoverable and the credential marked invalid. - // TODO: Evaluate bubbling up and handling this in the CalendarManager. IMO this should be done - // but this is a bigger refactor. + return response; + }, + isTokenExpired: async () => { + const myGoogleAuth = await this.getMyGoogleAuthSingleton(); + return myGoogleAuth.isTokenExpiring(); + }, + isTokenObjectUnusable: async function (response) { + // TODO: Confirm that if this logic should go to isAccessTokenUnusable + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + + if (responseBody.error === "invalid_grant") { + return { + reason: "invalid_grant", + }; + } + } + return null; + }, + isAccessTokenUnusable: async () => { + // As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected + // So, a situation where access_token is invalid but refresh_token is valid should not happen + return null; + }, + invalidateTokenObject: () => invalidateCredential(this.credential.id), + expireAccessToken: async () => { + await markTokenAsExpired(this.credential); + }, + updateTokenObject: async (token) => { + this.myGoogleAuth.setCredentials(token); await prisma.credential.update({ - where: { id: credential.id }, + where: { + id: credential.id, + }, data: { - invalid: true, + key: token, }, }); - } - return myGoogleAuth; - }; + }, + }); + this.oAuthManagerInstance = auth; return { - getToken: async () => { - const myGoogleAuth = await getGoogleAuth(); - const isExpired = () => myGoogleAuth.isTokenExpiring(); - return !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(myGoogleAuth); + getMyGoogleAuthWithRefreshedToken: async () => { + // It would automatically update myGoogleAuth with correct token + const { token } = await auth.getTokenObjectOrFetch(); + if (!token) { + throw new Error("Invalid grant for Google Calendar app"); + } + + const myGoogleAuth = await this.getMyGoogleAuthSingleton(); + return myGoogleAuth; }, }; }; public authedCalendar = async () => { - const myGoogleAuth = await this.auth.getToken(); + const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken(); const calendar = google.calendar({ version: "v3", auth: myGoogleAuth, @@ -188,6 +228,7 @@ export default class GoogleCalendarService implements Calendar { }; async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise { + this.log.debug("Creating event"); const formattedCalEvent = formatCalEvent(calEventRaw); const payload: calendar_v3.Schema$Event = { @@ -480,11 +521,14 @@ export default class GoogleCalendarService implements Calendar { if (!calendarCacheEnabled) { this.log.warn("Calendar Cache is disabled - Skipping"); const { timeMin, timeMax, items } = args; - const apires = await calendar.freebusy.query({ - requestBody: { timeMin, timeMax, items }, - }); - - freeBusyResult = apires.data; + ({ json: freeBusyResult } = await this.oAuthManagerInstance.request( + async () => + new AxiosLikeResponseToFetchResponse( + await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }) + ) + )); } else { const { timeMin: _timeMin, timeMax: _timeMax, items } = args; const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax); @@ -502,9 +546,14 @@ export default class GoogleCalendarService implements Calendar { if (cached) { freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; } else { - const apires = await calendar.freebusy.query({ - requestBody: { timeMin, timeMax, items }, - }); + ({ json: freeBusyResult } = await this.oAuthManagerInstance.request( + async () => + new AxiosLikeResponseToFetchResponse( + await calendar.freebusy.query({ + requestBody: { timeMin, timeMax, items }, + }) + ) + )); // Skipping await to respond faster await prisma.calendarCache.upsert({ @@ -515,18 +564,16 @@ export default class GoogleCalendarService implements Calendar { }, }, update: { - value: JSON.parse(JSON.stringify(apires.data)), + value: JSON.parse(JSON.stringify(freeBusyResult)), expiresAt: new Date(Date.now() + CACHING_TIME), }, create: { - value: JSON.parse(JSON.stringify(apires.data)), + value: JSON.parse(JSON.stringify(freeBusyResult)), credentialId: this.credential.id, key, expiresAt: new Date(Date.now() + CACHING_TIME), }, }); - - freeBusyResult = apires.data; } } if (!freeBusyResult.calendars) return null; @@ -548,6 +595,7 @@ export default class GoogleCalendarService implements Calendar { dateTo: string, selectedCalendars: IntegrationCalendar[] ): Promise { + this.log.debug("Getting availability"); const calendar = await this.authedCalendar(); const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === this.integrationName) @@ -613,11 +661,18 @@ export default class GoogleCalendarService implements Calendar { } async listCalendars(): Promise { + this.log.debug("Listing calendars"); const calendar = await this.authedCalendar(); try { - const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); - if (!cals.data.items) return []; - return cals.data.items.map( + const { json: cals } = await this.oAuthManagerInstance.request( + async () => + new AxiosLikeResponseToFetchResponse( + await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }) + ) + ); + + if (!cals.items) return []; + return cals.items.map( (cal) => ({ externalId: cal.id ?? "No id", diff --git a/packages/app-store/office365calendar/api/add.ts b/packages/app-store/office365calendar/api/add.ts index e087eab78a76e..fd7ca5849f5a3 100644 --- a/packages/app-store/office365calendar/api/add.ts +++ b/packages/app-store/office365calendar/api/add.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { stringify } from "querystring"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; @@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) scope: scopes.join(" "), client_id, prompt: "select_account", - redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`, state, }; const query = stringify(params); diff --git a/packages/app-store/office365calendar/api/callback.ts b/packages/app-store/office365calendar/api/callback.ts index 68771440530af..de34f6be39767 100644 --- a/packages/app-store/office365calendar/api/callback.ts +++ b/packages/app-store/office365calendar/api/callback.ts @@ -2,7 +2,7 @@ import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-type import type { NextApiRequest, NextApiResponse } from "next"; import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { handleErrorsJson } from "@calcom/lib/errors"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; @@ -50,7 +50,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) grant_type: "authorization_code", code, scope: scopes.join(" "), - redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`, client_secret, }); diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index 2242a2210fbda..9f3d7a3aa0c86 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -1,12 +1,10 @@ import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta"; import type { DefaultBodyType } from "msw"; -import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import prisma from "@calcom/prisma"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; import type { Calendar, @@ -17,10 +15,10 @@ import type { } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; -import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; -import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; -import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; -import type { O365AuthCredentials } from "../types/Office365Calendar"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; +import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; +import metadata from "../_metadata"; import { getOfficeAppKeys } from "./getOfficeAppKeys"; interface IRequest { @@ -49,26 +47,58 @@ interface BodyValue { start: { dateTime: string }; } -const refreshTokenResponseSchema = z.object({ - access_token: z.string(), - expires_in: z - .number() - .transform((currentTimeOffsetInSeconds) => Math.round(+new Date() / 1000 + currentTimeOffsetInSeconds)), - refresh_token: z.string().optional(), -}); - export default class Office365CalendarService implements Calendar { private url = ""; private integrationName = ""; private log: typeof logger; - private accessToken: string | null = null; - auth: { getToken: () => Promise }; + private auth: OAuthManager; private apiGraphUrl = "https://graph.microsoft.com/v1.0"; private credential: CredentialPayload; constructor(credential: CredentialPayload) { this.integrationName = "office365_calendar"; - this.auth = this.o365Auth(credential); + const tokenResponse = getTokenObjectFromCredential(credential); + + this.auth = new OAuthManager({ + credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: metadata.slug, + currentTokenObject: tokenResponse, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + if (!refreshToken) { + return null; + } + const { client_id, client_secret } = await getOfficeAppKeys(); + return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + scope: "User.Read Calendars.Read Calendars.ReadWrite", + client_id, + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret, + }), + }); + }, + isTokenObjectUnusable: async function () { + // TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well. + // This is a placeholder for future implementation. + return null; + }, + isAccessTokenUnusable: async function () { + // TODO: Implement this + return null; + }, + invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id), + expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential), + updateTokenObject: (tokenObject) => + oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }), + }); + this.credential = credential; this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } @@ -232,58 +262,6 @@ export default class Office365CalendarService implements Calendar { }); } - private o365Auth = (credential: CredentialPayload) => { - const isExpired = (expiryDate: number) => { - if (!expiryDate) { - return true; - } else { - return expiryDate < Math.round(+new Date() / 1000); - } - }; - const o365AuthCredentials = credential.key as O365AuthCredentials; - - const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => { - const { client_id, client_secret } = await getOfficeAppKeys(); - const response = await refreshOAuthTokens( - async () => - await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - scope: "User.Read Calendars.Read Calendars.ReadWrite", - client_id, - refresh_token: o365AuthCredentials.refresh_token, - grant_type: "refresh_token", - client_secret, - }), - }), - "office365-calendar", - credential.userId - ); - const responseJson = await handleErrorsJson(response); - const tokenResponse: ParseRefreshTokenResponse = - parseRefreshTokenResponse(responseJson, refreshTokenResponseSchema); - o365AuthCredentials = { ...o365AuthCredentials, ...tokenResponse }; - await prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - key: o365AuthCredentials, - }, - }); - return o365AuthCredentials.access_token; - }; - - return { - getToken: () => - refreshTokenResponseSchema.safeParse(o365AuthCredentials).success && - !isExpired(o365AuthCredentials.expires_in) - ? Promise.resolve(o365AuthCredentials.access_token) - : refreshAccessToken(o365AuthCredentials), - }; - }; - private translateEvent = (event: CalendarEvent) => { return { subject: event.title, @@ -339,14 +317,12 @@ export default class Office365CalendarService implements Calendar { }; private fetcher = async (endpoint: string, init?: RequestInit | undefined) => { - this.accessToken = await this.auth.getToken(); - return fetch(`${this.apiGraphUrl}${endpoint}`, { - method: "get", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", + return this.auth.requestRaw({ + url: `${this.apiGraphUrl}${endpoint}`, + options: { + method: "get", + ...init, }, - ...init, }); }; diff --git a/packages/app-store/office365video/api/add.ts b/packages/app-store/office365video/api/add.ts index 4a2cf45b56daf..e0d5ed82b86ba 100644 --- a/packages/app-store/office365video/api/add.ts +++ b/packages/app-store/office365video/api/add.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { stringify } from "querystring"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; @@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) response_type: "code", scope: scopes.join(" "), client_id, - redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`, state, }; const query = stringify(params); diff --git a/packages/app-store/office365video/api/callback.ts b/packages/app-store/office365video/api/callback.ts index fd2db4ae66260..8d13922560799 100644 --- a/packages/app-store/office365video/api/callback.ts +++ b/packages/app-store/office365video/api/callback.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; @@ -47,7 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) grant_type: "authorization_code", code, scope: scopes.join(" "), - redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`, client_secret, }); diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts new file mode 100644 index 0000000000000..413968be3c4d3 --- /dev/null +++ b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts @@ -0,0 +1,282 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; + +import { expect, test, vi, describe } from "vitest"; + +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { internalServerErrorResponse, successResponse } from "../../_utils/testUtils"; +import config from "../config.json"; +import VideoApiAdapter from "./VideoApiAdapter"; + +const URLS = { + CREATE_MEETING: { + url: "https://graph.microsoft.com/v1.0/me/onlineMeetings", + method: "POST", + }, + UPDATE_MEETING: { + url: "https://graph.microsoft.com/v1.0/me/onlineMeetings", + method: "POST", + }, +}; + +vi.mock("../../_utils/getParsedAppKeysFromSlug", () => ({ + default: vi.fn().mockImplementation((slug) => { + if (slug !== config.slug) { + throw new Error( + `expected to be called with the correct slug. Expected ${config.slug} - Received ${slug}` + ); + } + return { + client_id: "FAKE_CLIENT_ID", + client_secret: "FAKE_CLIENT_SECRET", + }; + }), +})); + +const mockRequestRaw = vi.fn(); +vi.mock("../../_utils/oauth/OAuthManager", () => ({ + OAuthManager: vi.fn().mockImplementation(() => { + return { requestRaw: mockRequestRaw }; + }), +})); + +const testCredential = { + appId: config.slug, + id: 1, + invalid: false, + key: { + scope: "https://www.googleapis.com/auth/calendar.events", + token_type: "Bearer", + expiry_date: 1625097600000, + access_token: "", + refresh_token: "", + }, + type: config.type, + userId: 1, + user: { email: "example@cal.com" }, + teamId: 1, +}; + +describe("createMeeting", () => { + test("Successful `createMeeting` call", async () => { + prismaMock.calendarCache.findUnique; + + const videoApi = VideoApiAdapter(testCredential); + + mockRequestRaw.mockImplementation(({ url }) => { + if (url === URLS.CREATE_MEETING.url) { + return Promise.resolve( + successResponse({ + json: { + id: 1, + joinWebUrl: "https://join_web_url.example.com", + joinUrl: "https://join_url.example.com", + }, + }) + ); + } + throw new Error("Unexpected URL"); + }); + + const event = { + title: "Test Meeting", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }; + + const createdMeeting = await videoApi?.createMeeting(event); + expect(OAuthManager).toHaveBeenCalled(); + expect(mockRequestRaw).toHaveBeenCalledWith({ + url: URLS.CREATE_MEETING.url, + options: { + method: "POST", + body: JSON.stringify({ + startDateTime: event.startTime, + endDateTime: event.endTime, + subject: event.title, + }), + }, + }); + + expect(createdMeeting).toEqual({ + id: 1, + password: "", + type: "office365_video", + url: "https://join_web_url.example.com", + }); + }); + + test(" `createMeeting` when there is no joinWebUrl and only joinUrl", async () => { + prismaMock.calendarCache.findUnique; + + const videoApi = VideoApiAdapter(testCredential); + + mockRequestRaw.mockImplementation(({ url }) => { + if (url === URLS.CREATE_MEETING.url) { + return Promise.resolve( + successResponse({ + json: { + id: 1, + joinUrl: "https://join_url.example.com", + error: { + message: "ERROR", + }, + }, + }) + ); + } + throw new Error("Unexpected URL"); + }); + + const event = { + title: "Test Meeting", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }; + + await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError( + "Error creating MS Teams meeting" + ); + expect(OAuthManager).toHaveBeenCalled(); + expect(mockRequestRaw).toHaveBeenCalledWith({ + url: URLS.CREATE_MEETING.url, + options: { + method: "POST", + body: JSON.stringify({ + startDateTime: event.startTime, + endDateTime: event.endTime, + subject: event.title, + }), + }, + }); + }); + + test("Failing `createMeeting` call", async () => { + const videoApi = VideoApiAdapter(testCredential); + + mockRequestRaw.mockImplementation(({ url }) => { + if (url === URLS.CREATE_MEETING.url) { + return Promise.resolve( + internalServerErrorResponse({ + json: { + id: 1, + joinWebUrl: "https://example.com", + joinUrl: "https://example.com", + }, + }) + ); + } + throw new Error("Unexpected URL"); + }); + + const event = { + title: "Test Meeting", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }; + + await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError("Internal Server Error"); + expect(OAuthManager).toHaveBeenCalled(); + expect(mockRequestRaw).toHaveBeenCalledWith({ + url: URLS.CREATE_MEETING.url, + options: { + method: "POST", + body: JSON.stringify({ + startDateTime: event.startTime, + endDateTime: event.endTime, + subject: event.title, + }), + }, + }); + }); +}); + +describe("updateMeeting", () => { + test("Successful `updateMeeting` call", async () => { + const videoApi = VideoApiAdapter(testCredential); + + mockRequestRaw.mockImplementation(({ url }) => { + if (url === URLS.CREATE_MEETING.url) { + return Promise.resolve( + successResponse({ + json: { + id: 1, + joinWebUrl: "https://join_web_url.example.com", + joinUrl: "https://join_url.example.com", + }, + }) + ); + } + throw new Error("Unexpected URL"); + }); + + const event = { + title: "Test Meeting", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }; + + const updatedMeeting = await videoApi?.updateMeeting(null, event); + expect(OAuthManager).toHaveBeenCalled(); + expect(mockRequestRaw).toHaveBeenCalledWith({ + url: URLS.CREATE_MEETING.url, + options: { + method: "POST", + body: JSON.stringify({ + startDateTime: event.startTime, + endDateTime: event.endTime, + subject: event.title, + }), + }, + }); + expect(updatedMeeting).toEqual({ + id: 1, + password: "", + type: config.type, + url: "https://join_web_url.example.com", + }); + }); + + test("Failing `updateMeeting` call", async () => { + const videoApi = VideoApiAdapter(testCredential); + + mockRequestRaw.mockImplementation(({ url }) => { + if (url === URLS.CREATE_MEETING.url) { + return Promise.resolve( + internalServerErrorResponse({ + json: { + id: 1, + joinWebUrl: "https://join_web_url.example.com", + joinUrl: "https://join_url.example.com", + }, + }) + ); + } + throw new Error("Unexpected URL"); + }); + + const event = { + title: "Test Meeting", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }; + + await expect(() => videoApi?.updateMeeting(null, event)).rejects.toThrowError("Internal Server Error"); + expect(OAuthManager).toHaveBeenCalled(); + expect(mockRequestRaw).toHaveBeenCalledWith({ + url: URLS.CREATE_MEETING.url, + options: { + method: "POST", + body: JSON.stringify({ + startDateTime: event.startTime, + endDateTime: event.endTime, + subject: event.title, + }), + }, + }); + }); +}); diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.ts b/packages/app-store/office365video/lib/VideoApiAdapter.ts index 4a9a032b3a150..8d15c51f8dfbf 100644 --- a/packages/app-store/office365video/lib/VideoApiAdapter.ts +++ b/packages/app-store/office365video/lib/VideoApiAdapter.ts @@ -1,18 +1,16 @@ -import type { Prisma } from "@prisma/client"; +import { z } from "zod"; -import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors"; +import { handleErrorsRaw } from "@calcom/lib/errors"; import { HttpError } from "@calcom/lib/http-error"; -import prisma from "@calcom/prisma"; 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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; -import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; - -let client_id = ""; -let client_secret = ""; +import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper"; +import config from "../config.json"; /** @link https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings?view=graph-rest-1.0&tabs=http#response */ export interface TeamsEventResult { @@ -24,90 +22,56 @@ export interface TeamsEventResult { subject: string; } -interface O365AuthCredentials { - email: string; - scope: string; - token_type: string; - expiry_date: number; - access_token: string; - refresh_token: string; - ext_expires_in: number; -} - -interface ITokenResponse { - expiry_date: number; - expires_in?: number; - token_type: string; - scope: string; - access_token: string; - refresh_token: string; - error?: string; - error_description?: string; -} - -// Checks to see if our O365 user token is valid or if we need to refresh -const o365Auth = async (credential: CredentialPayload) => { - const appKeys = await getAppKeysFromSlug("msteams"); - if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; - if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret; - if (!client_id) throw new HttpError({ statusCode: 400, message: "MS teams client_id missing." }); - if (!client_secret) throw new HttpError({ statusCode: 400, message: "MS teams client_secret missing." }); - - const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date()); - - const o365AuthCredentials = credential.key as unknown as O365AuthCredentials; +const o365VideoAppKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); - const refreshAccessToken = async (refreshToken: string) => { - const response = await refreshOAuthTokens( - async () => - await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id, - refresh_token: refreshToken, - grant_type: "refresh_token", - client_secret, - }), - }), - "msteams", - credential.userId - ); - - const responseBody = await handleErrorsJson(response); - - if (responseBody?.error) { - console.error(responseBody); - throw new HttpError({ statusCode: 500, message: `Error contacting MS Teams: ${responseBody.error}` }); - } - // set expiry date as offset from current time. - responseBody.expiry_date = Math.round(Date.now() + (responseBody?.expires_in || 0) * 1000); - delete responseBody.expires_in; - // Store new tokens in database. - await prisma.credential.update({ - where: { - id: credential.id, - }, - data: { - // @NOTE: prisma doesn't know key its a JSON so do as responseBody - key: responseBody as unknown as Prisma.InputJsonValue, - }, - }); - o365AuthCredentials.expiry_date = responseBody.expiry_date; - o365AuthCredentials.access_token = responseBody.access_token; - return o365AuthCredentials.access_token; - }; - - return { - getToken: () => - isExpired(o365AuthCredentials.expiry_date) - ? refreshAccessToken(o365AuthCredentials.refresh_token) - : Promise.resolve(o365AuthCredentials.access_token), - }; +const getO365VideoAppKeys = async () => { + return getParsedAppKeysFromSlug(config.slug, o365VideoAppKeysSchema); }; const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { - const auth = o365Auth(credential); + const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential); + + const auth = new OAuthManager({ + credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: config.slug, + currentTokenObject: tokenResponse, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + if (!refreshToken) { + return null; + } + const { client_id, client_secret } = await getO365VideoAppKeys(); + return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id, + refresh_token: refreshToken, + grant_type: "refresh_token", + client_secret, + }), + }); + }, + isTokenObjectUnusable: async function () { + // TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well. + // This is a placeholder for future implementation. + return null; + }, + isAccessTokenUnusable: async function () { + // TODO: Implement this + return null; + }, + invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id), + expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential), + updateTokenObject: (tokenObject) => + oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }), + }); const translateEvent = (event: CalendarEvent) => { return { @@ -123,16 +87,15 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => return Promise.resolve([]); }, updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent) => { - const accessToken = await (await auth).getToken(); - - const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw); + const resultString = await auth + .requestRaw({ + url: "https://graph.microsoft.com/v1.0/me/onlineMeetings", + options: { + method: "POST", + body: JSON.stringify(translateEvent(event)), + }, + }) + .then(handleErrorsRaw); const resultObject = JSON.parse(resultString); @@ -140,23 +103,22 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => type: "office365_video", id: resultObject.id, password: "", - url: resultObject.joinUrl, + url: resultObject.joinWebUrl || resultObject.joinUrl, }); }, deleteMeeting: () => { return Promise.resolve([]); }, createMeeting: async (event: CalendarEvent): Promise => { - const accessToken = await (await auth).getToken(); - - const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(translateEvent(event)), - }).then(handleErrorsRaw); + const resultString = await auth + .requestRaw({ + url: "https://graph.microsoft.com/v1.0/me/onlineMeetings", + options: { + method: "POST", + body: JSON.stringify(translateEvent(event)), + }, + }) + .then(handleErrorsRaw); const resultObject = JSON.parse(resultString); diff --git a/packages/app-store/zoomvideo/api/callback.ts b/packages/app-store/zoomvideo/api/callback.ts index 1d0e2599ebfcd..77f648f23a11c 100644 --- a/packages/app-store/zoomvideo/api/callback.ts +++ b/packages/app-store/zoomvideo/api/callback.ts @@ -28,7 +28,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const responseBody = await result.json(); errorMessage = responseBody.error; - } catch (e) {} + } catch (e) { + errorMessage = await result.clone().text(); + } res.status(400).json({ message: errorMessage }); return; diff --git a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts index 070d9d8ead2d1..c551cc68b1a38 100644 --- a/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/zoomvideo/lib/VideoApiAdapter.ts @@ -1,20 +1,30 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, +} from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; -import type { Credential } from "@calcom/prisma/client"; import { Frequency } from "@calcom/prisma/zod-utils"; 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 { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; -import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; -import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; -import metadata from "../_metadata"; +import { invalidateCredential } from "../../_utils/invalidateCredential"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; +import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired"; +import { metadata } from "../_metadata"; import { getZoomAppKeys } from "./getZoomAppKeys"; +const log = logger.getSubLogger({ prefix: ["app-store/zoomvideo/lib/VideoApiAdapter"] }); + /** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */ const zoomEventResultSchema = z.object({ id: z.number(), @@ -49,93 +59,6 @@ export const zoomMeetingsSchema = z.object({ ), }); -// Successful API response -// @TODO: add link to the docs -const zoomTokenSchema = z.object({ - scope: z.string().regex(new RegExp("meeting:write")), - expiry_date: z.number(), - expires_in: z.number().optional(), // deprecated, purely for backwards compatibility; superseeded by expiry_date. - token_type: z.literal("bearer"), - access_token: z.string(), - refresh_token: z.string(), -}); - -type ZoomToken = z.infer; - -const isTokenValid = (token: Partial) => - zoomTokenSchema.safeParse(token).success && (token.expires_in || token.expiry_date || 0) > Date.now(); - -/** @link https://marketplace.zoom.us/docs/guides/auth/oauth/#request */ -const zoomRefreshedTokenSchema = z.object({ - access_token: z.string(), - token_type: z.literal("bearer"), - refresh_token: z.string(), - expires_in: z.number(), - scope: z.string(), -}); - -const zoomAuth = (credential: CredentialPayload) => { - const refreshAccessToken = async (refreshToken: string) => { - const { client_id, client_secret } = await getZoomAppKeys(); - const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; - - const response = await refreshOAuthTokens( - async () => - await fetch("https://zoom.us/oauth/token", { - method: "POST", - headers: { - Authorization: authHeader, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }), - metadata.slug, - credential.userId - ); - - const responseBody = await handleZoomResponse(response, credential.id); - - if (responseBody.error) { - if (responseBody.error === "invalid_grant") { - return Promise.reject(new Error("Invalid grant for Cal.com zoom app")); - } - } - // We check the if the new credentials matches the expected response structure - const newTokens: ParseRefreshTokenResponse = parseRefreshTokenResponse( - responseBody, - zoomRefreshedTokenSchema - ); - - const key = credential.key as ZoomToken; - key.access_token = newTokens.access_token ?? key.access_token; - key.refresh_token = (newTokens.refresh_token as string) ?? key.refresh_token; - // set expiry date as offset from current time. - key.expiry_date = - typeof newTokens.expires_in === "number" - ? Math.round(Date.now() + newTokens.expires_in * 1000) - : key.expiry_date; - // Store new tokens in database. - await prisma.credential.update({ - where: { id: credential.id }, - data: { key: { ...key, ...newTokens } }, - }); - return newTokens.access_token; - }; - - return { - getToken: async () => { - const credentialKey = credential.key as ZoomToken; - - return isTokenValid(credentialKey) - ? Promise.resolve(credentialKey.access_token) - : refreshAccessToken(credentialKey.refresh_token); - }, - }; -}; - type ZoomRecurrence = { end_date_time?: string; type: 1 | 2 | 3; @@ -146,6 +69,8 @@ type ZoomRecurrence = { }; const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { + const tokenResponse = getTokenObjectFromCredential(credential); + const translateEvent = (event: CalendarEvent) => { const getRecurrence = ({ recurringEvent, @@ -228,18 +153,90 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => }; const fetchZoomApi = async (endpoint: string, options?: RequestInit) => { - const auth = zoomAuth(credential); - const accessToken = await auth.getToken(); - const response = await fetch(`https://api.zoom.us/v2/${endpoint}`, { - method: "GET", - ...options, - headers: { - Authorization: `Bearer ${accessToken}`, - ...options?.headers, + const auth = new OAuthManager({ + credentialSyncVariables: { + APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME, + }, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: metadata.slug, + currentTokenObject: tokenResponse, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + if (!refreshToken) { + return null; + } + const clientCredentials = await getZoomAppKeys(); + const { client_id, client_secret } = clientCredentials; + const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; + return fetch("https://zoom.us/oauth/token", { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + }, + isTokenObjectUnusable: async function (response) { + const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] }); + myLog.debug(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + myLog.debug(safeStringify({ responseBody })); + + if (responseBody.error === "invalid_grant") { + return { reason: responseBody.error }; + } + } + return null; + }, + isAccessTokenUnusable: async function (response) { + const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] }); + myLog.debug(safeStringify({ status: response.status, ok: response.ok })); + if (!response.ok || (response.status < 200 && response.status >= 300)) { + const responseBody = await response.json(); + myLog.debug(safeStringify({ responseBody })); + + if (responseBody.code === 124) { + return { reason: responseBody.message ?? "" }; + } + } + return null; + }, + invalidateTokenObject: () => invalidateCredential(credential.id), + expireAccessToken: () => markTokenAsExpired(credential), + updateTokenObject: async (newTokenObject) => { + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: newTokenObject, + }, + }); }, }); - const responseBody = await handleZoomResponse(response, credential.id); - return responseBody; + + const { json } = await auth.request({ + url: `https://api.zoom.us/v2/${endpoint}`, + options: { + method: "GET", + ...options, + headers: { + ...options?.headers, + }, + }, + }); + + return json; }; return { @@ -268,12 +265,6 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => }, body: JSON.stringify(translateEvent(event)), }); - if (response.error) { - if (response.error === "invalid_grant") { - await invalidateCredential(credential.id); - return Promise.reject(new Error("Invalid grant for Cal.com zoom app")); - } - } const result = zoomEventResultSchema.parse(response); @@ -319,51 +310,11 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => url: bookingRef.meetingUrl as string, }); } catch (err) { + log.error("Failed to update meeting", safeStringify(err)); return Promise.reject(new Error("Failed to update meeting")); } }, }; }; -const handleZoomResponse = async (response: Response, credentialId: Credential["id"]) => { - let _response = response.clone(); - const responseClone = response.clone(); - if (_response.headers.get("content-encoding") === "gzip") { - const responseString = await response.text(); - _response = JSON.parse(responseString); - } - if (!response.ok || (response.status < 200 && response.status >= 300)) { - const responseBody = await _response.json(); - - if ((response && response.status === 124) || responseBody.error === "invalid_grant") { - await invalidateCredential(credentialId); - } - throw Error(response.statusText); - } - // handle 204 response code with empty response (causes crash otherwise as "" is invalid JSON) - if (response.status === 204) { - return; - } - return responseClone.json(); -}; - -const invalidateCredential = async (credentialId: Credential["id"]) => { - const credential = await prisma.credential.findUnique({ - where: { - id: credentialId, - }, - }); - - if (credential) { - await prisma.credential.update({ - where: { - id: credentialId, - }, - data: { - invalid: true, - }, - }); - } -}; - export default ZoomVideoApiAdapter; diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 7648d3c015be1..6e7c2f567dbf8 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -104,7 +104,7 @@ export const getConnectedCalendars = async ( } } - log.error("getConnectedCalendars failed", safeStringify({ error, item })); + log.error("getConnectedCalendars failed", safeStringify(error), safeStringify({ item })); return { integration: cleanIntegrationKeys(item.integration), diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 500005470dc39..7ebe925d62eb0 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -109,7 +109,11 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv log.debug("created Meeting", safeStringify(returnObject)); } catch (err) { await sendBrokenIntegrationEmail(calEvent, "video"); - log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); + log.error( + "createMeeting failed", + safeStringify(err), + safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) }) + ); // Default to calVideo const defaultMeeting = await createMeetingWithCalVideo(calEvent); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 0c6a62eba2f46..22dc2588cdadb 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -115,6 +115,8 @@ export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET; export const CREDENTIAL_SYNC_SECRET_HEADER_NAME = process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"; +export const CREDENTIAL_SYNC_ENDPOINT = process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT; + export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; diff --git a/packages/lib/errors.ts b/packages/lib/errors.ts index 191c87173110f..b9f292adb3c69 100644 --- a/packages/lib/errors.ts +++ b/packages/lib/errors.ts @@ -12,6 +12,7 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb } export async function handleErrorsJson(response: Response): Promise { + // FIXME: I don't know why we are handling gzipped case separately. This should be handled by fetch itself. if (response.headers.get("content-encoding") === "gzip") { const responseText = await response.text(); return new Promise((resolve) => resolve(JSON.parse(responseText))); @@ -34,7 +35,7 @@ export function handleErrorsRaw(response: Response) { console.error({ response }); return "{}"; } - if (!response.ok && response.status < 200 && response.status >= 300) { + if (!response.ok || response.status < 200 || response.status >= 300) { response.text().then(console.log); throw Error(response.statusText); } diff --git a/packages/lib/safeStringify.ts b/packages/lib/safeStringify.ts index b41ff3e2c4ec0..7fe0a531526d6 100644 --- a/packages/lib/safeStringify.ts +++ b/packages/lib/safeStringify.ts @@ -1,5 +1,12 @@ +/** + * It stringifies the object which is necessary to ensure that in a logging system(like Axiom) we see the object in context in a single log event + */ export function safeStringify(obj: unknown) { try { + if (obj instanceof Error) { + // Errors don't serialize well, so we extract what we want + return obj.stack ?? obj.message; + } // Avoid crashing on circular references return JSON.stringify(obj); } catch (e) { diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 12198062b6e0e..58773c0e996ca 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -225,7 +225,7 @@ model TravelSchedule { @@index([endDate]) } -// It holds Personal Profiles of a User plus it has email, password and other core things +// It holds Personal Profiles of a User plus it has email, password and other core things.. model User { id Int @id @default(autoincrement()) username String? diff --git a/yarn.lock b/yarn.lock index f85fd0610629f..87dc219835ab6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -290,6 +290,25 @@ __metadata: languageName: node linkType: hard +"@auth/core@npm:^0.1.4": + version: 0.1.4 + resolution: "@auth/core@npm:0.1.4" + dependencies: + "@panva/hkdf": 1.0.2 + cookie: 0.5.0 + jose: 4.11.1 + oauth4webapi: 2.0.5 + preact: 10.11.3 + preact-render-to-string: 5.2.3 + peerDependencies: + nodemailer: 6.8.0 + peerDependenciesMeta: + nodemailer: + optional: true + checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79 + languageName: node + linkType: hard + "@aw-web-design/x-default-browser@npm:1.4.126": version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" @@ -4004,7 +4023,7 @@ __metadata: languageName: unknown linkType: soft -"@calcom/atoms@*, @calcom/atoms@workspace:packages/platform/atoms": +"@calcom/atoms@*, @calcom/atoms@workspace:^, @calcom/atoms@workspace:packages/platform/atoms": version: 0.0.0-use.local resolution: "@calcom/atoms@workspace:packages/platform/atoms" dependencies: @@ -4017,9 +4036,14 @@ __metadata: "@types/react": 18.0.26 "@types/react-dom": ^18.0.9 "@vitejs/plugin-react": ^2.2.0 + autoprefixer: ^10.4.19 class-variance-authority: ^0.4.0 clsx: ^2.0.0 lucide-react: ^0.364.0 + postcss: ^8.4.38 + postcss-import: ^16.1.0 + postcss-prefixer: ^3.0.0 + postcss-prefixwrap: 1.46.0 react-use: ^17.4.2 rollup-plugin-node-builtins: ^2.1.2 tailwind-merge: ^1.13.2 @@ -4031,6 +4055,41 @@ __metadata: languageName: unknown linkType: soft +"@calcom/auth@workspace:apps/auth": + version: 0.0.0-use.local + resolution: "@calcom/auth@workspace:apps/auth" + dependencies: + "@auth/core": ^0.1.4 + "@calcom/app-store": "*" + "@calcom/app-store-cli": "*" + "@calcom/config": "*" + "@calcom/core": "*" + "@calcom/dayjs": "*" + "@calcom/embed-core": "workspace:*" + "@calcom/embed-react": "workspace:*" + "@calcom/embed-snippet": "workspace:*" + "@calcom/features": "*" + "@calcom/lib": "*" + "@calcom/prisma": "*" + "@calcom/trpc": "*" + "@calcom/tsconfig": "*" + "@calcom/types": "*" + "@calcom/ui": "*" + "@types/node": 16.9.1 + "@types/react": 18.0.26 + "@types/react-dom": ^18.0.9 + eslint: ^8.34.0 + eslint-config-next: ^13.2.1 + next: ^13.5.4 + next-auth: ^4.22.1 + postcss: ^8.4.18 + react: ^18.2.0 + react-dom: ^18.2.0 + tailwindcss: ^3.3.3 + typescript: ^4.9.4 + languageName: unknown + linkType: soft + "@calcom/base@workspace:packages/platform/examples/base": version: 0.0.0-use.local resolution: "@calcom/base@workspace:packages/platform/examples/base" @@ -4151,7 +4210,7 @@ __metadata: chart.js: ^3.7.1 client-only: ^0.0.1 eslint: ^8.34.0 - next: ^13.4.6 + next: ^13.5.4 next-auth: ^4.22.1 next-i18next: ^13.2.2 postcss: ^8.4.18 @@ -4351,6 +4410,29 @@ __metadata: languageName: unknown linkType: soft +"@calcom/example-app-credential-sync@workspace:example-apps/credential-sync": + version: 0.0.0-use.local + resolution: "@calcom/example-app-credential-sync@workspace:example-apps/credential-sync" + dependencies: + "@calcom/atoms": "*" + "@prisma/client": 5.4.2 + "@types/node": ^20.3.1 + "@types/react": ^18 + "@types/react-dom": ^18 + autoprefixer: ^10.0.1 + dotenv: ^16.3.1 + eslint: ^8 + eslint-config-next: 14.0.4 + next: 14.0.4 + postcss: ^8 + prisma: ^5.7.1 + react: ^18 + react-dom: ^18 + tailwindcss: ^3.3.0 + typescript: ^4.9.4 + languageName: unknown + linkType: soft + "@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar": version: 0.0.0-use.local resolution: "@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar" @@ -5328,6 +5410,7 @@ __metadata: dependencies: "@algora/sdk": ^0.1.2 "@calcom/app-store": "*" + "@calcom/atoms": "workspace:^" "@calcom/config": "*" "@calcom/dayjs": "*" "@calcom/embed-react": "workspace:^" @@ -5373,6 +5456,7 @@ __metadata: "@vercel/og": ^0.5.0 autoprefixer: ^10.4.12 bcryptjs: ^2.4.3 + class-variance-authority: ^0.7.0 clsx: ^1.2.1 cobe: ^0.4.1 concurrently: ^7.6.0 @@ -5385,6 +5469,7 @@ __metadata: env-cmd: ^10.1.0 eslint: ^8.34.0 fathom-client: ^3.5.0 + framer-motion: ^11.0.25 globby: ^13.1.3 graphql: ^16.8.0 graphql-codegen: ^0.4.0 @@ -5406,7 +5491,7 @@ __metadata: prism-react-renderer: ^1.3.5 react: ^18.2.0 react-confetti: ^6.0.1 - react-datocms: ^3.1.0 + react-datocms: ^5.0.3 react-device-detect: ^2.2.2 react-dom: ^18.2.0 react-fast-marquee: ^1.6.4 @@ -5418,6 +5503,7 @@ __metadata: react-merge-refs: 1.1.0 react-resize-detector: ^9.1.0 react-twemoji: ^0.3.0 + react-twitter-embed: ^4.0.4 react-use-measure: ^2.1.1 react-wrap-balancer: ^1.0.0 remark: ^14.0.2 @@ -9117,6 +9203,59 @@ __metadata: languageName: node linkType: hard +"@mux/mux-player-react@npm:*": + version: 2.5.0 + resolution: "@mux/mux-player-react@npm:2.5.0" + dependencies: + "@mux/mux-player": 2.5.0 + "@mux/playback-core": 0.23.0 + prop-types: ^15.7.2 + peerDependencies: + "@types/react": ^17.0.0 || ^18 + react: ^17.0.2 || ^18 + react-dom: ^17.0.2 || ^18 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 9b2854793a47928d964fbbd46d4c2e659d1dea29993bf74febcdbd3dfd2729abdb9d9d926e67113376ea7a9d99da9c275705d702996503deade6d35128e7f6ac + languageName: node + linkType: hard + +"@mux/mux-player@npm:2.5.0": + version: 2.5.0 + resolution: "@mux/mux-player@npm:2.5.0" + dependencies: + "@mux/mux-video": 0.18.0 + "@mux/playback-core": 0.23.0 + media-chrome: ~3.2.1 + checksum: 566bcb3372fb0f6ac4e37ce82707d565693681d010a7ae6d9890ae6b74320d8464af7bb1f8582709a7263c2e09e9e58137c2caf5f2c2eb3db91a137fb625d064 + languageName: node + linkType: hard + +"@mux/mux-video@npm:0.18.0": + version: 0.18.0 + resolution: "@mux/mux-video@npm:0.18.0" + dependencies: + "@mux/playback-core": 0.23.0 + castable-video: ~1.0.6 + custom-media-element: ~1.2.3 + media-tracks: ~0.3.0 + checksum: a6a8137cfbfd04304f11434cc8e2eff3c1f3e72fd758cd4b2bf4eea4ce29a29525116a491e4be3ce4c995eba483704a5020ab4db5960017b22c5a913bfb04279 + languageName: node + linkType: hard + +"@mux/playback-core@npm:0.23.0": + version: 0.23.0 + resolution: "@mux/playback-core@npm:0.23.0" + dependencies: + hls.js: ~1.5.8 + mux-embed: ~5.2.0 + checksum: c5bee62359ee79094ceca839972a7ecbcaf7339cb68ab5f404bbb6b3b7e25d1066c69bc4d92aa9912af5fa6d6a0e4eabe745e3589fffcc92f8b78171a6af58eb + languageName: node + linkType: hard + "@ndelangen/get-tarball@npm:^3.0.7": version: 3.0.9 resolution: "@ndelangen/get-tarball@npm:3.0.9" @@ -9386,13 +9525,6 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:13.5.6": - version: 13.5.6 - resolution: "@next/env@npm:13.5.6" - checksum: 5e8f3f6f987a15dad3cd7b2bcac64a6382c2ec372d95d0ce6ab295eb59c9731222017eebf71ff3005932de2571f7543bce7e5c6a8c90030207fb819404138dc2 - languageName: node - linkType: hard - "@next/env@npm:14.0.4": version: 14.0.4 resolution: "@next/env@npm:14.0.4" @@ -9432,13 +9564,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-darwin-arm64@npm:13.5.6" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@next/swc-darwin-arm64@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-darwin-arm64@npm:14.0.4" @@ -9460,13 +9585,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-darwin-x64@npm:13.5.6" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@next/swc-darwin-x64@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-darwin-x64@npm:14.0.4" @@ -9488,13 +9606,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-linux-arm64-gnu@npm:13.5.6" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@next/swc-linux-arm64-gnu@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-arm64-gnu@npm:14.0.4" @@ -9516,13 +9627,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-linux-arm64-musl@npm:13.5.6" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@next/swc-linux-arm64-musl@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-arm64-musl@npm:14.0.4" @@ -9544,13 +9648,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-linux-x64-gnu@npm:13.5.6" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@next/swc-linux-x64-gnu@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-x64-gnu@npm:14.0.4" @@ -9572,13 +9669,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-linux-x64-musl@npm:13.5.6" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@next/swc-linux-x64-musl@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-x64-musl@npm:14.0.4" @@ -9600,13 +9690,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-win32-arm64-msvc@npm:13.5.6" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@next/swc-win32-arm64-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-arm64-msvc@npm:14.0.4" @@ -9628,13 +9711,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-win32-ia32-msvc@npm:13.5.6" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@next/swc-win32-ia32-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-ia32-msvc@npm:14.0.4" @@ -9656,13 +9732,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:13.5.6": - version: 13.5.6 - resolution: "@next/swc-win32-x64-msvc@npm:13.5.6" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@next/swc-win32-x64-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-x64-msvc@npm:14.0.4" @@ -10305,6 +10374,13 @@ __metadata: languageName: node linkType: hard +"@panva/hkdf@npm:1.0.2": + version: 1.0.2 + resolution: "@panva/hkdf@npm:1.0.2" + checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03 + languageName: node + linkType: hard + "@panva/hkdf@npm:^1.0.2": version: 1.0.4 resolution: "@panva/hkdf@npm:1.0.4" @@ -17259,15 +17335,6 @@ __metadata: languageName: node linkType: hard -"@vercel/analytics@npm:^0.1.6": - version: 0.1.11 - resolution: "@vercel/analytics@npm:0.1.11" - peerDependencies: - react: ^16.8||^17||^18 - checksum: 05b8180ac6e23ebe7c09d74c43f8ee78c408cd0b6546e676389cbf4fba44dfeeae3648c9b52e2421be64fe3aeee8b026e6ea4bdfc0589fb5780670f2b090a167 - languageName: node - linkType: hard - "@vercel/edge-config@npm:^0.1.1": version: 0.1.1 resolution: "@vercel/edge-config@npm:0.1.1" @@ -19004,6 +19071,24 @@ __metadata: languageName: node linkType: hard +"autoprefixer@npm:^10.4.19": + version: 10.4.19 + resolution: "autoprefixer@npm:10.4.19" + dependencies: + browserslist: ^4.23.0 + caniuse-lite: ^1.0.30001599 + fraction.js: ^4.3.7 + normalize-range: ^0.1.2 + picocolors: ^1.0.0 + postcss-value-parser: ^4.2.0 + peerDependencies: + postcss: ^8.1.0 + bin: + autoprefixer: bin/autoprefixer + checksum: 3a4bc5bace05e057396dca2b306503efc175e90e8f2abf5472d3130b72da1d54d97c0ee05df21bf04fe66a7df93fd8c8ec0f1aca72a165f4701a02531abcbf11 + languageName: node + linkType: hard + "available-typed-arrays@npm:^1.0.5": version: 1.0.5 resolution: "available-typed-arrays@npm:1.0.5" @@ -20374,6 +20459,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001599": + version: 1.0.30001612 + resolution: "caniuse-lite@npm:1.0.30001612" + checksum: 2b6ab6a19c72bdf8dccac824944e828a2a1fae52c6dfeb2d64ccecfd60d0466d2e5a392e996da2150d92850188a5034666dceed34a38d978177f6934e0bf106d + languageName: node + linkType: hard + "capital-case@npm:^1.0.4": version: 1.0.4 resolution: "capital-case@npm:1.0.4" @@ -20411,6 +20503,15 @@ __metadata: languageName: node linkType: hard +"castable-video@npm:~1.0.6": + version: 1.0.6 + resolution: "castable-video@npm:1.0.6" + dependencies: + custom-media-element: ~1.2.2 + checksum: 873ea75b35c594ed3755bacedd33628c070c751e34e0a64e42f98a4e9e0fda7f988ecac0ec723a4f7a25f332ba4650d4686e826614d81663554b9b31298cc648 + languageName: node + linkType: hard + "ccount@npm:^2.0.0": version: 2.0.1 resolution: "ccount@npm:2.0.1" @@ -20875,6 +20976,15 @@ __metadata: languageName: node linkType: hard +"class-variance-authority@npm:^0.7.0": + version: 0.7.0 + resolution: "class-variance-authority@npm:0.7.0" + dependencies: + clsx: 2.0.0 + checksum: e7fd1fab433ef06f52a1b7b241b70b4a185864deef199d3b0a2c3412f1cc179517288264c383f3b971a00d76811625fc8f7ffe709e6170219e88cd7368f08a20 + languageName: node + linkType: hard + "classnames@npm:^2.2.5, classnames@npm:^2.2.6": version: 2.3.2 resolution: "classnames@npm:2.3.2" @@ -21147,6 +21257,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:2.0.0": + version: 2.0.0 + resolution: "clsx@npm:2.0.0" + checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e + languageName: node + linkType: hard + "clsx@npm:^1.1.1": version: 1.1.1 resolution: "clsx@npm:1.1.1" @@ -22326,6 +22443,16 @@ __metadata: languageName: node linkType: hard +"css-selector-tokenizer@npm:^0.7.2": + version: 0.7.3 + resolution: "css-selector-tokenizer@npm:0.7.3" + dependencies: + cssesc: ^3.0.0 + fastparse: ^1.1.2 + checksum: 92560a9616a8bc073b88c678aa04f22c599ac23c5f8587e60f4861069e2d5aeb37b722af581ae3c5fbce453bed7a893d9c3e06830912e6d28badc3b8b99acd24 + languageName: node + linkType: hard + "css-to-react-native@npm:^3.0.0": version: 3.0.0 resolution: "css-to-react-native@npm:3.0.0" @@ -22442,6 +22569,13 @@ __metadata: languageName: node linkType: hard +"custom-media-element@npm:~1.2.2, custom-media-element@npm:~1.2.3": + version: 1.2.3 + resolution: "custom-media-element@npm:1.2.3" + checksum: da43680c35c870cd80ea02b9fce3efe25e9502c7088de5ab0e47ef2734aa9ce7e147c95f78a10abd4c13c47a81eae4712a7651ad18f87e023bc371082ba09842 + languageName: node + linkType: hard + "d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": version: 3.2.3 resolution: "d3-array@npm:3.2.3" @@ -25755,6 +25889,13 @@ __metadata: languageName: node linkType: hard +"fastparse@npm:^1.1.2": + version: 1.1.2 + resolution: "fastparse@npm:1.1.2" + checksum: c4d199809dc4e8acafeb786be49481cc9144de296e2d54df4540ccfd868d0df73afc649aba70a748925eb32bbc4208b723d6288adf92382275031a8c7e10c0aa + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.13.0 resolution: "fastq@npm:1.13.0" @@ -26483,6 +26624,26 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^11.0.25": + version: 11.1.7 + resolution: "framer-motion@npm:11.1.7" + dependencies: + tslib: ^2.4.0 + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: ba4f2f0823eec7b78fa87180545cf8c832929ce52de8bab34312a67bfd6dee4ab6ad17143b9df2efcd4ede7cb87fc326843a1101a6cad3031c89a6ad2160e037 + languageName: node + linkType: hard + "fresh@npm:0.5.2, fresh@npm:^0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -28164,6 +28325,13 @@ __metadata: languageName: node linkType: hard +"hls.js@npm:~1.5.8": + version: 1.5.8 + resolution: "hls.js@npm:1.5.8" + checksum: 93781b38859ce852952244e9671d536a73acb93a20cfbc3a68e372056d024789e48c7d67354b6b9ac35acab4a161d0d15adcf64279a0832f46465233cdbc8be0 + languageName: node + linkType: hard + "hmac-drbg@npm:^1.0.1": version: 1.0.1 resolution: "hmac-drbg@npm:1.0.1" @@ -30220,15 +30388,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:^5.0.0": - version: 5.0.0 - resolution: "isomorphic-ws@npm:5.0.0" - peerDependencies: - ws: "*" - checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 - languageName: node - linkType: hard - "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -30987,6 +31146,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:4.11.1": + version: 4.11.1 + resolution: "jose@npm:4.11.1" + checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7 + languageName: node + linkType: hard + "jose@npm:5.2.1": version: 5.2.1 resolution: "jose@npm:5.2.1" @@ -32976,15 +33142,6 @@ __metadata: languageName: node linkType: hard -"lucide-react@npm:^0.363.0": - version: 0.363.0 - resolution: "lucide-react@npm:0.363.0" - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 - checksum: abe8fad469a2f14181eca6f3682403c5d53a4a354f333b0f7d3b575471d451c6f3fd36f59d96745a5a823e4cc55eeb2dcda70774a6fe438834ea3fb6fa5b75af - languageName: node - linkType: hard - "luxon@npm:3.3.0": version: 3.3.0 resolution: "luxon@npm:3.3.0" @@ -33544,6 +33701,20 @@ __metadata: languageName: node linkType: hard +"media-chrome@npm:~3.2.1": + version: 3.2.1 + resolution: "media-chrome@npm:3.2.1" + checksum: 0e7d8a6850d0c4844be7b7975bcf80886aad5f2ae3cff1081aa5a7b7920ddf9f15b2da4df875f83ffb5f083d3fc1d337a8ac204317e40391f8311eb7f2f58c5e + languageName: node + linkType: hard + +"media-tracks@npm:~0.3.0": + version: 0.3.0 + resolution: "media-tracks@npm:0.3.0" + checksum: 217880bdf566cd070f22fce2cba132cd602ae2838a20f1c75aeb5f928edda283133d474b1f427deb2758cd9b64911f88eb6380921662566bbeb7051adbb81629 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -34898,6 +35069,13 @@ __metadata: languageName: node linkType: hard +"mux-embed@npm:~5.2.0": + version: 5.2.0 + resolution: "mux-embed@npm:5.2.0" + checksum: c90f6e216a8239ef15534b26feb2299cbac51dff82766e23c89be6e30bf4348fe711fb092968b607fd45d1b509e75882efc2e12e0b34039483c1bf1b7c6690bb + languageName: node + linkType: hard + "mysql2@npm:3.9.1": version: 3.9.1 resolution: "mysql2@npm:3.9.1" @@ -35320,61 +35498,6 @@ __metadata: languageName: node linkType: hard -"next@npm:^13.4.6": - version: 13.5.6 - resolution: "next@npm:13.5.6" - dependencies: - "@next/env": 13.5.6 - "@next/swc-darwin-arm64": 13.5.6 - "@next/swc-darwin-x64": 13.5.6 - "@next/swc-linux-arm64-gnu": 13.5.6 - "@next/swc-linux-arm64-musl": 13.5.6 - "@next/swc-linux-x64-gnu": 13.5.6 - "@next/swc-linux-x64-musl": 13.5.6 - "@next/swc-win32-arm64-msvc": 13.5.6 - "@next/swc-win32-ia32-msvc": 13.5.6 - "@next/swc-win32-x64-msvc": 13.5.6 - "@swc/helpers": 0.5.2 - busboy: 1.6.0 - caniuse-lite: ^1.0.30001406 - postcss: 8.4.31 - styled-jsx: 5.1.1 - watchpack: 2.4.0 - peerDependencies: - "@opentelemetry/api": ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - dependenciesMeta: - "@next/swc-darwin-arm64": - optional: true - "@next/swc-darwin-x64": - optional: true - "@next/swc-linux-arm64-gnu": - optional: true - "@next/swc-linux-arm64-musl": - optional: true - "@next/swc-linux-x64-gnu": - optional: true - "@next/swc-linux-x64-musl": - optional: true - "@next/swc-win32-arm64-msvc": - optional: true - "@next/swc-win32-ia32-msvc": - optional: true - "@next/swc-win32-x64-msvc": - optional: true - peerDependenciesMeta: - "@opentelemetry/api": - optional: true - sass: - optional: true - bin: - next: dist/bin/next - checksum: c869b0014ae921ada3bf22301985027ec320aebcd6aa9c16e8afbded68bb8def5874cca034c680e8c351a79578f1e514971d02777f6f0a5a1d7290f25970ac0d - languageName: node - linkType: hard - "next@npm:^13.5.4": version: 13.5.5 resolution: "next@npm:13.5.5" @@ -36029,6 +36152,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:2.0.5": + version: 2.0.5 + resolution: "oauth4webapi@npm:2.0.5" + checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9 + languageName: node + linkType: hard + "oauth@npm:^0.9.15": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -37874,6 +38004,19 @@ __metadata: languageName: node linkType: hard +"postcss-import@npm:^16.1.0": + version: 16.1.0 + resolution: "postcss-import@npm:16.1.0" + dependencies: + postcss-value-parser: ^4.0.0 + read-cache: ^1.0.0 + resolve: ^1.1.7 + peerDependencies: + postcss: ^8.0.0 + checksum: 6d7f2fd649b7c7c3ff58d9d08003a0502466a007176655922ec535c98ab1a6bb42f09f017bb05bd18dd5fb57419df0ed9a06ec7f53b1a286fcb2daf964eec19c + languageName: node + linkType: hard + "postcss-js@npm:^4.0.1": version: 4.0.1 resolution: "postcss-js@npm:4.0.1" @@ -37972,6 +38115,26 @@ __metadata: languageName: node linkType: hard +"postcss-prefixer@npm:^3.0.0": + version: 3.0.0 + resolution: "postcss-prefixer@npm:3.0.0" + dependencies: + css-selector-tokenizer: ^0.7.2 + peerDependencies: + postcss: ^8.0.0 + checksum: 5083289b671e2b7d1507d8574c9bcf1efe4485117adc98cee34aae2d9edb5633d203947f1d202b0a8dc631e5920247995e414a08fba1bdf822f50a043333c203 + languageName: node + linkType: hard + +"postcss-prefixwrap@npm:1.46.0": + version: 1.46.0 + resolution: "postcss-prefixwrap@npm:1.46.0" + peerDependencies: + postcss: "*" + checksum: a76a391c54b95cca9ec024205ca9526fa5e2cb0e033da8c78aee557b5b604a18d667ac7f5209d2af9d5bb13c7562389af0edab3e9d31ad06109c44ba979fb671 + languageName: node + linkType: hard + "postcss-pseudo-companion-classes@npm:^0.1.1": version: 0.1.1 resolution: "postcss-pseudo-companion-classes@npm:0.1.1" @@ -38092,6 +38255,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.4.38": + version: 8.4.38 + resolution: "postcss@npm:8.4.38" + dependencies: + nanoid: ^3.3.7 + picocolors: ^1.0.0 + source-map-js: ^1.2.0 + checksum: 649f9e60a763ca4b5a7bbec446a069edf07f057f6d780a5a0070576b841538d1ecf7dd888f2fbfd1f76200e26c969e405aeeae66332e6927dbdc8bdcb90b9451 + languageName: node + linkType: hard + "postgres-array@npm:~2.0.0": version: 2.0.0 resolution: "postgres-array@npm:2.0.0" @@ -38159,6 +38333,17 @@ __metadata: languageName: node linkType: hard +"preact-render-to-string@npm:5.2.3": + version: 5.2.3 + resolution: "preact-render-to-string@npm:5.2.3" + dependencies: + pretty-format: ^3.8.0 + peerDependencies: + preact: ">=10" + checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44 + languageName: node + linkType: hard + "preact-render-to-string@npm:^5.1.19": version: 5.2.6 resolution: "preact-render-to-string@npm:5.2.6" @@ -38170,7 +38355,7 @@ __metadata: languageName: node linkType: hard -"preact@npm:^10.6.3": +"preact@npm:10.11.3, preact@npm:^10.6.3": version: 10.11.3 resolution: "preact@npm:10.11.3" checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367 @@ -39190,20 +39375,21 @@ __metadata: languageName: node linkType: hard -"react-datocms@npm:^3.1.0": - version: 3.1.4 - resolution: "react-datocms@npm:3.1.4" +"react-datocms@npm:^5.0.3": + version: 5.0.3 + resolution: "react-datocms@npm:5.0.3" dependencies: + "@mux/mux-player-react": "*" datocms-listen: ^0.1.9 datocms-structured-text-generic-html-renderer: ^2.0.1 datocms-structured-text-utils: ^2.0.1 - react-intersection-observer: ^8.33.1 + react-intersection-observer: ^9.4.3 react-string-replace: ^1.1.0 universal-base64: ^2.1.0 use-deep-compare-effect: ^1.6.1 peerDependencies: react: ">= 16.12.0" - checksum: 54aba12aef4937175c2011548a8a576c96c8d8a596e84d191826910624c1d596e76a49782689dc236388a10803b02e700ac820cb7500cca7fd147a81f6c544c3 + checksum: 22c20152afb54424acfe967a2c8c525cd9f132a33374f2aba0231f16ea64dade389b096e2dac8de9ffded612bc32e9891d725609ee947639fe1cef907cb143f5 languageName: node linkType: hard @@ -39445,12 +39631,16 @@ __metadata: languageName: node linkType: hard -"react-intersection-observer@npm:^8.33.1": - version: 8.34.0 - resolution: "react-intersection-observer@npm:8.34.0" +"react-intersection-observer@npm:^9.4.3": + version: 9.8.2 + resolution: "react-intersection-observer@npm:9.8.2" peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0|| ^18.0.0 - checksum: 7713fecfd1512c7f5a60f9f0bf15403b8f8bbd4110bcafaeaea6de36a0e0eb60368c3638f99e9c97b75ad8fc787ea48c241dcb5c694f821d7f2976f709082cc5 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react-dom: + optional: true + checksum: 0e77226cf458499959773da9db9e00ae33a6eb95c2fa1ec3f506bbcba00955c1c27d2320fba8cdd740cadb1c7d6cc396386cbd9c359298cff196cac660dbee49 languageName: node linkType: hard @@ -39974,6 +40164,18 @@ __metadata: languageName: node linkType: hard +"react-twitter-embed@npm:^4.0.4": + version: 4.0.4 + resolution: "react-twitter-embed@npm:4.0.4" + dependencies: + scriptjs: ^2.5.9 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: cdb3c5bd04c4da0efa767476be47c0a3865fb6335f2a1b9e242170167b51615c38164223278cef60c77143c4bac27ba582cbea054d0af3f138104fa5ec537c4c + languageName: node + linkType: hard + "react-universal-interface@npm:^0.6.2": version: 0.6.2 resolution: "react-universal-interface@npm:0.6.2" @@ -41801,6 +42003,13 @@ __metadata: languageName: node linkType: hard +"scriptjs@npm:^2.5.9": + version: 2.5.9 + resolution: "scriptjs@npm:2.5.9" + checksum: fc84cb6b60b6fb9aa6f1b3bc59fc94b233bd5241ed3a04233579014382b5eb60640269c87d8657902acc09f9b785ee33230c218627cea00e653564bda8f5acb6 + languageName: node + linkType: hard + "scuid@npm:^1.1.0": version: 1.1.0 resolution: "scuid@npm:1.1.0" @@ -42478,6 +42687,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 791a43306d9223792e84293b00458bf102a8946e7188f3db0e4e22d8d530b5f80a4ce468eb5ec0bf585443ad55ebbd630bf379c98db0b1f317fd902500217f97 + languageName: node + linkType: hard + "source-map-support@npm:0.5.13": version: 0.5.13 resolution: "source-map-support@npm:0.5.13" From a3a95a1496205f844eff000d010d9f6c3b3e5e99 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 25 Apr 2024 17:26:46 -0300 Subject: [PATCH 3/8] chore: SDK readme (#14761) --- packages/platform/sdk/README.md | 64 +++++++++++++++++++- packages/platform/sdk/src/lib/http-caller.ts | 2 +- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/platform/sdk/README.md b/packages/platform/sdk/README.md index a88f10d556a48..ef8942b2b8483 100644 --- a/packages/platform/sdk/README.md +++ b/packages/platform/sdk/README.md @@ -3,5 +3,65 @@ Logo
- Cal.com Atoms SDK -

\ No newline at end of file + Cal.com SDK +

+ +![SDK Version](https://img.shields.io/github/package-json/v/calcom/cal.com/main?filename=packages%2Fplatform%2Fsdk%2Fpackage.json) + +## Install + +```bash +yarn add @calcom/sdk +``` + +## Usage + +To use the Cal.com SDK you need to have an OAuth Client set up, which you can obtain [here](https://app.cal.com/settings/organizations/platform/oauth-clients/). + +```typescript +import { Cal } from "@calcom/sdk"; + +const sdk = new Cal("your_client_id", { + clientSecret: "your_client_secret", +}); +``` + +### Authenticating as a User +The SDK is also meant to be used as an authenticated user, to do that, you need to pass the `accessToken` to the `authOptions` in the SDK constructor. + +```typescript +const authedSdk = new Cal("your_client_id", { + clientSecret: "your_client_secret", + accessToken: "your_user_access_token" +}); + +const schedule = await authedSdk.schedules.createSchedule({ + availabilities: [ + { + days: [1, 2, 3, 4, 5, 6], + startTime: "09:00:00", + endTime: "17:00:00", + }, + ], + isDefault: true, + name: "Default Schedule", + timeZone: "America/Argentina/Buenos_Aires", +}); +``` + +You can manually refresh access tokens, or you can let the SDK handle token refreshes via the `handleRefresh` option. + +To manually update an access token, you can use the following snippet: +```typescript +sdk.secrets().updateAccessToken(oauth.accessToken, oauth.refreshToken); +``` + +## Configuration + +| Option | Required | Description | +|----------------------------|----------|-----------------------------------------------------------------------------------------------------| +| `authOptions.clientSecret` | `TRUE` | The Client Secret corresponding to the client ID passed as the first parameter. | +| `authOptions.accessToken` | `FALSE` | `Optional` Access token when authenticating as a specific user. | +| `authOptions.refreshToken` | `FALSE` | `Optional` If provided, the SDK can handle refreshing access tokens automatically when they expire. | +| `options.baseUrl` | `FALSE` | `Defaults to https://api.cal.com`. The base URI for the Cal.com platform API | +| `options.handleRefresh` | `FALSE` | Whether the SDK should handle automatic refreshes for expired access tokens. | diff --git a/packages/platform/sdk/src/lib/http-caller.ts b/packages/platform/sdk/src/lib/http-caller.ts index 566bc47146841..bea196cfc919f 100644 --- a/packages/platform/sdk/src/lib/http-caller.ts +++ b/packages/platform/sdk/src/lib/http-caller.ts @@ -68,7 +68,7 @@ export class HttpCaller { try { await this.secrets?.refreshAccessToken(this.clientId); - this.retryQueuedRequests(); + await this.retryQueuedRequests(); } catch (refreshError) { console.error("Failed to refresh token:", refreshError); // Optionally, clear the queue on failure to prevent hanging requests From d02947f8871368630892d8a3d04cbbd7e673db94 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 25 Apr 2024 20:22:47 -0300 Subject: [PATCH 4/8] chore: SDK cleanup (#14763) --- packages/platform/sdk/src/cal.ts | 4 ++-- packages/platform/sdk/src/endpoints/endpoint-handler.ts | 2 +- .../src/endpoints/{ee/users => managed-users}/index.ts | 8 ++++---- .../src/endpoints/{ee/users => managed-users}/types.ts | 0 packages/platform/sdk/src/lib/http-caller.ts | 8 +++----- packages/platform/sdk/src/lib/sdk-secrets.ts | 7 ++----- packages/platform/sdk/src/types.ts | 2 +- 7 files changed, 13 insertions(+), 18 deletions(-) rename packages/platform/sdk/src/endpoints/{ee/users => managed-users}/index.ts (88%) rename packages/platform/sdk/src/endpoints/{ee/users => managed-users}/types.ts (100%) diff --git a/packages/platform/sdk/src/cal.ts b/packages/platform/sdk/src/cal.ts index 14669427f2b2e..bf27f4a26f5b5 100644 --- a/packages/platform/sdk/src/cal.ts +++ b/packages/platform/sdk/src/cal.ts @@ -2,9 +2,9 @@ import axios from "axios"; import axiosRetry from "axios-retry"; import { Bookings } from "./endpoints/bookings"; -import { ManagedUsers } from "./endpoints/ee/users"; import { Events } from "./endpoints/events"; import { EventTypes } from "./endpoints/events/event-types"; +import { ManagedUsers } from "./endpoints/managed-users"; import { OAuthFlow } from "./endpoints/oauth-flow"; import { Schedules } from "./endpoints/schedules"; import { Slots } from "./endpoints/slots"; @@ -24,7 +24,7 @@ export class CalSdk { schedules: Schedules; users: ManagedUsers; - private _secrets: SdkSecrets; + private readonly _secrets: SdkSecrets; constructor( public readonly clientId: string, diff --git a/packages/platform/sdk/src/endpoints/endpoint-handler.ts b/packages/platform/sdk/src/endpoints/endpoint-handler.ts index 9d5723a2b60b2..caf7680c7a876 100644 --- a/packages/platform/sdk/src/endpoints/endpoint-handler.ts +++ b/packages/platform/sdk/src/endpoints/endpoint-handler.ts @@ -6,7 +6,7 @@ import { assert } from "ts-essentials"; import type { CalSdk } from "../cal"; export abstract class EndpointHandler { - constructor(private readonly key: string, private readonly calSdk: CalSdk) {} + protected constructor(private readonly key: string, private readonly calSdk: CalSdk) {} withForAtomParam(forAtom: boolean, config?: AxiosRequestConfig) { if (!forAtom) return config; diff --git a/packages/platform/sdk/src/endpoints/ee/users/index.ts b/packages/platform/sdk/src/endpoints/managed-users/index.ts similarity index 88% rename from packages/platform/sdk/src/endpoints/ee/users/index.ts rename to packages/platform/sdk/src/endpoints/managed-users/index.ts index a78389fee19c6..3d35789f1ddc6 100644 --- a/packages/platform/sdk/src/endpoints/ee/users/index.ts +++ b/packages/platform/sdk/src/endpoints/managed-users/index.ts @@ -1,7 +1,7 @@ -import type { CalSdk } from "../../../cal"; -import { Endpoints } from "../../../lib/endpoints"; -import type { BasicPlatformResponse, PaginationOptions } from "../../../types"; -import { EndpointHandler } from "../../endpoint-handler"; +import type { CalSdk } from "../../cal"; +import { Endpoints } from "../../lib/endpoints"; +import type { BasicPlatformResponse, PaginationOptions } from "../../types"; +import { EndpointHandler } from "../endpoint-handler"; import type { CreateUserArgs, CreateUserResponse, User } from "./types"; export class ManagedUsers extends EndpointHandler { diff --git a/packages/platform/sdk/src/endpoints/ee/users/types.ts b/packages/platform/sdk/src/endpoints/managed-users/types.ts similarity index 100% rename from packages/platform/sdk/src/endpoints/ee/users/types.ts rename to packages/platform/sdk/src/endpoints/managed-users/types.ts diff --git a/packages/platform/sdk/src/lib/http-caller.ts b/packages/platform/sdk/src/lib/http-caller.ts index bea196cfc919f..c55bb7f33c9a3 100644 --- a/packages/platform/sdk/src/lib/http-caller.ts +++ b/packages/platform/sdk/src/lib/http-caller.ts @@ -35,7 +35,9 @@ export class HttpCaller { private readonly axiosClient: AxiosInstance, private readonly options?: HttpCallerOptions ) { - this.setupInterceptors(); + if (options?.shouldHandleRefresh) { + this.setupInterceptors(); + } } private async retryQueuedRequests() { @@ -161,8 +163,4 @@ export class HttpCaller { params, }); } - - isAwaitingRefresh() { - return this.awaitingRefresh; - } } diff --git a/packages/platform/sdk/src/lib/sdk-secrets.ts b/packages/platform/sdk/src/lib/sdk-secrets.ts index babe6be7e7272..44059e29a40ac 100644 --- a/packages/platform/sdk/src/lib/sdk-secrets.ts +++ b/packages/platform/sdk/src/lib/sdk-secrets.ts @@ -58,10 +58,7 @@ export class SdkSecrets { return !!this.accessToken; } - public _debug() { - return { - refreshedAt: this.refreshedAt, - accessToken: this.accessToken, - }; + public getRefreshedAt(): Date | null { + return this.refreshedAt; } } diff --git a/packages/platform/sdk/src/types.ts b/packages/platform/sdk/src/types.ts index c98c8543fc9d7..4677cf342dda0 100644 --- a/packages/platform/sdk/src/types.ts +++ b/packages/platform/sdk/src/types.ts @@ -22,7 +22,7 @@ export enum ApiVersion { } export type SdkAuthOptions = { - clientSecret: string; + clientSecret?: string; accessToken?: string; refreshToken?: string; }; From 5d76bd6b9c961af13e249af35a4bdb8a34c01d6b Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Fri, 26 Apr 2024 09:21:07 +0300 Subject: [PATCH 5/8] v4.0.4 (#14764) --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index c3c59defa6cdc..c3a1202d5da30 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "4.0.3", + "version": "4.0.4", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From d004815a68e2e53bf137ed16473a0b6454b1926f Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 26 Apr 2024 11:20:29 +0200 Subject: [PATCH 6/8] feat: v2 managed user create default schedule given timeZone (#14727) * schedules service: function to create default user schedule * managed user: create default schedule if timeZone provided * swagger update --- .../schedules/services/schedules.service.ts | 10 + .../oauth-client-users.controller.e2e-spec.ts | 17 + .../oauth-clients/oauth-client.module.ts | 2 + .../services/oauth-clients-users.service.ts | 10 +- apps/api/v2/swagger/documentation.json | 418 +++++++++--------- 5 files changed, 247 insertions(+), 210 deletions(-) diff --git a/apps/api/v2/src/ee/schedules/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/services/schedules.service.ts index d74abd6cebf0c..c91176e422f0d 100644 --- a/apps/api/v2/src/ee/schedules/services/schedules.service.ts +++ b/apps/api/v2/src/ee/schedules/services/schedules.service.ts @@ -24,6 +24,16 @@ export class SchedulesService { private readonly usersRepository: UsersRepository ) {} + async createUserDefaultSchedule(userId: number, timeZone: string) { + const schedule = { + isDefault: true, + name: "Default schedule", + timeZone, + }; + + return this.createUserSchedule(userId, schedule); + } + async createUserSchedule(userId: number, schedule: CreateScheduleInput) { const availabilities = schedule.availabilities?.length ? schedule.availabilities diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index 5af5207662d6a..568dfd8db4633 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -17,6 +17,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client"; import * as request from "supertest"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -75,10 +76,12 @@ describe("OAuth Client Users Endpoints", () => { let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; let teamRepositoryFixture: TeamRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let schedulesRepositoryFixture: SchedulesRepositoryFixture; let postResponseData: CreateUserResponse; const userEmail = "oauth-client-user@gmail.com"; + const userTimeZone = "Europe/Rome"; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -93,6 +96,7 @@ describe("OAuth Client Users Endpoints", () => { userRepositoryFixture = new UserRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); organization = await teamRepositoryFixture.create({ name: "organization" }); oAuthClient = await createOAuthClient(organization.id); @@ -134,6 +138,7 @@ describe("OAuth Client Users Endpoints", () => { it(`/POST`, async () => { const requestBody: CreateManagedUserInput = { email: userEmail, + timeZone: userTimeZone, }; const response = await request(app.getHttpServer()) @@ -158,6 +163,7 @@ describe("OAuth Client Users Endpoints", () => { await userConnectedToOAuth(responseBody.data.user.email); await userHasDefaultEventTypes(responseBody.data.user.id); + await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId); }); async function userConnectedToOAuth(userEmail: string) { @@ -181,6 +187,17 @@ describe("OAuth Client Users Endpoints", () => { ).toBeTruthy(); } + async function userHasDefaultSchedule(userId: number, scheduleId: number | null) { + expect(scheduleId).toBeDefined(); + expect(scheduleId).not.toBeNull(); + + const user = await userRepositoryFixture.get(userId); + expect(user?.defaultScheduleId).toEqual(scheduleId); + + const schedule = scheduleId ? await schedulesRepositoryFixture.getById(scheduleId) : null; + expect(schedule?.userId).toEqual(userId); + } + it(`/GET: return list of managed users`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`) diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 74a1b554ae48c..77a651b49adaa 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -1,4 +1,5 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; import { AuthModule } from "@/modules/auth/auth.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; @@ -25,6 +26,7 @@ import { Global, Module } from "@nestjs/common"; MembershipsModule, EventTypesModule, OrganizationsModule, + SchedulesModule, ], providers: [ OAuthClientRepository, diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index d4fd46fb5ea94..e04b52dcaecda 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -1,4 +1,5 @@ import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; @@ -14,7 +15,8 @@ export class OAuthClientUsersService { constructor( private readonly userRepository: UsersRepository, private readonly tokensRepository: TokensRepository, - private readonly eventTypesService: EventTypesService + private readonly eventTypesService: EventTypesService, + private readonly schedulesService: SchedulesService ) {} async createOauthClientUser( @@ -62,8 +64,14 @@ export class OAuthClientUsersService { oAuthClientId, user.id ); + await this.eventTypesService.createUserDefaultEventTypes(user.id); + if (body.timeZone) { + const defaultSchedule = await this.schedulesService.createUserDefaultSchedule(user.id, body.timeZone); + user.defaultScheduleId = defaultSchedule.id; + } + return { user, tokens: { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index b822b36de23a4..dabbc49a7d64b 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -773,105 +773,87 @@ ] } }, - "/v2/gcal/oauth/auth-url": { - "get": { - "operationId": "GcalController_redirect", - "parameters": [ - { - "name": "Authorization", - "required": true, - "in": "header", - "schema": { - "type": "string" + "/v2/schedules": { + "post": { + "operationId": "SchedulesController_createSchedule", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput" + } } } - ], + }, "responses": { - "200": { + "201": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcalAuthUrlOutput" + "$ref": "#/components/schemas/CreateScheduleOutput" } } } } }, "tags": [ - "Google Calendar" + "Schedules" ] - } - }, - "/v2/gcal/oauth/save": { + }, "get": { - "operationId": "GcalController_save", - "parameters": [ - { - "name": "state", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "code", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], + "operationId": "SchedulesController_getSchedules", + "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcalSaveRedirectOutput" + "$ref": "#/components/schemas/GetSchedulesOutput" } } } } }, "tags": [ - "Google Calendar" + "Schedules" ] } }, - "/v2/gcal/check": { + "/v2/schedules/default": { "get": { - "operationId": "GcalController_check", + "operationId": "SchedulesController_getDefaultSchedule", "parameters": [], "responses": { "200": { - "description": "", + "description": "Returns the default schedule", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcalCheckOutput" + "$ref": "#/components/schemas/GetDefaultScheduleOutput" } } } } }, "tags": [ - "Google Calendar" + "Schedules" ] } }, - "/v2/provider/{clientId}": { + "/v2/schedules/{scheduleId}": { "get": { - "operationId": "CalProviderController_verifyClientId", + "operationId": "SchedulesController_getSchedule", "parameters": [ { - "name": "clientId", + "name": "scheduleId", "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], @@ -881,23 +863,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderVerifyClientOutput" + "$ref": "#/components/schemas/GetScheduleOutput" } } } } }, "tags": [ - "Cal provider" + "Schedules" ] - } - }, - "/v2/provider/{clientId}/access-token": { - "get": { - "operationId": "CalProviderController_verifyAccessToken", + }, + "patch": { + "operationId": "SchedulesController_updateSchedule", "parameters": [ { - "name": "clientId", + "name": "scheduleId", "required": true, "in": "path", "schema": { @@ -905,44 +885,23 @@ } } ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderVerifyAccessTokenOutput" - } - } - } - } - }, - "tags": [ - "Cal provider" - ] - } - }, - "/v2/schedules": { - "post": { - "operationId": "SchedulesController_createSchedule", - "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateScheduleInput" + "$ref": "#/components/schemas/UpdateScheduleInput" } } } }, "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateScheduleOutput" + "$ref": "#/components/schemas/UpdateScheduleOutput" } } } @@ -952,16 +911,25 @@ "Schedules" ] }, - "get": { - "operationId": "SchedulesController_getSchedules", - "parameters": [], + "delete": { + "operationId": "SchedulesController_deleteSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetSchedulesOutput" + "$ref": "#/components/schemas/DeleteScheduleOutput" } } } @@ -972,37 +940,54 @@ ] } }, - "/v2/schedules/default": { + "/v2/gcal/oauth/auth-url": { "get": { - "operationId": "SchedulesController_getDefaultSchedule", - "parameters": [], + "operationId": "GcalController_redirect", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "Returns the default schedule", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetDefaultScheduleOutput" + "$ref": "#/components/schemas/GcalAuthUrlOutput" } } } } }, "tags": [ - "Schedules" + "Google Calendar" ] } }, - "/v2/schedules/{scheduleId}": { + "/v2/gcal/oauth/save": { "get": { - "operationId": "SchedulesController_getSchedule", + "operationId": "GcalController_save", "parameters": [ { - "name": "scheduleId", + "name": "state", "required": true, - "in": "path", + "in": "query", "schema": { - "type": "number" + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" } } ], @@ -1012,21 +997,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetScheduleOutput" + "$ref": "#/components/schemas/GcalSaveRedirectOutput" } } } } }, "tags": [ - "Schedules" + "Google Calendar" ] - }, - "patch": { - "operationId": "SchedulesController_updateSchedule", + } + }, + "/v2/gcal/check": { + "get": { + "operationId": "GcalController_check", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalCheckOutput" + } + } + } + } + }, + "tags": [ + "Google Calendar" + ] + } + }, + "/v2/provider/{clientId}": { + "get": { + "operationId": "CalProviderController_verifyClientId", "parameters": [ { - "name": "scheduleId", + "name": "clientId", "required": true, "in": "path", "schema": { @@ -1034,41 +1042,33 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput" - } - } - } - }, "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateScheduleOutput" + "$ref": "#/components/schemas/ProviderVerifyClientOutput" } } } } }, "tags": [ - "Schedules" + "Cal provider" ] - }, - "delete": { - "operationId": "SchedulesController_deleteSchedule", + } + }, + "/v2/provider/{clientId}/access-token": { + "get": { + "operationId": "CalProviderController_verifyAccessToken", "parameters": [ { - "name": "scheduleId", + "name": "clientId", "required": true, "in": "path", "schema": { - "type": "number" + "type": "string" } } ], @@ -1078,14 +1078,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteScheduleOutput" + "$ref": "#/components/schemas/ProviderVerifyAccessTokenOutput" } } } } }, "tags": [ - "Schedules" + "Cal provider" ] } }, @@ -2788,96 +2788,6 @@ "data" ] }, - "AuthUrlData": { - "type": "object", - "properties": { - "authUrl": { - "type": "string" - } - }, - "required": [ - "authUrl" - ] - }, - "GcalAuthUrlOutput": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - }, - "data": { - "$ref": "#/components/schemas/AuthUrlData" - } - }, - "required": [ - "status", - "data" - ] - }, - "GcalSaveRedirectOutput": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": [ - "url" - ] - }, - "GcalCheckOutput": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - } - }, - "required": [ - "status" - ] - }, - "ProviderVerifyClientOutput": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - } - }, - "required": [ - "status" - ] - }, - "ProviderVerifyAccessTokenOutput": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - } - }, - "required": [ - "status" - ] - }, "CreateAvailabilityInput": { "type": "object", "properties": { @@ -3347,6 +3257,96 @@ "status" ] }, + "AuthUrlData": { + "type": "object", + "properties": { + "authUrl": { + "type": "string" + } + }, + "required": [ + "authUrl" + ] + }, + "GcalAuthUrlOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/AuthUrlData" + } + }, + "required": [ + "status", + "data" + ] + }, + "GcalSaveRedirectOutput": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "GcalCheckOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "ProviderVerifyClientOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "ProviderVerifyAccessTokenOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, "MeOutput": { "type": "object", "properties": { From 0650898302af844cdcd30ab231c5a38e44cdb6cb Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Fri, 26 Apr 2024 14:58:06 +0400 Subject: [PATCH 7/8] feat: Add ratings to insights (#14687) --- apps/web/pages/insights/index.tsx | 10 + apps/web/public/static/locales/en/common.json | 9 + .../insights/components/BookingKPICards.tsx | 14 +- .../components/BookingStatusLineChart.tsx | 4 +- .../insights/components/FeedbackTable.tsx | 70 +++ .../components/HighestNoShowHostTable.tsx | 47 ++ .../components/HighestRatedMembersTable.tsx | 47 ++ .../components/LowestRatedMembersTable.tsx | 47 ++ .../components/RecentFeedbackTable.tsx | 44 ++ .../components/TotalBookingUsersTable.tsx | 3 +- .../components/TotalUserFeedbackTable.tsx | 54 ++ .../features/insights/components/index.ts | 4 + packages/features/insights/server/events.ts | 56 ++ .../features/insights/server/trpc-router.ts | 585 +++++++++++++++++- .../migration.sql | 34 + packages/prisma/schema.prisma | 41 +- packages/prisma/seed-insights.ts | 16 + 17 files changed, 1061 insertions(+), 24 deletions(-) create mode 100644 packages/features/insights/components/FeedbackTable.tsx create mode 100644 packages/features/insights/components/HighestNoShowHostTable.tsx create mode 100644 packages/features/insights/components/HighestRatedMembersTable.tsx create mode 100644 packages/features/insights/components/LowestRatedMembersTable.tsx create mode 100644 packages/features/insights/components/RecentFeedbackTable.tsx create mode 100644 packages/features/insights/components/TotalUserFeedbackTable.tsx create mode 100644 packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx index 64ea23aae0fa6..89194173000e8 100644 --- a/apps/web/pages/insights/index.tsx +++ b/apps/web/pages/insights/index.tsx @@ -7,6 +7,10 @@ import { LeastBookedTeamMembersTable, MostBookedTeamMembersTable, PopularEventsTable, + HighestNoShowHostTable, + RecentFeedbackTable, + HighestRatedMembersTable, + LowestRatedMembersTable, } from "@calcom/features/insights/components"; import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider"; import { Filters } from "@calcom/features/insights/filters"; @@ -90,6 +94,12 @@ export default function InsightsPage() { +
+ + + + +
{t("looking_for_more_insights")}{" "} { const categories: { title: string; - index: "created" | "completed" | "rescheduled" | "cancelled"; + index: "created" | "completed" | "rescheduled" | "cancelled" | "no_show" | "rating" | "csat"; }[] = [ { title: t("events_created"), @@ -57,6 +57,18 @@ export const BookingKPICards = () => { title: t("events_cancelled"), index: "cancelled", }, + { + title: t("event_ratings"), + index: "rating", + }, + { + title: t("event_no_show"), + index: "no_show", + }, + { + title: t("csat_score"), + index: "csat", + }, ]; if (isPending) { diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/BookingStatusLineChart.tsx index 6fa9285affdf7..216337cefeb58 100644 --- a/packages/features/insights/components/BookingStatusLineChart.tsx +++ b/packages/features/insights/components/BookingStatusLineChart.tsx @@ -59,9 +59,9 @@ export const BookingStatusLineChart = () => { diff --git a/packages/features/insights/components/FeedbackTable.tsx b/packages/features/insights/components/FeedbackTable.tsx new file mode 100644 index 0000000000000..ad1e18a833b51 --- /dev/null +++ b/packages/features/insights/components/FeedbackTable.tsx @@ -0,0 +1,70 @@ +import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react"; + +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { User } from "@calcom/prisma/client"; +import { Avatar, EmptyScreen, Button } from "@calcom/ui"; + +export const FeedbackTable = ({ + data, +}: { + data: + | { + userId: number | null; + user: Pick; + emailMd5?: string; + username?: string; + rating: number | null; + feedback: string | null; + }[] + | undefined; +}) => { + const { t } = useLocale(); + return ( + + + <> + {data && data?.length > 0 ? ( + data?.map((item) => ( + + + +

+ {item.user.name} +

+
+ + + {item.rating} + + + + + {item.feedback} + + +
+ )) + ) : ( + + {t("workflows")} + + } + /> + )} + +
+
+ ); +}; diff --git a/packages/features/insights/components/HighestNoShowHostTable.tsx b/packages/features/insights/components/HighestNoShowHostTable.tsx new file mode 100644 index 0000000000000..72443de16014a --- /dev/null +++ b/packages/features/insights/components/HighestNoShowHostTable.tsx @@ -0,0 +1,47 @@ +import { Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { CardInsights } from "./Card"; +import { LoadingInsight } from "./LoadingInsights"; +import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; + +export const HighestNoShowHostTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( + { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + isAll, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), + } + ); + + if (isPending) return ; + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return data && data.length > 0 ? ( + + {t("most_no_show_host")} + + + ) : ( + <> + ); +}; diff --git a/packages/features/insights/components/HighestRatedMembersTable.tsx b/packages/features/insights/components/HighestRatedMembersTable.tsx new file mode 100644 index 0000000000000..7a1ce1bae51cd --- /dev/null +++ b/packages/features/insights/components/HighestRatedMembersTable.tsx @@ -0,0 +1,47 @@ +import { Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { CardInsights } from "./Card"; +import { LoadingInsight } from "./LoadingInsights"; +import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; + +export const HighestRatedMembersTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery( + { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + isAll, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), + } + ); + + if (isPending) return ; + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return data && data.length > 0 ? ( + + {t("highest_rated_members")} + + + ) : ( + <> + ); +}; diff --git a/packages/features/insights/components/LowestRatedMembersTable.tsx b/packages/features/insights/components/LowestRatedMembersTable.tsx new file mode 100644 index 0000000000000..652b8d603b856 --- /dev/null +++ b/packages/features/insights/components/LowestRatedMembersTable.tsx @@ -0,0 +1,47 @@ +import { Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { CardInsights } from "./Card"; +import { LoadingInsight } from "./LoadingInsights"; +import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; + +export const LowestRatedMembersTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery( + { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + isAll, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), + } + ); + + if (isPending) return ; + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return data && data.length > 0 ? ( + + {t("lowest_rated_members")} + + + ) : ( + <> + ); +}; diff --git a/packages/features/insights/components/RecentFeedbackTable.tsx b/packages/features/insights/components/RecentFeedbackTable.tsx new file mode 100644 index 0000000000000..969267ef0e7d8 --- /dev/null +++ b/packages/features/insights/components/RecentFeedbackTable.tsx @@ -0,0 +1,44 @@ +import { Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { CardInsights } from "./Card"; +import { FeedbackTable } from "./FeedbackTable"; +import { LoadingInsight } from "./LoadingInsights"; + +export const RecentFeedbackTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter; + const [startDate, endDate] = dateRange; + + const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery( + { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + isAll, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll), + } + ); + + if (isPending) return ; + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return ( + + {t("recent_ratings")} + + + ); +}; diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx index d6564795ca8af..405cfb85906ab 100644 --- a/packages/features/insights/components/TotalBookingUsersTable.tsx +++ b/packages/features/insights/components/TotalBookingUsersTable.tsx @@ -1,5 +1,6 @@ import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { User } from "@calcom/prisma/client"; import { Avatar } from "@calcom/ui"; @@ -27,7 +28,7 @@ export const TotalBookingUsersTable = ({ diff --git a/packages/features/insights/components/TotalUserFeedbackTable.tsx b/packages/features/insights/components/TotalUserFeedbackTable.tsx new file mode 100644 index 0000000000000..25c8b56c5aeb6 --- /dev/null +++ b/packages/features/insights/components/TotalUserFeedbackTable.tsx @@ -0,0 +1,54 @@ +import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react"; + +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { User } from "@calcom/prisma/client"; +import { Avatar } from "@calcom/ui"; + +export const TotalUserFeedbackTable = ({ + data, +}: { + data: + | { + userId: number | null; + user: Pick; + emailMd5?: string; + count?: number; + averageRating?: number | null; + username?: string; + }[] + | undefined; +}) => { + return ( + + + <> + {data && + data?.length > 0 && + data?.map((item) => ( + + + +

+ {item.user.name} +

+
+ + + + {item.averageRating ? item.averageRating.toFixed(1) : item.count} + + + +
+ ))} + +
+
+ ); +}; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index af5e7aa673c8d..14489329270b9 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -4,3 +4,7 @@ export { BookingStatusLineChart } from "./BookingStatusLineChart"; export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; export { PopularEventsTable } from "./PopularEventsTable"; +export { RecentFeedbackTable } from "./RecentFeedbackTable"; +export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; +export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; +export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index fd6f040950d71..5ec6593a10e48 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -77,6 +77,18 @@ class EventsInsights { return result; }; + static getNoShowHostsInTimeRange = async ( + timeRange: ITimeRange, + where: Prisma.BookingTimeStatusWhereInput + ) => { + const result = await this.getBookingsInTimeRange(timeRange, { + ...where, + noShowHost: true, + }); + + return result; + }; + static getBaseBookingCountForEventStatus = async (where: Prisma.BookingTimeStatusWhereInput) => { const baseBookings = await prisma.bookingTimeStatus.count({ where, @@ -112,6 +124,47 @@ class EventsInsights { }); }; + static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { + return await prisma.bookingTimeStatus.aggregate({ + _avg: { + rating: true, + }, + where: { + ...whereConditional, + rating: { + not: null, // Exclude null ratings + }, + }, + }); + }; + + static getTotalNoShows = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { + return await prisma.bookingTimeStatus.count({ + where: { + ...whereConditional, + noShowHost: true, + }, + }); + }; + + static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => { + const result = await prisma.bookingTimeStatus.findMany({ + where: { + ...whereConditional, + rating: { + not: null, + }, + }, + select: { rating: true }, + }); + + const totalResponses = result.length; + const satisfactoryResponses = result.filter((item) => item.rating && item.rating > 3).length; + const csat = totalResponses > 0 ? (satisfactoryResponses / totalResponses) * 100 : 0; + + return csat; + }; + static getTimeLine = async (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => { let resultTimeLine: string[] = []; @@ -239,6 +292,9 @@ class EventsInsights { paid: true, userEmail: true, username: true, + rating: true, + ratingFeedback: true, + noShowHost: true, }, where: whereConditional, }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index a13daf4175d6e..9650e06464527 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -112,6 +112,18 @@ const emptyResponseEventsByStatus = { count: 0, deltaPrevious: 0, }, + rating: { + count: 0, + deltaPrevious: 0, + }, + no_show: { + count: 0, + deltaPrevious: 0, + }, + csat: { + count: 0, + deltaPrevious: 0, + }, previousRange: { startDate: dayjs().toISOString(), endDate: dayjs().toISOString(), @@ -257,6 +269,14 @@ export const insightsRouter = router({ const totalCancelled = await EventsInsights.getTotalCancelledEvents(baseWhereCondition); + const totalRatingsAggregate = await EventsInsights.getAverageRating(baseWhereCondition); + const averageRating = totalRatingsAggregate._avg.rating + ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1)) + : 0; + + const totalNoShow = await EventsInsights.getTotalNoShows(baseWhereCondition); + const totalCSAT = await EventsInsights.getTotalCSAT(baseWhereCondition); + const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day"); const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day"); @@ -278,6 +298,14 @@ export const insightsRouter = router({ ); const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseCondition); + const lastPeriodTotalRatingsAggregate = await EventsInsights.getAverageRating(lastPeriodBaseCondition); + const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating + ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1)) + : 0; + + const lastPeriodTotalNoShow = await EventsInsights.getTotalNoShows(lastPeriodBaseCondition); + const lastPeriodTotalCSAT = await EventsInsights.getTotalCSAT(lastPeriodBaseCondition); + const result = { empty: false, created: { @@ -299,6 +327,18 @@ export const insightsRouter = router({ count: totalCancelled, deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled), }, + no_show: { + count: totalNoShow, + deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow), + }, + rating: { + count: averageRating, + deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating), + }, + csat: { + count: totalCSAT, + deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT), + }, previousRange: { startDate: lastPeriodStartDate.format("YYYY-MM-DD"), endDate: lastPeriodEndDate.format("YYYY-MM-DD"), @@ -308,7 +348,9 @@ export const insightsRouter = router({ result.created.count === 0 && result.completed.count === 0 && result.rescheduled.count === 0 && - result.cancelled.count === 0 + result.cancelled.count === 0 && + result.no_show.count === 0 && + result.rating.count === 0 ) { return emptyResponseEventsByStatus; } @@ -474,6 +516,7 @@ export const insightsRouter = router({ Completed: 0, Rescheduled: 0, Cancelled: 0, + "No-Show (Host)": 0, }; const startOfEndOf = timeView; let startDate = dayjs(date).startOf(startOfEndOf); @@ -511,11 +554,19 @@ export const insightsRouter = router({ }, whereConditional ), + EventsInsights.getNoShowHostsInTimeRange( + { + start: startDate, + end: endDate, + }, + whereConditional + ), ]); EventData["Created"] = promisesResult[0]; EventData["Completed"] = promisesResult[1]; EventData["Rescheduled"] = promisesResult[2]; EventData["Cancelled"] = promisesResult[3]; + EventData["No-Show (Host)"] = promisesResult[4]; result.push(EventData); } @@ -1437,6 +1488,538 @@ export const insightsRouter = router({ return eventTypeResult; }), + recentRatings: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable().optional(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + isAll: z.boolean().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, isAll } = input; + if (!teamId) { + return []; + } + const user = ctx.user; + + const bookingWhere: Prisma.BookingTimeStatusWhereInput = { + teamId, + eventTypeId, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + ratingFeedback: { not: null }, + }; + + if (isAll && user.isOwnerAdminOfParentTeam) { + delete bookingWhere.teamId; + const teamsFromOrg = await ctx.insightsDb.team.findMany({ + where: { + parentId: user?.organizationId, + }, + select: { + id: true, + }, + }); + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + accepted: true, + }, + select: { + userId: true, + }, + }); + + bookingWhere["OR"] = [ + { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + }, + { + userId: { + in: usersFromTeam.map((u) => u.userId), + }, + teamId: null, + }, + ]; + } + + if (teamId && !isAll) { + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromTeams = usersFromTeam.map((u) => u.userId); + bookingWhere["OR"] = [ + { + teamId, + }, + { + userId: { + in: userIdsFromTeams, + }, + teamId: null, + }, + ]; + } + const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({ + where: bookingWhere, + orderBy: { + endTime: "desc", + }, + select: { + userId: true, + rating: true, + ratingFeedback: true, + }, + take: 10, + }); + + const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { + if (!!booking.userId && !userIds.includes(booking.userId)) { + userIds.push(booking.userId); + } + return userIds; + }, []); + + if (userIds.length === 0) { + return []; + } + const usersFromTeam = await ctx.insightsDb.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: userSelect, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam.map((booking) => ({ + userId: booking.userId, + // We know with 100% certainty that userHashMap.get(...) will retrieve a user + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: userHashMap.get(booking.userId)!, + emailMd5: md5(user?.email), + rating: booking.rating, + feedback: booking.ratingFeedback, + })); + + return result; + }), + membersWithMostNoShow: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable().optional(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + isAll: z.boolean().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, isAll } = input; + if (!teamId) { + return []; + } + const user = ctx.user; + + const bookingWhere: Prisma.BookingTimeStatusWhereInput = { + teamId, + eventTypeId, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + noShowHost: true, + }; + + if (isAll && user.isOwnerAdminOfParentTeam) { + delete bookingWhere.teamId; + const teamsFromOrg = await ctx.insightsDb.team.findMany({ + where: { + parentId: user?.organizationId, + }, + select: { + id: true, + }, + }); + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + accepted: true, + }, + select: { + userId: true, + }, + }); + + bookingWhere["OR"] = [ + { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + }, + { + userId: { + in: usersFromTeam.map((u) => u.userId), + }, + teamId: null, + }, + ]; + } + + if (teamId && !isAll) { + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromTeams = usersFromTeam.map((u) => u.userId); + bookingWhere["OR"] = [ + { + teamId, + }, + { + userId: { + in: userIdsFromTeams, + }, + teamId: null, + }, + ]; + } + const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({ + by: ["userId"], + where: bookingWhere, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "asc", + }, + }, + take: 10, + }); + + const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { + if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { + userIds.push(booking.userId); + } + return userIds; + }, []); + + if (userIds.length === 0) { + return []; + } + const usersFromTeam = await ctx.insightsDb.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: userSelect, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam.map((booking) => ({ + userId: booking.userId, + // We know with 100% certainty that userHashMap.get(...) will retrieve a user + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: userHashMap.get(booking.userId)!, + emailMd5: md5(user?.email), + count: booking._count.id, + })); + + return result; + }), + membersWithHighestRatings: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable().optional(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + isAll: z.boolean().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, isAll } = input; + if (!teamId) { + return []; + } + const user = ctx.user; + + const bookingWhere: Prisma.BookingTimeStatusWhereInput = { + teamId, + eventTypeId, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + rating: { not: null }, + }; + + if (isAll && user.isOwnerAdminOfParentTeam) { + delete bookingWhere.teamId; + const teamsFromOrg = await ctx.insightsDb.team.findMany({ + where: { + parentId: user?.organizationId, + }, + select: { + id: true, + }, + }); + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + accepted: true, + }, + select: { + userId: true, + }, + }); + + bookingWhere["OR"] = [ + { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + }, + { + userId: { + in: usersFromTeam.map((u) => u.userId), + }, + teamId: null, + }, + ]; + } + + if (teamId && !isAll) { + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromTeams = usersFromTeam.map((u) => u.userId); + bookingWhere["OR"] = [ + { + teamId, + }, + { + userId: { + in: userIdsFromTeams, + }, + teamId: null, + }, + ]; + } + const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({ + by: ["userId"], + where: bookingWhere, + _avg: { + rating: true, + }, + orderBy: { + _avg: { + rating: "desc", + }, + }, + take: 10, + }); + + const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { + if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { + userIds.push(booking.userId); + } + return userIds; + }, []); + + if (userIds.length === 0) { + return []; + } + const usersFromTeam = await ctx.insightsDb.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: userSelect, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam.map((booking) => ({ + userId: booking.userId, + // We know with 100% certainty that userHashMap.get(...) will retrieve a user + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: userHashMap.get(booking.userId)!, + emailMd5: md5(user?.email), + averageRating: booking._avg.rating, + })); + + return result; + }), + membersWithLowestRatings: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable().optional(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + isAll: z.boolean().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, isAll } = input; + if (!teamId) { + return []; + } + const user = ctx.user; + + const bookingWhere: Prisma.BookingTimeStatusWhereInput = { + teamId, + eventTypeId, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + rating: { not: null }, + }; + + if (isAll && user.isOwnerAdminOfParentTeam) { + delete bookingWhere.teamId; + const teamsFromOrg = await ctx.insightsDb.team.findMany({ + where: { + parentId: user?.organizationId, + }, + select: { + id: true, + }, + }); + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + accepted: true, + }, + select: { + userId: true, + }, + }); + + bookingWhere["OR"] = [ + { + teamId: { + in: teamsFromOrg.map((t) => t.id), + }, + }, + { + userId: { + in: usersFromTeam.map((u) => u.userId), + }, + teamId: null, + }, + ]; + } + + if (teamId && !isAll) { + const usersFromTeam = await ctx.insightsDb.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + userId: true, + }, + }); + const userIdsFromTeams = usersFromTeam.map((u) => u.userId); + bookingWhere["OR"] = [ + { + teamId, + }, + { + userId: { + in: userIdsFromTeams, + }, + teamId: null, + }, + ]; + } + const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({ + by: ["userId"], + where: bookingWhere, + _avg: { + rating: true, + }, + orderBy: { + _avg: { + rating: "asc", + }, + }, + take: 10, + }); + + const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { + if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { + userIds.push(booking.userId); + } + return userIds; + }, []); + + if (userIds.length === 0) { + return []; + } + const usersFromTeam = await ctx.insightsDb.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: userSelect, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam.map((booking) => ({ + userId: booking.userId, + // We know with 100% certainty that userHashMap.get(...) will retrieve a user + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: userHashMap.get(booking.userId)!, + emailMd5: md5(user?.email), + averageRating: booking._avg.rating, + })); + + return result; + }), rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { const { startDate, endDate, teamId, userId, memberUserId, isAll, eventTypeId } = input; diff --git a/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql new file mode 100644 index 0000000000000..71ccaec2c4b86 --- /dev/null +++ b/packages/prisma/migrations/20240419114622_add_ratings_to_insights/migration.sql @@ -0,0 +1,34 @@ +CREATE OR REPLACE VIEW public."BookingTimeStatus" +AS +SELECT + "Booking".id, + "Booking".uid, + "Booking"."eventTypeId", + "Booking".title, + "Booking".description, + "Booking"."startTime", + "Booking"."endTime", + "Booking"."createdAt", + "Booking".location, + "Booking".paid, + "Booking".status, + "Booking".rescheduled, + "Booking"."userId", + et."teamId", + et.length AS "eventLength", + CASE + WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text + WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text + WHEN "Booking"."endTime" < now() THEN 'completed'::text + WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text + ELSE NULL::text + END AS "timeStatus", + et."parentId" AS "eventParentId", + "u"."email" AS "userEmail", + "u"."username" AS "username", + "Booking"."ratingFeedback", + "Booking"."rating", + "Booking"."noShowHost" +FROM "Booking" +LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id +LEFT JOIN users u ON u.id = "Booking"."userId"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 58773c0e996ca..bf7b424767a1f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1123,25 +1123,28 @@ enum AccessScope { } view BookingTimeStatus { - id Int @unique - uid String? - eventTypeId Int? - title String? - description String? - startTime DateTime? - endTime DateTime? - createdAt DateTime? - location String? - paid Boolean? - status BookingStatus? - rescheduled Boolean? - userId Int? - teamId Int? - eventLength Int? - timeStatus String? - eventParentId Int? - userEmail String? - username String? + id Int @unique + uid String? + eventTypeId Int? + title String? + description String? + startTime DateTime? + endTime DateTime? + createdAt DateTime? + location String? + paid Boolean? + status BookingStatus? + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + timeStatus String? + eventParentId Int? + userEmail String? + username String? + ratingFeedback String? + rating Int? + noShowHost Boolean? } model CalendarCache { diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts index 7f4ad145299fd..0a32a8c6ed67e 100644 --- a/packages/prisma/seed-insights.ts +++ b/packages/prisma/seed-insights.ts @@ -6,6 +6,18 @@ import dayjs from "@calcom/dayjs"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { BookingStatus } from "@calcom/prisma/enums"; +function getRandomRatingFeedback() { + const feedbacks = [ + "Great chat!", + "Okay-ish", + "Quite Poor", + "Excellent chat!", + "Could be better", + "Wonderful!", + ]; + return feedbacks[Math.floor(Math.random() * feedbacks.length)]; +} + const shuffle = ( booking: any, year: number, @@ -62,6 +74,10 @@ const shuffle = ( console.log("This should not happen"); } + booking.rating = Math.floor(Math.random() * 5) + 1; // Generates a random rating from 1 to 5 + booking.ratingFeedback = getRandomRatingFeedback(); // Random feedback from a predefined list + booking.noShowHost = Math.random() < 0.5; + return booking; }; From c28870e0297e2867c7d497491003102f34ba534c Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 26 Apr 2024 13:01:50 +0200 Subject: [PATCH 8/8] refactor: booker atom unmount reset duration query param (#14766) --- packages/platform/atoms/booker/BookerPlatformWrapper.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 3fb9a3a065c37..73b2e5a97f831 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -57,6 +57,7 @@ type BookerPlatformWrapperAtomProps = Omit & { export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => { const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const setSelectedDate = useBookerStore((state) => state.setSelectedDate); + const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration); const setBookingData = useBookerStore((state) => state.setBookingData); const bookingData = useBookerStore((state) => state.bookingData); @@ -76,6 +77,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => setSelectedDate(null); setSelectedTimeslot(null); setSelectedMonth(null); + setSelectedDuration(null); }; }, []);