diff --git a/.env.example b/.env.example index 17f4d19cc42270..a01ef6cc4d5def 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,6 @@ DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" # Needed to run migrations while using a connection pooler like PgBouncer # Use the same one as DATABASE_URL if you're not using a connection pooler DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso" -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= INSIGHTS_DATABASE_URL= # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy @@ -171,8 +169,7 @@ STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET_APPS= STRIPE_PRIVATE_KEY= STRIPE_CLIENT_ID= -PAYMENT_FEE_FIXED= -PAYMENT_FEE_PERCENTAGE= + # Use for internal Public API Keys and optional API_KEY_PREFIX=cal_ @@ -215,6 +212,10 @@ E2E_TEST_MAILHOG_ENABLED= # ********************************************************************************************************** +# Cloudflare Turnstile +NEXT_PUBLIC_CLOUDFLARE_SITEKEY= +CLOUDFLARE_TURNSTILE_SECRET= + # Set the following value to true if you wish to enable Team Impersonation NEXT_PUBLIC_TEAM_IMPERSONATION=false @@ -232,6 +233,7 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc." # NEXT_PUBLIC_DISABLE_SIGNUP=true NEXT_PUBLIC_DISABLE_SIGNUP= +# Set this to 'non-strict' to enable CSP for support pages. 'strict' isn't supported yet. Also, check the README for details. # Content Security Policy CSP_POLICY= @@ -296,7 +298,7 @@ E2E_TEST_CALCOM_GCAL_KEYS= # You can use: `openssl rand -base64 32` to generate one 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_SECRET_HEADER_NAME="calcom-webhook-secret" +CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret" CALCOM_CREDENTIAL_SYNC_ENDPOINT="" # Key should match on Cal.com and your application # must be 24 bytes for AES256 encryption algorithm @@ -341,3 +343,14 @@ APP_ROUTER_TEAMS_ENABLED=0 # disable setry server source maps SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 + +# api v2 +NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2" + +# Ratelimiting via unkey +UNKEY_ROOT_KEY= + + +# Used for Cal.ai Enterprise Voice AI Agents +# https://retellai.com +RETELL_AI_KEY= diff --git a/.eslintignore b/.eslintignore index 5889b2527da1e4..219d7c2615901c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,4 @@ node_modules **/**/public packages/prisma/zod apps/web/public/embed +packages/ui/components/icon/dynamicIconImports.tsx diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8691d730571fa8..c51c8c7c52f93a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,7 +13,7 @@ Fixes # (issue) - If there is a requirement document, please, share it here. -- If there is ab UI/UX design document, please, share it here. +- If there is a UI/UX design document, please, share it here. ## Type of change diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml index f3fca58f9ad657..6931d193e2ae31 100644 --- a/.github/actions/cache-db/action.yml +++ b/.github/actions/cache-db/action.yml @@ -3,7 +3,7 @@ description: "Cache or restore if necessary" inputs: DATABASE_URL: required: false - default: "postgresql://postgres:@localhost:5432/calendso" + default: "postgresql://postgres:postgres@localhost:5432/calendso" path: required: false default: "backups/backup.sql" diff --git a/.github/workflows/api-v1-production-build.yml b/.github/workflows/api-v1-production-build.yml new file mode 100644 index 00000000000000..19786d19ba69d2 --- /dev/null +++ b/.github/workflows/api-v1-production-build.yml @@ -0,0 +1,69 @@ +name: Production Build + +on: + workflow_call: + +env: + ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }} + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} + DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }} + E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }} + E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }} + E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }} + E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }} + E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} + E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }} + GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }} + NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }} + NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }} + NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }} + NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} + NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }} + NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }} + PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }} + PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }} + SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }} + SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }} + SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }} + NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }} + +jobs: + build: + name: Build API V1 + runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 30 + services: + postgres: + image: postgres:13 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/dangerous-git-checkout + - uses: ./.github/actions/yarn-install + - uses: ./.github/actions/cache-db + - run: | + export NODE_OPTIONS="--max_old_space_size=8192" + yarn turbo run build --filter=@calcom/api... + shell: bash diff --git a/.github/workflows/cron-changeTimeZone.yml b/.github/workflows/cron-changeTimeZone.yml new file mode 100644 index 00000000000000..5a988dc9f4f806 --- /dev/null +++ b/.github/workflows/cron-changeTimeZone.yml @@ -0,0 +1,24 @@ +name: Cron - changeTimeZone + +on: + # "Scheduled workflows run on the latest commit on the default or base branch." + # — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule + schedule: + # Runs "At every full hour." (see https://crontab.guru) + - cron: "0 * * * *" + +jobs: + cron-scheduleEmailReminders: + env: + APP_URL: ${{ secrets.APP_URL }} + CRON_API_KEY: ${{ secrets.CRON_API_KEY }} + runs-on: ubuntu-latest + steps: + - name: cURL request + if: ${{ env.APP_URL && env.CRON_API_KEY }} + run: | + curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \ + -X POST \ + -H 'content-type: application/json' \ + -H 'authorization: ${{ secrets.CRON_API_KEY }}' \ + -sSf diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index a6858a2c0edf7f..c652a81c167026 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -10,13 +10,19 @@ jobs: runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + image: postgres:13 credentials: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} env: POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 ports: - 5432:5432 mailhog: diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index 7f71901a366e98..6a1c9f359abc68 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -10,13 +10,19 @@ jobs: runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + image: postgres:13 credentials: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} env: POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 ports: - 5432:5432 strategy: diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 1fd3232b0e4c8f..7c757aec1985f6 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -10,13 +10,19 @@ jobs: runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + image: postgres:13 credentials: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} env: POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 ports: - 5432:5432 mailhog: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b9e9632a711090..c4057f8ad0e9b0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,13 +10,19 @@ jobs: runs-on: buildjet-4vcpu-ubuntu-2204 services: postgres: - image: postgres:12.1 + image: postgres:13 credentials: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} env: POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 ports: - 5432:5432 mailhog: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 69d08f19d07d97..f4de65e6fe1eb3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -56,6 +56,13 @@ jobs: uses: ./.github/workflows/production-build.yml secrets: inherit + build-api-v1: + name: Production build + needs: [changes] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + uses: ./.github/workflows/api-v1-production-build.yml + secrets: inherit + build-without-database: name: Production build (without database) needs: [changes] @@ -99,7 +106,7 @@ jobs: secrets: inherit required: - needs: [changes, lint, type-check, test, build, e2e, e2e-embed, e2e-embed-react, e2e-app-store] + needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store] if: always() runs-on: buildjet-4vcpu-ubuntu-2204 steps: diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml index 9e1b97e77c465f..2542c1c6809e95 100644 --- a/.github/workflows/production-build-without-database.yml +++ b/.github/workflows/production-build-without-database.yml @@ -29,6 +29,9 @@ env: SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }} + NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }} + jobs: build: diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml index bec534a204c329..061ebcf8ca1cad 100644 --- a/.github/workflows/production-build.yml +++ b/.github/workflows/production-build.yml @@ -33,6 +33,9 @@ env: SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }} + NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }} + jobs: build: @@ -41,13 +44,19 @@ jobs: timeout-minutes: 30 services: postgres: - image: postgres:12.1 + image: postgres:13 credentials: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} env: POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres POSTGRES_DB: calendso + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 ports: - 5432:5432 steps: diff --git a/.prettierignore b/.prettierignore index 7944833941fa19..76b9ebc554d5b3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,3 +16,5 @@ public packages/prisma/zod packages/prisma/enums apps/web/public/embed +apps/api/v2/swagger/documentation.json +packages/ui/components/icon/dynamicIconImports.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb52da8d6ff687..7eac25c49301ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,8 +13,6 @@ Contributions are what makes the open source community such an amazing place to - Issues from non-core members automatically receive the `🚨 needs approval` label. - We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval. - - ## Priorities @@ -178,11 +176,15 @@ If you get errors, be sure to fix them before committing. Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo: If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`: + ```bash git checkout HEAD~1 yarn.lock git commit -m "Revert yarn.lock changes" ``` + +_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit If you've pushed the commit with the `yarn.lock`: + 1. Correct the commit locally using the above method. 2. Carefully force push: @@ -194,26 +196,35 @@ If `yarn.lock` was committed a while ago and there have been several commits sin 1. **Checkout a Previous Version**: - Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log: + ```bash git log yarn.lock ``` + - Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`: + ```bash git checkout yarn.lock ``` 2. **Commit the Reverted Version**: - After checking out the previous version of the `yarn.lock`, commit this change: + ```bash git commit -m "Revert yarn.lock to its state before unintended changes" ``` 3. **Proceed with Caution**: - If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes: + ```bash git pull origin ``` + - Then push the updated branch: + ```bash git push origin ``` + +Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR). diff --git a/LICENSE b/LICENSE index 886cdc684f7ac7..6520d20afc0bc1 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,8 @@ Copyright (c) 2020-present Cal.com, Inc. Portions of this software are licensed as follows: -* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee and https://github.com/calcom/cal.com/tree/main/apps/api/ directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE". +* All content that resides under https://github.com/calcom/cal.com/tree/main/packages/features/ee and +https://github.com/calcom/cal.com/tree/main/apps/api/v2/src/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE". * All third party components incorporated into the Cal.com Software are licensed under the original license provided by the owner of the applicable component. * Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below. diff --git a/README.md b/README.md index 97df0d902de5c4..d4e85ca9e7df2f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ To get a local copy up and running, please follow these simple steps. Here is what you need to be able to run Cal.com. - Node.js (Version: >=18.x) -- PostgreSQL +- PostgreSQL (Version: >=13.x) - Yarn _(recommended)_ > If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations). @@ -210,7 +210,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env ![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png) 5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this - `postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. + `postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. The port is configurable and does not have to be 5432. diff --git a/__checks__/organization.spec.ts b/__checks__/organization.spec.ts index e510f535827105..aebf4bd3e413ca 100644 --- a/__checks__/organization.spec.ts +++ b/__checks__/organization.spec.ts @@ -56,7 +56,7 @@ test.describe("Org", () => { ]; const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`); expect(response?.status()).toBe(200); - expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Dynamic"); + expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Group Meeting"); expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[0].name); expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[1].name); diff --git a/apps/api/index.js b/apps/api/index.js new file mode 100644 index 00000000000000..a7e4c44ce3e501 --- /dev/null +++ b/apps/api/index.js @@ -0,0 +1,18 @@ +const http = require("http"); +const connect = require("connect"); +const { createProxyMiddleware } = require("http-proxy-middleware"); + +const apiProxyV1 = createProxyMiddleware({ + target: "http://localhost:3003", +}); + +const apiProxyV2 = createProxyMiddleware({ + target: "http://localhost:3004", +}); + +const app = connect(); +app.use("/", apiProxyV1); + +app.use("/v2", apiProxyV2); + +http.createServer(app).listen(3002); diff --git a/apps/api/lib/helpers/customPrisma.ts b/apps/api/lib/helpers/customPrisma.ts deleted file mode 100644 index 926c7979cf1f04..00000000000000 --- a/apps/api/lib/helpers/customPrisma.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NextMiddleware } from "next-api-middleware"; - -import { CONSOLE_URL } from "@calcom/lib/constants"; -import { customPrisma } from "@calcom/prisma"; - -const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL; - -// This replaces the prisma client for the custom one if the key is valid -export const customPrismaClient: NextMiddleware = async (req, res, next) => { - const { - query: { key }, - } = req; - // If no custom api Id is provided, attach to request the regular cal.com prisma client. - if (!key) { - req.prisma = customPrisma(); - await next(); - return; - } - - // If we have a key, we check if the deployment matching the key, has a databaseUrl value set. - const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`) - .then((res) => res.json()) - .then((res) => res.databaseUrl); - - if (!databaseUrl) { - res.status(400).json({ error: "no databaseUrl set up at your instance yet" }); - return; - } - req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } }); - /* @note: - In order to skip verifyApiKey for customPrisma requests, - we pass isAdmin true, and userId 0, if we detect them later, - we skip verifyApiKey logic and pass onto next middleware instead. - */ - req.isAdmin = true; - req.isCustomPrisma = true; - // We don't need the key from here and on. Prevents unrecognized key errors. - delete req.query.key; - await next(); - await req.prisma.$disconnect(); - // @ts-expect-error testing - delete req.prisma; -}; diff --git a/apps/api/package.json b/apps/api/package.json index 75040b614c8b79..252bd4f1772b8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,46 +1,16 @@ { - "name": "@calcom/api", + "name": "@calcom/api-proxy", "version": "1.0.0", - "description": "Public API for Cal.com", - "main": "index.ts", - "repository": "git@github.com:calcom/api.git", - "author": "Cal.com Inc.", - "private": true, + "description": "", + "main": "index.js", "scripts": { - "build": "next build", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", - "dev": "PORT=3002 next dev", - "lint": "eslint . --ignore-path .gitignore", - "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", - "start": "PORT=3002 next start", - "docker-start-api": "PORT=80 next start", - "type-check": "tsc --pretty --noEmit" - }, - "devDependencies": { - "@calcom/tsconfig": "*", - "@calcom/types": "*", - "node-mocks-http": "^1.11.0" + "dev": "node ./index.js" }, + "author": "", + "license": "ISC", "dependencies": { - "@calcom/app-store": "*", - "@calcom/core": "*", - "@calcom/dayjs": "*", - "@calcom/emails": "*", - "@calcom/features": "*", - "@calcom/lib": "*", - "@calcom/prisma": "*", - "@calcom/trpc": "*", - "@sentry/nextjs": "^7.73.0", - "bcryptjs": "^2.4.3", - "memory-cache": "^0.2.0", - "next": "^13.5.4", - "next-api-middleware": "^1.0.1", - "next-axiom": "^0.17.0", - "next-swagger-doc": "^0.3.6", - "next-validations": "^0.2.0", - "typescript": "^4.9.4", - "tzdata": "^1.0.30", - "uuid": "^8.3.2", - "zod": "^3.22.4" + "connect": "^3.7.0", + "http": "^0.0.1-security", + "http-proxy-middleware": "^2.0.6" } } diff --git a/apps/api/pages/api/event-types/[id]/_auth-middleware.ts b/apps/api/pages/api/event-types/[id]/_auth-middleware.ts deleted file mode 100644 index 8907d7cd3b54a6..00000000000000 --- a/apps/api/pages/api/event-types/[id]/_auth-middleware.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { HttpError } from "@calcom/lib/http-error"; - -import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; - -async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; - const { id } = schemaQueryIdParseInt.parse(req.query); - if (isAdmin) return; - const eventType = await prisma.eventType.findFirst({ - where: { id, users: { some: { id: userId } } }, - }); - if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" }); -} - -export default authMiddleware; diff --git a/apps/api/test/lib/middleware/withMiddleware.test.ts b/apps/api/test/lib/middleware/withMiddleware.test.ts deleted file mode 100644 index d8771e9719e1e7..00000000000000 --- a/apps/api/test/lib/middleware/withMiddleware.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, vi, it, expect, afterEach } from "vitest"; - -import { middlewareOrder } from "../../../lib/helpers/withMiddleware"; - -afterEach(() => { - vi.resetAllMocks(); -}); - -// Not sure if there is much point testing this order is actually applied via an integration test: -// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62 -describe("API - withMiddleware test", () => { - it("Custom prisma should be before verifyApiKey", async () => { - const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient"); - const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey"); - expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex); - }); -}); diff --git a/apps/api/.env.example b/apps/api/v1/.env.example similarity index 78% rename from apps/api/.env.example rename to apps/api/v1/.env.example index c5354bfd7da9ce..37b43dd55744bf 100644 --- a/apps/api/.env.example +++ b/apps/api/v1/.env.example @@ -4,3 +4,4 @@ NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 # Get it in console.cal.com CALCOM_LICENSE_KEY="" +NEXT_PUBLIC_API_V2_ROOT_URL=http://localhost:5555 \ No newline at end of file diff --git a/apps/api/.gitignore b/apps/api/v1/.gitignore similarity index 100% rename from apps/api/.gitignore rename to apps/api/v1/.gitignore diff --git a/apps/api/.gitkeep b/apps/api/v1/.gitkeep similarity index 100% rename from apps/api/.gitkeep rename to apps/api/v1/.gitkeep diff --git a/apps/api/.prettierignore b/apps/api/v1/.prettierignore similarity index 100% rename from apps/api/.prettierignore rename to apps/api/v1/.prettierignore diff --git a/apps/api/LICENSE b/apps/api/v1/LICENSE similarity index 100% rename from apps/api/LICENSE rename to apps/api/v1/LICENSE diff --git a/apps/api/README.md b/apps/api/v1/README.md similarity index 100% rename from apps/api/README.md rename to apps/api/v1/README.md diff --git a/apps/api/lib/constants.ts b/apps/api/v1/lib/constants.ts similarity index 100% rename from apps/api/lib/constants.ts rename to apps/api/v1/lib/constants.ts diff --git a/apps/api/lib/helpers/addRequestid.ts b/apps/api/v1/lib/helpers/addRequestid.ts similarity index 100% rename from apps/api/lib/helpers/addRequestid.ts rename to apps/api/v1/lib/helpers/addRequestid.ts diff --git a/apps/api/lib/helpers/captureErrors.ts b/apps/api/v1/lib/helpers/captureErrors.ts similarity index 100% rename from apps/api/lib/helpers/captureErrors.ts rename to apps/api/v1/lib/helpers/captureErrors.ts diff --git a/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts b/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts new file mode 100644 index 00000000000000..8f2f0879b32be3 --- /dev/null +++ b/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts @@ -0,0 +1,23 @@ +import { get } from "@vercel/edge-config"; +import type { NextMiddleware } from "next-api-middleware"; + +const safeGet = async (key: string): Promise => { + try { + return get(key); + } catch (error) { + // Don't crash if EDGE_CONFIG env var is missing + } +}; + +export const config = { matcher: "/:path*" }; + +export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => { + const isInMaintenanceMode = await safeGet("isInMaintenanceMode"); + if (isInMaintenanceMode) { + return res + .status(503) + .json({ message: "API is currently under maintenance. Please try again at a later time." }); + } + + await next(); +}; diff --git a/apps/api/lib/helpers/extendRequest.ts b/apps/api/v1/lib/helpers/extendRequest.ts similarity index 100% rename from apps/api/lib/helpers/extendRequest.ts rename to apps/api/v1/lib/helpers/extendRequest.ts diff --git a/apps/api/lib/helpers/httpMethods.ts b/apps/api/v1/lib/helpers/httpMethods.ts similarity index 100% rename from apps/api/lib/helpers/httpMethods.ts rename to apps/api/v1/lib/helpers/httpMethods.ts diff --git a/apps/api/lib/helpers/rateLimitApiKey.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.ts similarity index 84% rename from apps/api/lib/helpers/rateLimitApiKey.ts rename to apps/api/v1/lib/helpers/rateLimitApiKey.ts index de2c71f4e25be3..8a2f6b6e91d149 100644 --- a/apps/api/lib/helpers/rateLimitApiKey.ts +++ b/apps/api/v1/lib/helpers/rateLimitApiKey.ts @@ -1,7 +1,6 @@ import type { NextMiddleware } from "next-api-middleware"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { API_KEY_RATE_LIMIT } from "@calcom/lib/rateLimit"; export const rateLimitApiKey: NextMiddleware = async (req, res, next) => { if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); @@ -11,7 +10,7 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => { identifier: req.query.apiKey as string, rateLimitingType: "api", onRateLimiterResponse: (response) => { - res.setHeader("X-RateLimit-Limit", API_KEY_RATE_LIMIT); + res.setHeader("X-RateLimit-Limit", response.limit); res.setHeader("X-RateLimit-Remaining", response.remaining); res.setHeader("X-RateLimit-Reset", response.reset); }, diff --git a/apps/api/lib/helpers/safeParseJSON.ts b/apps/api/v1/lib/helpers/safeParseJSON.ts similarity index 100% rename from apps/api/lib/helpers/safeParseJSON.ts rename to apps/api/v1/lib/helpers/safeParseJSON.ts diff --git a/apps/api/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts similarity index 89% rename from apps/api/lib/helpers/verifyApiKey.ts rename to apps/api/v1/lib/helpers/verifyApiKey.ts index 85bb98ad7c3ef1..8b8114888ed4e6 100644 --- a/apps/api/lib/helpers/verifyApiKey.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.ts @@ -3,6 +3,7 @@ import type { NextMiddleware } from "next-api-middleware"; import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import { IS_PRODUCTION } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; import { isAdminGuard } from "../utils/isAdmin"; @@ -16,15 +17,9 @@ export const dateNotInPast = function (date: Date) { // This verifies the apiKey and sets the user if it is valid. export const verifyApiKey: NextMiddleware = async (req, res, next) => { - const { prisma, isCustomPrisma, isAdmin } = req; const hasValidLicense = await checkLicense(prisma); if (!hasValidLicense && IS_PRODUCTION) return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" }); - // If the user is an admin and using a license key (from customPrisma), skip the apiKey check. - if (isCustomPrisma && isAdmin) { - await next(); - return; - } // Check if the apiKey query param is provided. if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); // remove the prefix from the user provided api_key. If no env set default to "cal_" @@ -43,6 +38,5 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => { req.userId = apiKey.userId; // save the isAdmin boolean here for later use req.isAdmin = await isAdminGuard(req); - req.isCustomPrisma = false; await next(); }; diff --git a/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts new file mode 100644 index 00000000000000..a797bb2db2f167 --- /dev/null +++ b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts @@ -0,0 +1,24 @@ +import type { NextMiddleware } from "next-api-middleware"; + +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; + +export const verifyCredentialSyncEnabled: NextMiddleware = async (req, res, next) => { + const { isAdmin } = req; + + if (!isAdmin) { + return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" }); + } + + if (!APP_CREDENTIAL_SHARING_ENABLED) { + return res.status(501).json({ error: "Credential syncing is not enabled" }); + } + + if ( + req.headers[process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"] !== + process.env.CALCOM_CREDENTIAL_SYNC_SECRET + ) { + return res.status(401).json({ message: "Invalid credential sync secret" }); + } + + await next(); +}; diff --git a/apps/api/lib/helpers/withMiddleware.ts b/apps/api/v1/lib/helpers/withMiddleware.ts similarity index 68% rename from apps/api/lib/helpers/withMiddleware.ts rename to apps/api/v1/lib/helpers/withMiddleware.ts index ecfa22fd4f2134..95b8984ee1d225 100644 --- a/apps/api/lib/helpers/withMiddleware.ts +++ b/apps/api/v1/lib/helpers/withMiddleware.ts @@ -2,7 +2,7 @@ import { label } from "next-api-middleware"; import { addRequestId } from "./addRequestid"; import { captureErrors } from "./captureErrors"; -import { customPrismaClient } from "./customPrisma"; +import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode"; import { extendRequest } from "./extendRequest"; import { HTTP_POST, @@ -14,6 +14,7 @@ import { } from "./httpMethods"; import { rateLimitApiKey } from "./rateLimitApiKey"; import { verifyApiKey } from "./verifyApiKey"; +import { verifyCredentialSyncEnabled } from "./verifyCredentialSyncEnabled"; import { withPagination } from "./withPagination"; const middleware = { @@ -24,27 +25,26 @@ const middleware = { HTTP_POST, HTTP_DELETE, addRequestId, + checkIsInMaintenanceMode, verifyApiKey, rateLimitApiKey, - customPrismaClient, extendRequest, pagination: withPagination, captureErrors, + verifyCredentialSyncEnabled, }; type Middleware = keyof typeof middleware; -const middlewareOrder = +const middlewareOrder = [ // The order here, determines the order of execution - [ - "extendRequest", - "captureErrors", - // - Put customPrismaClient before verifyApiKey always. - "customPrismaClient", - "verifyApiKey", - "rateLimitApiKey", - "addRequestId", - ] as Middleware[]; // <-- Provide a list of middleware to call automatically + "checkIsInMaintenanceMode", + "extendRequest", + "captureErrors", + "verifyApiKey", + "rateLimitApiKey", + "addRequestId", +] as Middleware[]; // <-- Provide a list of middleware to call automatically const withMiddleware = label(middleware, middlewareOrder); diff --git a/apps/api/lib/helpers/withPagination.ts b/apps/api/v1/lib/helpers/withPagination.ts similarity index 100% rename from apps/api/lib/helpers/withPagination.ts rename to apps/api/v1/lib/helpers/withPagination.ts diff --git a/apps/api/lib/types.ts b/apps/api/v1/lib/types.ts similarity index 100% rename from apps/api/lib/types.ts rename to apps/api/v1/lib/types.ts diff --git a/apps/api/lib/utils/extractUserIdsFromQuery.ts b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts similarity index 100% rename from apps/api/lib/utils/extractUserIdsFromQuery.ts rename to apps/api/v1/lib/utils/extractUserIdsFromQuery.ts diff --git a/apps/api/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts similarity index 82% rename from apps/api/lib/utils/isAdmin.ts rename to apps/api/v1/lib/utils/isAdmin.ts index 9b9e6f53425d59..8b90c378678ab3 100644 --- a/apps/api/lib/utils/isAdmin.ts +++ b/apps/api/v1/lib/utils/isAdmin.ts @@ -1,9 +1,10 @@ import type { NextApiRequest } from "next"; +import prisma from "@calcom/prisma"; import { UserPermissionRole } from "@calcom/prisma/enums"; export const isAdminGuard = async (req: NextApiRequest) => { - const { userId, prisma } = req; + const { userId } = req; const user = await prisma.user.findUnique({ where: { id: userId } }); return user?.role === UserPermissionRole.ADMIN; }; diff --git a/apps/api/lib/utils/isValidBase64Image.ts b/apps/api/v1/lib/utils/isValidBase64Image.ts similarity index 100% rename from apps/api/lib/utils/isValidBase64Image.ts rename to apps/api/v1/lib/utils/isValidBase64Image.ts diff --git a/apps/api/lib/utils/stringifyISODate.ts b/apps/api/v1/lib/utils/stringifyISODate.ts similarity index 100% rename from apps/api/lib/utils/stringifyISODate.ts rename to apps/api/v1/lib/utils/stringifyISODate.ts diff --git a/apps/api/lib/validations/api-key.ts b/apps/api/v1/lib/validations/api-key.ts similarity index 100% rename from apps/api/lib/validations/api-key.ts rename to apps/api/v1/lib/validations/api-key.ts diff --git a/apps/api/lib/validations/attendee.ts b/apps/api/v1/lib/validations/attendee.ts similarity index 100% rename from apps/api/lib/validations/attendee.ts rename to apps/api/v1/lib/validations/attendee.ts diff --git a/apps/api/lib/validations/availability.ts b/apps/api/v1/lib/validations/availability.ts similarity index 100% rename from apps/api/lib/validations/availability.ts rename to apps/api/v1/lib/validations/availability.ts diff --git a/apps/api/lib/validations/booking-reference.ts b/apps/api/v1/lib/validations/booking-reference.ts similarity index 100% rename from apps/api/lib/validations/booking-reference.ts rename to apps/api/v1/lib/validations/booking-reference.ts diff --git a/apps/api/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts similarity index 100% rename from apps/api/lib/validations/booking.ts rename to apps/api/v1/lib/validations/booking.ts diff --git a/apps/api/v1/lib/validations/connected-calendar.ts b/apps/api/v1/lib/validations/connected-calendar.ts new file mode 100644 index 00000000000000..470aea1bd3702e --- /dev/null +++ b/apps/api/v1/lib/validations/connected-calendar.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const CalendarSchema = z.object({ + externalId: z.string(), + name: z.string(), + primary: z.boolean(), + readOnly: z.boolean(), +}); + +const IntegrationSchema = z.object({ + name: z.string(), + appId: z.string(), + userId: z.number(), + integration: z.string(), + calendars: z.array(CalendarSchema), +}); + +export const schemaConnectedCalendarsReadPublic = z.array(IntegrationSchema); diff --git a/apps/api/v1/lib/validations/credential-sync.ts b/apps/api/v1/lib/validations/credential-sync.ts new file mode 100644 index 00000000000000..04e248c047025d --- /dev/null +++ b/apps/api/v1/lib/validations/credential-sync.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { HttpError } from "@calcom/lib/http-error"; + +const userId = z.string().transform((val) => { + const userIdInt = parseInt(val); + + if (isNaN(userIdInt)) { + throw new HttpError({ message: "userId is not a valid number", statusCode: 400 }); + } + + return userIdInt; +}); +const appSlug = z.string(); +const credentialId = z.string().transform((val) => { + const credentialIdInt = parseInt(val); + + if (isNaN(credentialIdInt)) { + throw new HttpError({ message: "credentialId is not a valid number", statusCode: 400 }); + } + + return credentialIdInt; +}); +const encryptedKey = z.string(); + +export const schemaCredentialGetParams = z.object({ + userId, + appSlug: appSlug.optional(), +}); + +export const schemaCredentialPostParams = z.object({ + userId, + createSelectedCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), + createDestinationCalendar: z + .string() + .optional() + .transform((val) => { + return val === "true"; + }), +}); + +export const schemaCredentialPostBody = z.object({ + appSlug, + encryptedKey, +}); + +export const schemaCredentialPatchParams = z.object({ + userId, + credentialId, +}); + +export const schemaCredentialPatchBody = z.object({ + encryptedKey, +}); + +export const schemaCredentialDeleteParams = z.object({ + userId, + credentialId, +}); diff --git a/apps/api/lib/validations/destination-calendar.ts b/apps/api/v1/lib/validations/destination-calendar.ts similarity index 100% rename from apps/api/lib/validations/destination-calendar.ts rename to apps/api/v1/lib/validations/destination-calendar.ts diff --git a/apps/api/lib/validations/event-type-custom-input.ts b/apps/api/v1/lib/validations/event-type-custom-input.ts similarity index 100% rename from apps/api/lib/validations/event-type-custom-input.ts rename to apps/api/v1/lib/validations/event-type-custom-input.ts diff --git a/apps/api/lib/validations/event-type.ts b/apps/api/v1/lib/validations/event-type.ts similarity index 100% rename from apps/api/lib/validations/event-type.ts rename to apps/api/v1/lib/validations/event-type.ts diff --git a/apps/api/lib/validations/membership.ts b/apps/api/v1/lib/validations/membership.ts similarity index 100% rename from apps/api/lib/validations/membership.ts rename to apps/api/v1/lib/validations/membership.ts diff --git a/apps/api/lib/validations/payment.ts b/apps/api/v1/lib/validations/payment.ts similarity index 100% rename from apps/api/lib/validations/payment.ts rename to apps/api/v1/lib/validations/payment.ts diff --git a/apps/api/lib/validations/reminder-mail.ts b/apps/api/v1/lib/validations/reminder-mail.ts similarity index 100% rename from apps/api/lib/validations/reminder-mail.ts rename to apps/api/v1/lib/validations/reminder-mail.ts diff --git a/apps/api/lib/validations/schedule.ts b/apps/api/v1/lib/validations/schedule.ts similarity index 100% rename from apps/api/lib/validations/schedule.ts rename to apps/api/v1/lib/validations/schedule.ts diff --git a/apps/api/lib/validations/selected-calendar.ts b/apps/api/v1/lib/validations/selected-calendar.ts similarity index 100% rename from apps/api/lib/validations/selected-calendar.ts rename to apps/api/v1/lib/validations/selected-calendar.ts diff --git a/apps/api/lib/validations/shared/baseApiParams.ts b/apps/api/v1/lib/validations/shared/baseApiParams.ts similarity index 100% rename from apps/api/lib/validations/shared/baseApiParams.ts rename to apps/api/v1/lib/validations/shared/baseApiParams.ts diff --git a/apps/api/lib/validations/shared/jsonSchema.ts b/apps/api/v1/lib/validations/shared/jsonSchema.ts similarity index 100% rename from apps/api/lib/validations/shared/jsonSchema.ts rename to apps/api/v1/lib/validations/shared/jsonSchema.ts diff --git a/apps/api/lib/validations/shared/queryAttendeeEmail.ts b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts similarity index 100% rename from apps/api/lib/validations/shared/queryAttendeeEmail.ts rename to apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts diff --git a/apps/api/lib/validations/shared/queryIdString.ts b/apps/api/v1/lib/validations/shared/queryIdString.ts similarity index 100% rename from apps/api/lib/validations/shared/queryIdString.ts rename to apps/api/v1/lib/validations/shared/queryIdString.ts diff --git a/apps/api/lib/validations/shared/queryIdTransformParseInt.ts b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts similarity index 100% rename from apps/api/lib/validations/shared/queryIdTransformParseInt.ts rename to apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts diff --git a/apps/api/lib/validations/shared/querySlug.ts b/apps/api/v1/lib/validations/shared/querySlug.ts similarity index 100% rename from apps/api/lib/validations/shared/querySlug.ts rename to apps/api/v1/lib/validations/shared/querySlug.ts diff --git a/apps/api/lib/validations/shared/queryTeamId.ts b/apps/api/v1/lib/validations/shared/queryTeamId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryTeamId.ts rename to apps/api/v1/lib/validations/shared/queryTeamId.ts diff --git a/apps/api/lib/validations/shared/queryUserEmail.ts b/apps/api/v1/lib/validations/shared/queryUserEmail.ts similarity index 100% rename from apps/api/lib/validations/shared/queryUserEmail.ts rename to apps/api/v1/lib/validations/shared/queryUserEmail.ts diff --git a/apps/api/lib/validations/shared/queryUserId.ts b/apps/api/v1/lib/validations/shared/queryUserId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryUserId.ts rename to apps/api/v1/lib/validations/shared/queryUserId.ts diff --git a/apps/api/lib/validations/shared/timeZone.ts b/apps/api/v1/lib/validations/shared/timeZone.ts similarity index 100% rename from apps/api/lib/validations/shared/timeZone.ts rename to apps/api/v1/lib/validations/shared/timeZone.ts diff --git a/apps/api/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts similarity index 100% rename from apps/api/lib/validations/team.ts rename to apps/api/v1/lib/validations/team.ts diff --git a/apps/api/lib/validations/user.ts b/apps/api/v1/lib/validations/user.ts similarity index 100% rename from apps/api/lib/validations/user.ts rename to apps/api/v1/lib/validations/user.ts diff --git a/apps/api/lib/validations/webhook.ts b/apps/api/v1/lib/validations/webhook.ts similarity index 100% rename from apps/api/lib/validations/webhook.ts rename to apps/api/v1/lib/validations/webhook.ts diff --git a/apps/api/next-env.d.ts b/apps/api/v1/next-env.d.ts similarity index 100% rename from apps/api/next-env.d.ts rename to apps/api/v1/next-env.d.ts diff --git a/apps/api/next-i18next.config.js b/apps/api/v1/next-i18next.config.js similarity index 76% rename from apps/api/next-i18next.config.js rename to apps/api/v1/next-i18next.config.js index 402b72363cf401..cab1a8b008039f 100644 --- a/apps/api/next-i18next.config.js +++ b/apps/api/v1/next-i18next.config.js @@ -4,7 +4,7 @@ const i18nConfig = require("@calcom/config/next-i18next.config"); /** @type {import("next-i18next").UserConfig} */ const config = { ...i18nConfig, - localePath: path.resolve("../web/public/static/locales"), + localePath: path.resolve("../../web/public/static/locales"), }; module.exports = config; diff --git a/apps/api/next.config.js b/apps/api/v1/next.config.js similarity index 80% rename from apps/api/next.config.js rename to apps/api/v1/next.config.js index 4d861ce3c30faf..d875b8169c8257 100644 --- a/apps/api/next.config.js +++ b/apps/api/v1/next.config.js @@ -47,6 +47,22 @@ const nextConfig = { source: "/v:version/:rest*", destination: "/api/v:version/:rest*", }, + { + source: "/api/v2", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`, + }, + { + source: "/api/v2/health", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`, + }, + { + source: "/api/v2/docs/:path*", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/docs/:path*`, + }, + { + source: "/api/v2/:path*", + destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/api/v2/:path*`, + }, // This redirects requests to api/v*/ to /api/ passing version as a query parameter. { source: "/api/v:version/:rest*", diff --git a/apps/api/next.d.ts b/apps/api/v1/next.d.ts similarity index 81% rename from apps/api/next.d.ts rename to apps/api/v1/next.d.ts index c6f0a9d4c94c63..5c1be26eb44639 100644 --- a/apps/api/next.d.ts +++ b/apps/api/v1/next.d.ts @@ -1,8 +1,6 @@ import type { Session } from "next-auth"; import type { NextApiRequest as BaseNextApiRequest } from "next/types"; -import type { PrismaClient } from "@calcom/prisma"; - export type * from "next/types"; export declare module "next" { @@ -11,11 +9,9 @@ export declare module "next" { userId: number; method: string; - prisma: PrismaClient; // session: { user: { id: number } }; // query: Partial<{ [key: string]: string | string[] }>; isAdmin: boolean; - isCustomPrisma: boolean; pagination: { take: number; skip: number }; } } diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json new file mode 100644 index 00000000000000..5a752cbfbd790d --- /dev/null +++ b/apps/api/v1/package.json @@ -0,0 +1,46 @@ +{ + "name": "@calcom/api", + "version": "1.0.0", + "description": "Public API for Cal.com", + "main": "index.ts", + "repository": "git@github.com:calcom/api.git", + "author": "Cal.com Inc.", + "private": true, + "scripts": { + "build": "next build", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", + "dev": "PORT=3003 next dev", + "lint": "eslint . --ignore-path .gitignore", + "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", + "start": "PORT=3003 next start", + "docker-start-api": "PORT=80 next start", + "type-check": "tsc --pretty --noEmit" + }, + "devDependencies": { + "@calcom/tsconfig": "*", + "@calcom/types": "*", + "node-mocks-http": "^1.11.0" + }, + "dependencies": { + "@calcom/app-store": "*", + "@calcom/core": "*", + "@calcom/dayjs": "*", + "@calcom/emails": "*", + "@calcom/features": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/trpc": "*", + "@sentry/nextjs": "^7.73.0", + "bcryptjs": "^2.4.3", + "memory-cache": "^0.2.0", + "next": "^13.5.4", + "next-api-middleware": "^1.0.1", + "next-axiom": "^0.17.0", + "next-swagger-doc": "^0.3.6", + "next-validations": "^0.2.0", + "typescript": "^4.9.4", + "tzdata": "^1.0.30", + "uuid": "^8.3.2", + "zod": "^3.22.4" + } +} diff --git a/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts similarity index 88% rename from apps/api/pages/api/api-keys/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts index fb1c9174859ebe..c0e3fb14388033 100644 --- a/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; export async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdAsString.parse(req.query); // Admin can check any api key if (isAdmin) return; diff --git a/apps/api/pages/api/api-keys/[id]/_delete.ts b/apps/api/v1/pages/api/api-keys/[id]/_delete.ts similarity index 87% rename from apps/api/pages/api/api-keys/[id]/_delete.ts rename to apps/api/v1/pages/api/api-keys/[id]/_delete.ts index 099c6ba7b13d24..f42146caa886a5 100644 --- a/apps/api/pages/api/api-keys/[id]/_delete.ts +++ b/apps/api/v1/pages/api/api-keys/[id]/_delete.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdAsString.parse(query); await prisma.apiKey.delete({ where: { id } }); return { message: `ApiKey with id: ${id} deleted` }; diff --git a/apps/api/pages/api/api-keys/[id]/_get.ts b/apps/api/v1/pages/api/api-keys/[id]/_get.ts similarity index 89% rename from apps/api/pages/api/api-keys/[id]/_get.ts rename to apps/api/v1/pages/api/api-keys/[id]/_get.ts index 99b1188507fcbb..7c9645ce8759bc 100644 --- a/apps/api/pages/api/api-keys/[id]/_get.ts +++ b/apps/api/v1/pages/api/api-keys/[id]/_get.ts @@ -1,12 +1,13 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { apiKeyPublicSchema } from "~/lib/validations/api-key"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdAsString.parse(query); const api_key = await prisma.apiKey.findUniqueOrThrow({ where: { id } }); return { api_key: apiKeyPublicSchema.parse(api_key) }; diff --git a/apps/api/pages/api/api-keys/[id]/_patch.ts b/apps/api/v1/pages/api/api-keys/[id]/_patch.ts similarity index 90% rename from apps/api/pages/api/api-keys/[id]/_patch.ts rename to apps/api/v1/pages/api/api-keys/[id]/_patch.ts index 673a081f5418f4..b08c8b97ab4113 100644 --- a/apps/api/pages/api/api-keys/[id]/_patch.ts +++ b/apps/api/v1/pages/api/api-keys/[id]/_patch.ts @@ -1,12 +1,13 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { apiKeyEditBodySchema, apiKeyPublicSchema } from "~/lib/validations/api-key"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; async function patchHandler(req: NextApiRequest) { - const { prisma, body } = req; + const { body } = req; const { id } = schemaQueryIdAsString.parse(req.query); const data = apiKeyEditBodySchema.parse(body); const api_key = await prisma.apiKey.update({ where: { id }, data }); diff --git a/apps/api/pages/api/api-keys/[id]/index.ts b/apps/api/v1/pages/api/api-keys/[id]/index.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/index.ts rename to apps/api/v1/pages/api/api-keys/[id]/index.ts diff --git a/apps/api/pages/api/api-keys/_get.ts b/apps/api/v1/pages/api/api-keys/_get.ts similarity index 95% rename from apps/api/pages/api/api-keys/_get.ts rename to apps/api/v1/pages/api/api-keys/_get.ts index 8c23c6fe9ed580..8f18e3ebae59d1 100644 --- a/apps/api/pages/api/api-keys/_get.ts +++ b/apps/api/v1/pages/api/api-keys/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import type { Ensure } from "@calcom/types/utils"; import { apiKeyPublicSchema } from "~/lib/validations/api-key"; @@ -29,7 +30,7 @@ function hasReqArgs(req: CustomNextApiRequest): req is Ensure) { - const { isAdmin, prisma } = req; + const { isAdmin } = req; if (isAdmin) return; const { userId } = req; const { bookingId } = body; diff --git a/apps/api/pages/api/attendees/[id]/index.ts b/apps/api/v1/pages/api/attendees/[id]/index.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/index.ts rename to apps/api/v1/pages/api/attendees/[id]/index.ts diff --git a/apps/api/pages/api/attendees/_get.ts b/apps/api/v1/pages/api/attendees/_get.ts similarity index 94% rename from apps/api/pages/api/attendees/_get.ts rename to apps/api/v1/pages/api/attendees/_get.ts index d6662d897cd84a..b3ec13db7bd7b9 100644 --- a/apps/api/pages/api/attendees/_get.ts +++ b/apps/api/v1/pages/api/attendees/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; @@ -30,7 +31,7 @@ import { schemaAttendeeReadPublic } from "~/lib/validations/attendee"; * description: No attendees were found */ async function handler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const args: Prisma.AttendeeFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } }; const data = await prisma.attendee.findMany(args); const attendees = data.map((attendee) => schemaAttendeeReadPublic.parse(attendee)); diff --git a/apps/api/pages/api/attendees/_post.ts b/apps/api/v1/pages/api/attendees/_post.ts similarity index 96% rename from apps/api/pages/api/attendees/_post.ts rename to apps/api/v1/pages/api/attendees/_post.ts index 8570376c555a9e..610f3062822cf7 100644 --- a/apps/api/pages/api/attendees/_post.ts +++ b/apps/api/v1/pages/api/attendees/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "~/lib/validations/attendee"; @@ -51,7 +52,7 @@ import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "~/lib/ * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const body = schemaAttendeeCreateBodyParams.parse(req.body); if (!isAdmin) { diff --git a/apps/api/pages/api/attendees/index.ts b/apps/api/v1/pages/api/attendees/index.ts similarity index 100% rename from apps/api/pages/api/attendees/index.ts rename to apps/api/v1/pages/api/attendees/index.ts diff --git a/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts similarity index 88% rename from apps/api/pages/api/availabilities/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts index 63ad3a3c326371..406c808511cb47 100644 --- a/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts @@ -1,9 +1,11 @@ import type { NextApiRequest } from "next"; +import prisma from "@calcom/prisma"; + import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin, query } = req; + const { userId, isAdmin, query } = req; const { id } = schemaQueryIdParseInt.parse(query); /** Admins can skip the ownership verification */ if (isAdmin) return; diff --git a/apps/api/pages/api/availabilities/[id]/_delete.ts b/apps/api/v1/pages/api/availabilities/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/availabilities/[id]/_delete.ts rename to apps/api/v1/pages/api/availabilities/[id]/_delete.ts index d480ad4a932cd8..9c5539add46267 100644 --- a/apps/api/pages/api/availabilities/[id]/_delete.ts +++ b/apps/api/v1/pages/api/availabilities/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -36,7 +37,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); await prisma.availability.delete({ where: { id } }); return { message: `Availability with id: ${id} deleted successfully` }; diff --git a/apps/api/pages/api/availabilities/[id]/_get.ts b/apps/api/v1/pages/api/availabilities/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/availabilities/[id]/_get.ts rename to apps/api/v1/pages/api/availabilities/[id]/_get.ts index a481ac1f8953e8..aeb41de90e88e6 100644 --- a/apps/api/pages/api/availabilities/[id]/_get.ts +++ b/apps/api/v1/pages/api/availabilities/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaAvailabilityReadPublic } from "~/lib/validations/availability"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -37,7 +38,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Availability not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const availability = await prisma.availability.findUnique({ where: { id }, diff --git a/apps/api/pages/api/availabilities/[id]/_patch.ts b/apps/api/v1/pages/api/availabilities/[id]/_patch.ts similarity index 97% rename from apps/api/pages/api/availabilities/[id]/_patch.ts rename to apps/api/v1/pages/api/availabilities/[id]/_patch.ts index 2b97d01461f06d..87cd787b930c4b 100644 --- a/apps/api/pages/api/availabilities/[id]/_patch.ts +++ b/apps/api/v1/pages/api/availabilities/[id]/_patch.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaAvailabilityEditBodyParams, @@ -72,7 +73,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; + const { query, body } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = schemaAvailabilityEditBodyParams.parse(body); const availability = await prisma.availability.update({ diff --git a/apps/api/pages/api/availabilities/[id]/index.ts b/apps/api/v1/pages/api/availabilities/[id]/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/index.ts rename to apps/api/v1/pages/api/availabilities/[id]/index.ts diff --git a/apps/api/pages/api/availabilities/_post.ts b/apps/api/v1/pages/api/availabilities/_post.ts similarity index 97% rename from apps/api/pages/api/availabilities/_post.ts rename to apps/api/v1/pages/api/availabilities/_post.ts index 19c76f26cb0233..a0f915241fbd8a 100644 --- a/apps/api/pages/api/availabilities/_post.ts +++ b/apps/api/v1/pages/api/availabilities/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaAvailabilityCreateBodyParams, @@ -71,7 +72,6 @@ import { * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { prisma } = req; const data = schemaAvailabilityCreateBodyParams.parse(req.body); await checkPermissions(req); const availability = await prisma.availability.create({ @@ -86,7 +86,7 @@ async function postHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; if (isAdmin) return; const data = schemaAvailabilityCreateBodyParams.parse(req.body); const schedule = await prisma.schedule.findFirst({ diff --git a/apps/api/pages/api/availabilities/index.ts b/apps/api/v1/pages/api/availabilities/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/index.ts rename to apps/api/v1/pages/api/availabilities/index.ts diff --git a/apps/api/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts similarity index 99% rename from apps/api/pages/api/availability/_get.ts rename to apps/api/v1/pages/api/availability/_get.ts index 6f39d99c8da9e5..580d149c6fbf11 100644 --- a/apps/api/pages/api/availability/_get.ts +++ b/apps/api/v1/pages/api/availability/_get.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { stringOrNumber } from "@calcom/prisma/zod-utils"; @@ -188,7 +189,7 @@ const availabilitySchema = z ); async function handler(req: NextApiRequest) { - const { prisma, isAdmin, userId: reqUserId } = req; + const { isAdmin, userId: reqUserId } = req; const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); if (!teamId) return getUserAvailability({ diff --git a/apps/api/pages/api/availability/index.ts b/apps/api/v1/pages/api/availability/index.ts similarity index 100% rename from apps/api/pages/api/availability/index.ts rename to apps/api/v1/pages/api/availability/index.ts diff --git a/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts similarity index 90% rename from apps/api/pages/api/booking-references/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts index fb4d179e61eb89..1970d5d20859e8 100644 --- a/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); // Here we make sure to only return references of the user's own bookings if the user is not an admin. if (isAdmin) return; diff --git a/apps/api/pages/api/booking-references/[id]/_delete.ts b/apps/api/v1/pages/api/booking-references/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/booking-references/[id]/_delete.ts rename to apps/api/v1/pages/api/booking-references/[id]/_delete.ts index 23a83ce311dfcc..0dbcf32525d9a1 100644 --- a/apps/api/pages/api/booking-references/[id]/_delete.ts +++ b/apps/api/v1/pages/api/booking-references/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -34,7 +35,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); await prisma.bookingReference.delete({ where: { id } }); return { message: `BookingReference with id: ${id} deleted` }; diff --git a/apps/api/pages/api/booking-references/[id]/_get.ts b/apps/api/v1/pages/api/booking-references/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/booking-references/[id]/_get.ts rename to apps/api/v1/pages/api/booking-references/[id]/_get.ts index 6baf71a550d349..5ef19d92633be2 100644 --- a/apps/api/pages/api/booking-references/[id]/_get.ts +++ b/apps/api/v1/pages/api/booking-references/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -35,7 +36,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: BookingReference was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const booking_reference = await prisma.bookingReference.findUniqueOrThrow({ where: { id } }); return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) }; diff --git a/apps/api/pages/api/booking-references/[id]/_patch.ts b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts similarity index 96% rename from apps/api/pages/api/booking-references/[id]/_patch.ts rename to apps/api/v1/pages/api/booking-references/[id]/_patch.ts index 37e17e2e00b152..d90ddeb31d7605 100644 --- a/apps/api/pages/api/booking-references/[id]/_patch.ts +++ b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingEditBodyParams, @@ -59,7 +60,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body, isAdmin, userId } = req; + const { query, body, isAdmin, userId } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = schemaBookingEditBodyParams.parse(body); /* If user tries to update bookingId, we run extra checks */ diff --git a/apps/api/pages/api/booking-references/[id]/index.ts b/apps/api/v1/pages/api/booking-references/[id]/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/index.ts rename to apps/api/v1/pages/api/booking-references/[id]/index.ts diff --git a/apps/api/pages/api/booking-references/_get.ts b/apps/api/v1/pages/api/booking-references/_get.ts similarity index 94% rename from apps/api/pages/api/booking-references/_get.ts rename to apps/api/v1/pages/api/booking-references/_get.ts index c3b81a46222fec..15dc11c1c04023 100644 --- a/apps/api/pages/api/booking-references/_get.ts +++ b/apps/api/v1/pages/api/booking-references/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-reference"; @@ -29,7 +30,7 @@ import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-refe * description: No booking references were found */ async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const args: Prisma.BookingReferenceFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } }; const data = await prisma.bookingReference.findMany(args); return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) }; diff --git a/apps/api/pages/api/booking-references/_post.ts b/apps/api/v1/pages/api/booking-references/_post.ts similarity index 97% rename from apps/api/pages/api/booking-references/_post.ts rename to apps/api/v1/pages/api/booking-references/_post.ts index b3b8b713cffacf..98761421f93467 100644 --- a/apps/api/pages/api/booking-references/_post.ts +++ b/apps/api/v1/pages/api/booking-references/_post.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingCreateBodyParams, @@ -61,7 +62,7 @@ import { * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const body = schemaBookingCreateBodyParams.parse(req.body); const args: Prisma.BookingFindFirstOrThrowArgs = isAdmin ? /* If admin, we only check that the booking exists */ diff --git a/apps/api/pages/api/booking-references/index.ts b/apps/api/v1/pages/api/booking-references/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/index.ts rename to apps/api/v1/pages/api/booking-references/index.ts diff --git a/apps/api/pages/api/bookings/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts similarity index 93% rename from apps/api/pages/api/bookings/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts index 0016de42e9b9c3..b365b675e06b5f 100644 --- a/apps/api/pages/api/bookings/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin, query } = req; + const { userId, isAdmin, query } = req; if (isAdmin) { return; } diff --git a/apps/api/pages/api/bookings/[id]/_delete.ts b/apps/api/v1/pages/api/bookings/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/_delete.ts rename to apps/api/v1/pages/api/bookings/[id]/_delete.ts diff --git a/apps/api/pages/api/bookings/[id]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/_get.ts similarity index 98% rename from apps/api/pages/api/bookings/[id]/_get.ts rename to apps/api/v1/pages/api/bookings/[id]/_get.ts index c549af8b86c9ee..bc8511b5867bb1 100644 --- a/apps/api/pages/api/bookings/[id]/_get.ts +++ b/apps/api/v1/pages/api/bookings/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingReadPublic } from "~/lib/validations/booking"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -86,7 +87,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const booking = await prisma.booking.findUnique({ where: { id }, diff --git a/apps/api/pages/api/bookings/[id]/_patch.ts b/apps/api/v1/pages/api/bookings/[id]/_patch.ts similarity index 98% rename from apps/api/pages/api/bookings/[id]/_patch.ts rename to apps/api/v1/pages/api/bookings/[id]/_patch.ts index 1e0e960b6d2d20..ed36ec8ed43f18 100644 --- a/apps/api/pages/api/bookings/[id]/_patch.ts +++ b/apps/api/v1/pages/api/bookings/[id]/_patch.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingEditBodyParams, schemaBookingReadPublic } from "~/lib/validations/booking"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -99,7 +100,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; + const { query, body } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = schemaBookingEditBodyParams.parse(body); await checkPermissions(req, data); diff --git a/apps/api/pages/api/bookings/[id]/cancel.ts b/apps/api/v1/pages/api/bookings/[id]/cancel.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/cancel.ts rename to apps/api/v1/pages/api/bookings/[id]/cancel.ts diff --git a/apps/api/pages/api/bookings/[id]/index.ts b/apps/api/v1/pages/api/bookings/[id]/index.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/index.ts rename to apps/api/v1/pages/api/bookings/[id]/index.ts diff --git a/apps/api/pages/api/bookings/_get.ts b/apps/api/v1/pages/api/bookings/_get.ts similarity index 99% rename from apps/api/pages/api/bookings/_get.ts rename to apps/api/v1/pages/api/bookings/_get.ts index e8986536210b37..67ae16f45635e4 100644 --- a/apps/api/pages/api/bookings/_get.ts +++ b/apps/api/v1/pages/api/bookings/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking"; import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail"; @@ -161,7 +162,7 @@ function buildWhereClause( } async function handler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query); diff --git a/apps/api/pages/api/bookings/_post.ts b/apps/api/v1/pages/api/bookings/_post.ts similarity index 100% rename from apps/api/pages/api/bookings/_post.ts rename to apps/api/v1/pages/api/bookings/_post.ts diff --git a/apps/api/pages/api/bookings/index.ts b/apps/api/v1/pages/api/bookings/index.ts similarity index 100% rename from apps/api/pages/api/bookings/index.ts rename to apps/api/v1/pages/api/bookings/index.ts diff --git a/apps/api/v1/pages/api/connected-calendars/_get.ts b/apps/api/v1/pages/api/connected-calendars/_get.ts new file mode 100644 index 00000000000000..47085c33acb163 --- /dev/null +++ b/apps/api/v1/pages/api/connected-calendars/_get.ts @@ -0,0 +1,146 @@ +import type { NextApiRequest } from "next"; + +import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars"; +import { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; +import { schemaConnectedCalendarsReadPublic } from "~/lib/validations/connected-calendar"; + +/** + * @swagger + * /connected-calendars: + * get: + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: false + * schema: + * type: number + * description: Admins can fetch connected calendars for other user e.g. &userId=1 or multiple users e.g. &userId=1&userId=2 + * summary: Fetch connected calendars + * tags: + * - connected-calendars + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * appId: + * type: string + * userId: + * type: number + * integration: + * type: string + * calendars: + * type: array + * items: + * type: object + * properties: + * externalId: + * type: string + * name: + * type: string + * primary: + * type: boolean + * readOnly: + * type: boolean + * examples: + * connectedCalendarExample: + * value: [ + * { + * "name": "Google Calendar", + * "appId": "google-calendar", + * "userId": 10, + * "integration": "google_calendar", + * "calendars": [ + * { + * "externalId": "alice@gmail.com", + * "name": "alice@gmail.com", + * "primary": true, + * "readOnly": false + * }, + * { + * "externalId": "addressbook#contacts@group.v.calendar.google.com", + * "name": "birthdays", + * "primary": false, + * "readOnly": true + * }, + * { + * "externalId": "en.latvian#holiday@group.v.calendar.google.com", + * "name": "Holidays in Narnia", + * "primary": false, + * "readOnly": true + * } + * ] + * } + * ] + * 401: + * description: Authorization information is missing or invalid. + * 403: + * description: Non admin user trying to fetch other user's connected calendars. + */ + +async function getHandler(req: NextApiRequest) { + const { userId, isAdmin } = req; + + if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + + const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; + + const usersWithCalendars = await prisma.user.findMany({ + where: { id: { in: userIds } }, + include: { + selectedCalendars: true, + destinationCalendar: true, + }, + }); + + return await getConnectedCalendars(usersWithCalendars); +} + +async function getConnectedCalendars(users: UserWithCalendars[]) { + const connectedDestinationCalendarsPromises = users.map((user) => + getConnectedDestinationCalendars(user, false, prisma).then((connectedCalendarsResult) => + connectedCalendarsResult.connectedCalendars.map((calendar) => ({ + userId: user.id, + ...calendar, + })) + ) + ); + const connectedDestinationCalendars = await Promise.all(connectedDestinationCalendarsPromises); + + const flattenedCalendars = connectedDestinationCalendars.flat(); + + const mapped = flattenedCalendars.map((calendar) => ({ + name: calendar.integration.name, + appId: calendar.integration.slug, + userId: calendar.userId, + integration: calendar.integration.type, + calendars: (calendar.calendars ?? []).map((c) => ({ + externalId: c.externalId, + name: c.name, + primary: c.primary ?? false, + readOnly: c.readOnly, + })), + })); + + return schemaConnectedCalendarsReadPublic.parse(mapped); +} + +export default defaultResponder(getHandler); diff --git a/apps/api/pages/api/me/index.ts b/apps/api/v1/pages/api/connected-calendars/index.ts similarity index 100% rename from apps/api/pages/api/me/index.ts rename to apps/api/v1/pages/api/connected-calendars/index.ts diff --git a/apps/api/v1/pages/api/credential-sync/_delete.ts b/apps/api/v1/pages/api/credential-sync/_delete.ts new file mode 100644 index 00000000000000..7753110ffc92d7 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_delete.ts @@ -0,0 +1,60 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialDeleteParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * delete: + * operationId: deleteUserAppCredential + * summary: Delete a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { userId, credentialId } = schemaCredentialDeleteParams.parse(req.query); + + const credential = await prisma.credential.delete({ + where: { + id: credentialId, + userId, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credentialDeleted: credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_get.ts b/apps/api/v1/pages/api/credential-sync/_get.ts new file mode 100644 index 00000000000000..d465d080fef3d9 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_get.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialGetParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * get: + * operationId: getUserAppCredentials + * summary: Get all app credentials for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { appSlug, userId } = schemaCredentialGetParams.parse(req.query); + + let credentials = await prisma.credential.findMany({ + where: { + userId, + ...(appSlug && { appId: appSlug }), + }, + select: { + id: true, + appId: true, + }, + }); + + // For apps we're transitioning to using the term slug to keep things consistent + credentials = credentials.map((credential) => { + return { + ...credential, + appSlug: credential.appId, + }; + }); + + return { credentials }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_patch.ts b/apps/api/v1/pages/api/credential-sync/_patch.ts new file mode 100644 index 00000000000000..ac7aac2ecb5ed2 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_patch.ts @@ -0,0 +1,85 @@ +import type { NextApiRequest } from "next"; + +import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { schemaCredentialPatchParams, schemaCredentialPatchBody } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * patch: + * operationId: updateUserAppCredential + * summary: Update a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * - in: query + * name: credentialId + * required: true + * schema: + * type: string + * description: ID of the credential to update + * tags: + * - credentials + * requestBody: + * description: Update a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * properties: + * encryptedKey: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + const { userId, credentialId } = schemaCredentialPatchParams.parse(req.query); + + const { encryptedKey } = schemaCredentialPatchBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = minimumTokenResponseSchema.parse(decryptedKey); + + const credential = await prisma.credential.update({ + where: { + id: credentialId, + userId, + }, + data: { + key, + }, + select: { + id: true, + appId: true, + }, + }); + + return { credential }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts new file mode 100644 index 00000000000000..6a6b7aebd982b6 --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/_post.ts @@ -0,0 +1,145 @@ +import type { NextApiRequest } from "next"; + +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; + +import { schemaCredentialPostBody, schemaCredentialPostParams } from "~/lib/validations/credential-sync"; + +/** + * @swagger + * /credential-sync: + * post: + * operationId: createUserAppCredential + * summary: Create a credential record for a user + * parameters: + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: ID of the user to fetch the credentials for + * tags: + * - credentials + * requestBody: + * description: Create a new credential + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - encryptedKey + * - appSlug + * properties: + * encryptedKey: + * type: string + * appSlug: + * type: string + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 505: + * description: Credential syncing not enabled + */ +async function handler(req: NextApiRequest) { + if (!req.body) { + throw new HttpError({ message: "Request body is missing", statusCode: 400 }); + } + + const { userId, createSelectedCalendar, createDestinationCalendar } = schemaCredentialPostParams.parse( + req.query + ); + + const { appSlug, encryptedKey } = schemaCredentialPostBody.parse(req.body); + + const decryptedKey = JSON.parse( + symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") + ); + + const key = minimumTokenResponseSchema.parse(decryptedKey); + + // Need to get app type + const app = await prisma.app.findUnique({ + where: { slug: appSlug }, + select: { dirName: true, categories: true }, + }); + + if (!app) { + throw new HttpError({ message: "App not found", statusCode: 500 }); + } + + const createCalendarResources = + app.categories.some((category) => category === "calendar") && + (createSelectedCalendar || createDestinationCalendar); + + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + + const createdcredential = await prisma.credential.create({ + data: { + userId, + appId: appSlug, + key, + type: appMetadata.type, + }, + select: credentialForCalendarServiceSelect, + }); + // createdcredential.user.email; + // TODO: ^ Investigate why this select doesn't work. + const credential = await prisma.credential.findUniqueOrThrow({ + where: { + id: createdcredential.id, + }, + select: credentialForCalendarServiceSelect, + }); + // ^ Workaround for the select in `create` not working + + if (createCalendarResources) { + const calendar = await getCalendar(credential); + if (!calendar) throw new HttpError({ message: "Calendar missing for credential", statusCode: 500 }); + const calendars = await calendar.listCalendars(); + const calendarToCreate = calendars.find((calendar) => calendar.primary) || calendars[0]; + + if (createSelectedCalendar) { + await prisma.selectedCalendar.createMany({ + data: [ + { + userId, + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credentialId: credential.id, + }, + ], + skipDuplicates: true, + }); + } + if (createDestinationCalendar) { + await prisma.destinationCalendar.create({ + data: { + integration: appMetadata.type, + externalId: calendarToCreate.externalId, + credential: { connect: { id: credential.id } }, + primaryEmail: calendarToCreate.email || credential.user?.email, + user: { connect: { id: userId } }, + }, + }); + } + } + + return { credential: { id: credential.id, type: credential.type } }; +} + +export default defaultResponder(handler); diff --git a/apps/api/v1/pages/api/credential-sync/index.ts b/apps/api/v1/pages/api/credential-sync/index.ts new file mode 100644 index 00000000000000..8def51c2bd441c --- /dev/null +++ b/apps/api/v1/pages/api/credential-sync/index.ts @@ -0,0 +1,12 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware("verifyCredentialSyncEnabled")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + }) +); diff --git a/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts similarity index 90% rename from apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts index 3bae379831ec43..bc5888acbbf4e6 100644 --- a/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); // Admins can just skip this check if (isAdmin) return; diff --git a/apps/api/pages/api/custom-inputs/[id]/_delete.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/custom-inputs/[id]/_delete.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts index 747e1954ffa57f..08b636b959bbed 100644 --- a/apps/api/pages/api/custom-inputs/[id]/_delete.ts +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -33,7 +34,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); await prisma.eventTypeCustomInput.delete({ where: { id } }); return { message: `CustomInputEventType with id: ${id} deleted successfully` }; diff --git a/apps/api/pages/api/custom-inputs/[id]/_get.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/custom-inputs/[id]/_get.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_get.ts index 96cb8133dcee36..321aea5487de38 100644 --- a/apps/api/pages/api/custom-inputs/[id]/_get.ts +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -34,7 +35,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: EventType was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = await prisma.eventTypeCustomInput.findUniqueOrThrow({ where: { id } }); return { event_type_custom_input: schemaEventTypeCustomInputPublic.parse(data) }; diff --git a/apps/api/pages/api/custom-inputs/[id]/_patch.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts similarity index 97% rename from apps/api/pages/api/custom-inputs/[id]/_patch.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts index 9ecdd822de7f09..ae863c3906efcd 100644 --- a/apps/api/pages/api/custom-inputs/[id]/_patch.ts +++ b/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaEventTypeCustomInputEditBodyParams, @@ -76,7 +77,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = schemaEventTypeCustomInputEditBodyParams.parse(req.body); const result = await prisma.eventTypeCustomInput.update({ where: { id }, data }); diff --git a/apps/api/pages/api/custom-inputs/[id]/index.ts b/apps/api/v1/pages/api/custom-inputs/[id]/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/index.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/index.ts diff --git a/apps/api/pages/api/custom-inputs/_get.ts b/apps/api/v1/pages/api/custom-inputs/_get.ts similarity index 94% rename from apps/api/pages/api/custom-inputs/_get.ts rename to apps/api/v1/pages/api/custom-inputs/_get.ts index db63434ddfeae4..02e8909451184f 100644 --- a/apps/api/pages/api/custom-inputs/_get.ts +++ b/apps/api/v1/pages/api/custom-inputs/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-custom-input"; @@ -28,7 +29,7 @@ import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-c * description: No eventTypeCustomInputs were found */ async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const args: Prisma.EventTypeCustomInputFindManyArgs = isAdmin ? {} : { where: { eventType: { userId } } }; const data = await prisma.eventTypeCustomInput.findMany(args); return { event_type_custom_inputs: data.map((v) => schemaEventTypeCustomInputPublic.parse(v)) }; diff --git a/apps/api/pages/api/custom-inputs/_post.ts b/apps/api/v1/pages/api/custom-inputs/_post.ts similarity index 97% rename from apps/api/pages/api/custom-inputs/_post.ts rename to apps/api/v1/pages/api/custom-inputs/_post.ts index 331fdd732fc9ac..cd6e04a6983c98 100644 --- a/apps/api/pages/api/custom-inputs/_post.ts +++ b/apps/api/v1/pages/api/custom-inputs/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaEventTypeCustomInputBodyParams, @@ -79,7 +80,7 @@ import { * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { eventTypeId, ...body } = schemaEventTypeCustomInputBodyParams.parse(req.body); if (!isAdmin) { diff --git a/apps/api/pages/api/custom-inputs/index.ts b/apps/api/v1/pages/api/custom-inputs/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/index.ts rename to apps/api/v1/pages/api/custom-inputs/index.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts similarity index 92% rename from apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts index 03ab72c797f147..276cf44f446e1e 100644 --- a/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); if (isAdmin) return; const userEventTypes = await prisma.eventType.findMany({ diff --git a/apps/api/pages/api/destination-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/destination-calendars/[id]/_delete.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts index 05ba70551a8b16..a356e2656b5d8c 100644 --- a/apps/api/pages/api/destination-calendars/[id]/_delete.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -33,7 +34,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Destination calendar not found */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); await prisma.destinationCalendar.delete({ where: { id } }); return { message: `OK, Destination Calendar removed successfully` }; diff --git a/apps/api/pages/api/destination-calendars/[id]/_get.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/destination-calendars/[id]/_get.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_get.ts index febb3fc59fbb40..6531539814516d 100644 --- a/apps/api/pages/api/destination-calendars/[id]/_get.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -34,7 +35,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Destination calendar not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const destinationCalendar = await prisma.destinationCalendar.findUnique({ diff --git a/apps/api/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts similarity index 99% rename from apps/api/pages/api/destination-calendars/[id]/_patch.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts index 0ea5b23598ae3a..75221c5192ee03 100644 --- a/apps/api/pages/api/destination-calendars/[id]/_patch.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts @@ -5,6 +5,7 @@ import type { z } from "zod"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -82,7 +83,7 @@ type UserCredentialType = { }; export async function patchHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma, query, body } = req; + const { userId, isAdmin, query, body } = req; const { id } = schemaQueryIdParseInt.parse(query); const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body); const assignedUserId = isAdmin ? parsedBody.userId || userId : userId; diff --git a/apps/api/pages/api/destination-calendars/[id]/index.ts b/apps/api/v1/pages/api/destination-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/index.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/index.ts diff --git a/apps/api/pages/api/destination-calendars/_get.ts b/apps/api/v1/pages/api/destination-calendars/_get.ts similarity index 96% rename from apps/api/pages/api/destination-calendars/_get.ts rename to apps/api/v1/pages/api/destination-calendars/_get.ts index f78a8cd8ab7612..a65bf90987ac87 100644 --- a/apps/api/pages/api/destination-calendars/_get.ts +++ b/apps/api/v1/pages/api/destination-calendars/_get.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery"; import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar"; @@ -29,7 +30,7 @@ import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destinati * description: No destination calendars were found */ async function getHandler(req: NextApiRequest) { - const { userId, prisma } = req; + const { userId } = req; const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; const userEventTypes = await prisma.eventType.findMany({ diff --git a/apps/api/pages/api/destination-calendars/_post.ts b/apps/api/v1/pages/api/destination-calendars/_post.ts similarity index 96% rename from apps/api/pages/api/destination-calendars/_post.ts rename to apps/api/v1/pages/api/destination-calendars/_post.ts index 40d3cf5e959b0a..e8fdf266b2615c 100644 --- a/apps/api/pages/api/destination-calendars/_post.ts +++ b/apps/api/v1/pages/api/destination-calendars/_post.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { @@ -60,7 +61,7 @@ import { * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma, body } = req; + const { userId, isAdmin, body } = req; const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body); await checkPermissions(req, userId); @@ -132,7 +133,7 @@ async function checkPermissions(req: NextApiRequest, userId: number) { if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" }); /* User should only be able to create for their own destination calendars*/ if (!isAdmin && body.eventTypeId) { - const ownsEventType = await req.prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } }); + const ownsEventType = await prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } }); if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" }); } // TODO:: Add support for team event types with validation diff --git a/apps/api/pages/api/destination-calendars/index.ts b/apps/api/v1/pages/api/destination-calendars/index.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/index.ts rename to apps/api/v1/pages/api/destination-calendars/index.ts diff --git a/apps/api/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts similarity index 100% rename from apps/api/pages/api/docs.ts rename to apps/api/v1/pages/api/docs.ts diff --git a/apps/api/pages/api/event-types/[id]/_delete.ts b/apps/api/v1/pages/api/event-types/[id]/_delete.ts similarity index 94% rename from apps/api/pages/api/event-types/[id]/_delete.ts rename to apps/api/v1/pages/api/event-types/[id]/_delete.ts index 2e598afc545f37..519047a127dabe 100644 --- a/apps/api/pages/api/event-types/[id]/_delete.ts +++ b/apps/api/v1/pages/api/event-types/[id]/_delete.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -37,7 +38,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); await checkPermissions(req); await prisma.eventType.delete({ where: { id } }); @@ -45,7 +46,7 @@ export async function deleteHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); if (isAdmin) return; /** Only event type owners can delete it */ diff --git a/apps/api/pages/api/event-types/[id]/_get.ts b/apps/api/v1/pages/api/event-types/[id]/_get.ts similarity index 97% rename from apps/api/pages/api/event-types/[id]/_get.ts rename to apps/api/v1/pages/api/event-types/[id]/_get.ts index 539a894775c8b2..e21c8bbe0f3237 100644 --- a/apps/api/pages/api/event-types/[id]/_get.ts +++ b/apps/api/v1/pages/api/event-types/[id]/_get.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; @@ -43,7 +44,7 @@ import getCalLink from "../_utils/getCalLink"; * description: EventType was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const eventType = await prisma.eventType.findUnique({ @@ -92,6 +93,7 @@ async function checkPermissions( await canAccessTeamEventOrThrow(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], }); + return true; } if (eventType?.userId === req.userId) return true; // is owner. throw new HttpError({ statusCode: 403, message: "Forbidden" }); diff --git a/apps/api/pages/api/event-types/[id]/_patch.ts b/apps/api/v1/pages/api/event-types/[id]/_patch.ts similarity index 98% rename from apps/api/pages/api/event-types/[id]/_patch.ts rename to apps/api/v1/pages/api/event-types/[id]/_patch.ts index 7c8fcd480a10ab..68b270324beefa 100644 --- a/apps/api/pages/api/event-types/[id]/_patch.ts +++ b/apps/api/v1/pages/api/event-types/[id]/_patch.ts @@ -4,6 +4,7 @@ import type { z } from "zod"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type"; @@ -203,7 +204,7 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, body } = req; + const { query, body } = req; const { id } = schemaQueryIdParseInt.parse(query); const { hosts = [], @@ -236,7 +237,7 @@ export async function patchHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest, body: z.infer) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); if (isAdmin) return; /** Only event type owners can modify it */ diff --git a/apps/api/pages/api/webhooks/[id]/index.ts b/apps/api/v1/pages/api/event-types/[id]/index.ts similarity index 84% rename from apps/api/pages/api/webhooks/[id]/index.ts rename to apps/api/v1/pages/api/event-types/[id]/index.ts index 727ad02843d761..26d7389880b9c0 100644 --- a/apps/api/pages/api/webhooks/[id]/index.ts +++ b/apps/api/v1/pages/api/event-types/[id]/index.ts @@ -4,11 +4,8 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import { withMiddleware } from "~/lib/helpers/withMiddleware"; -import authMiddleware from "./_auth-middleware"; - export default withMiddleware()( defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { - await authMiddleware(req); return defaultHandler({ GET: import("./_get"), PATCH: import("./_patch"), diff --git a/apps/api/pages/api/event-types/_get.ts b/apps/api/v1/pages/api/event-types/_get.ts similarity index 98% rename from apps/api/pages/api/event-types/_get.ts rename to apps/api/v1/pages/api/event-types/_get.ts index c36aea118bbb4e..e57926d66e32bb 100644 --- a/apps/api/pages/api/event-types/_get.ts +++ b/apps/api/v1/pages/api/event-types/_get.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma"; import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; @@ -42,7 +43,7 @@ import getCalLink from "./_utils/getCalLink"; * description: No event types were found */ async function getHandler(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId]; const { slug } = schemaQuerySlug.parse(req.query); const shouldUseUserId = !isAdmin || !slug || !!req.query.userId; diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/v1/pages/api/event-types/_post.ts similarity index 98% rename from apps/api/pages/api/event-types/_post.ts rename to apps/api/v1/pages/api/event-types/_post.ts index 6e748a1bc22bb7..19aec7ca338898 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/v1/pages/api/event-types/_post.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/client"; import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type"; @@ -264,7 +265,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts"; * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma, body } = req; + const { userId, isAdmin, body } = req; const { hosts = [], @@ -321,7 +322,7 @@ async function checkPermissions(req: NextApiRequest) { if ( body.teamId && !isAdmin && - !(await canUserAccessTeamWithRole(req.prisma, req.userId, isAdmin, body.teamId, { + !(await canUserAccessTeamWithRole(req.userId, isAdmin, body.teamId, { in: [MembershipRole.OWNER, MembershipRole.ADMIN], })) ) diff --git a/apps/api/pages/api/event-types/_utils/checkParentEventOwnership.ts b/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts similarity index 95% rename from apps/api/pages/api/event-types/_utils/checkParentEventOwnership.ts rename to apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts index 2e91d789b77401..7a7025296fbe48 100644 --- a/apps/api/pages/api/event-types/_utils/checkParentEventOwnership.ts +++ b/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; /** * Checks if a user, identified by the provided userId, has ownership (or admin rights) over @@ -13,7 +14,7 @@ import { HttpError } from "@calcom/lib/http-error"; * or if the user doesn't have ownership or admin rights to the associated team. */ export default async function checkParentEventOwnership(req: NextApiRequest) { - const { userId, prisma, body } = req; + const { userId, body } = req; /** These are already parsed upstream, we can assume they're good here. */ const parentId = Number(body.parentId); const parentEventType = await prisma.eventType.findUnique({ diff --git a/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts similarity index 90% rename from apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts rename to apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts index bc83c038b3262d..1055dc0d327154 100644 --- a/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts +++ b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import type { z } from "zod"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import type { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type"; @@ -9,7 +10,7 @@ export default async function checkTeamEventEditPermission( req: NextApiRequest, body: Pick, "teamId" | "userId"> ) { - const { prisma, isAdmin } = req; + const { isAdmin } = req; let userId = req.userId; if (isAdmin && body.userId) { userId = body.userId; @@ -25,7 +26,7 @@ export default async function checkTeamEventEditPermission( if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) { throw new HttpError({ - statusCode: 401, + statusCode: 403, message: "No permission to operate on event-type for this team", }); } diff --git a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts b/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts similarity index 95% rename from apps/api/pages/api/event-types/_utils/checkUserMembership.ts rename to apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts index d76fcb89adb513..176d5a93f7dae3 100644 --- a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts +++ b/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; /** * Checks if a user, identified by the provided userId, is a member of the team associated @@ -13,7 +14,7 @@ import { HttpError } from "@calcom/lib/http-error"; * or if the user isn't a member of the associated team. */ export default async function checkUserMembership(req: NextApiRequest) { - const { prisma, body } = req; + const { body } = req; /** These are already parsed upstream, we can assume they're good here. */ const parentId = Number(body.parentId); const userId = Number(body.userId); diff --git a/apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts b/apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts similarity index 87% rename from apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts rename to apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts index 301c5307a1f9d9..f3b29a934ed4ec 100644 --- a/apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts +++ b/apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts @@ -1,6 +1,8 @@ import type { NextApiRequest } from "next"; import type { z } from "zod"; +import prisma from "@calcom/prisma"; + import type { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type"; export default async function ensureOnlyMembersAsHosts( @@ -8,7 +10,7 @@ export default async function ensureOnlyMembersAsHosts( body: Pick, "hosts" | "teamId"> ) { if (body.teamId && body.hosts && body.hosts.length > 0) { - const teamMemberCount = await req.prisma.membership.count({ + const teamMemberCount = await prisma.membership.count({ where: { teamId: body.teamId, userId: { in: body.hosts.map((host) => host.userId) }, diff --git a/apps/api/pages/api/event-types/_utils/getCalLink.ts b/apps/api/v1/pages/api/event-types/_utils/getCalLink.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/getCalLink.ts rename to apps/api/v1/pages/api/event-types/_utils/getCalLink.ts diff --git a/apps/api/pages/api/event-types/index.ts b/apps/api/v1/pages/api/event-types/index.ts similarity index 100% rename from apps/api/pages/api/event-types/index.ts rename to apps/api/v1/pages/api/event-types/index.ts diff --git a/apps/api/pages/api/index.ts b/apps/api/v1/pages/api/index.ts similarity index 100% rename from apps/api/pages/api/index.ts rename to apps/api/v1/pages/api/index.ts diff --git a/apps/api/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts similarity index 97% rename from apps/api/pages/api/invites/_post.ts rename to apps/api/v1/pages/api/invites/_post.ts index 65390129a533e9..94703111a32ad0 100644 --- a/apps/api/pages/api/invites/_post.ts +++ b/apps/api/v1/pages/api/invites/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { createContext } from "@calcom/trpc/server/createContext"; import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema"; @@ -57,7 +58,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { } async function checkPermissions(req: NextApiRequest, body: TInviteMemberInputSchema) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; if (isAdmin) return; // To prevent auto-accepted invites, limit it to ADMIN users if (!isAdmin && "accepted" in body) diff --git a/apps/api/pages/api/invites/index.ts b/apps/api/v1/pages/api/invites/index.ts similarity index 100% rename from apps/api/pages/api/invites/index.ts rename to apps/api/v1/pages/api/invites/index.ts diff --git a/apps/api/pages/api/me/_get.ts b/apps/api/v1/pages/api/me/_get.ts similarity index 79% rename from apps/api/pages/api/me/_get.ts rename to apps/api/v1/pages/api/me/_get.ts index d0a375973059e5..637ebc1bb7bb20 100644 --- a/apps/api/pages/api/me/_get.ts +++ b/apps/api/v1/pages/api/me/_get.ts @@ -1,10 +1,11 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaUserReadPublic } from "~/lib/validations/user"; -async function handler({ userId, prisma }: NextApiRequest) { +async function handler({ userId }: NextApiRequest) { const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); return { user: schemaUserReadPublic.parse(data) }; } diff --git a/apps/api/pages/api/slots/index.ts b/apps/api/v1/pages/api/me/index.ts similarity index 100% rename from apps/api/pages/api/slots/index.ts rename to apps/api/v1/pages/api/me/index.ts diff --git a/apps/api/pages/api/memberships/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts similarity index 89% rename from apps/api/pages/api/memberships/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts index 97ac4ff16d4d63..e5bb676583507f 100644 --- a/apps/api/pages/api/memberships/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { membershipIdSchema } from "~/lib/validations/membership"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { teamId } = membershipIdSchema.parse(req.query); // Admins can just skip this check if (isAdmin) return; diff --git a/apps/api/pages/api/memberships/[id]/_delete.ts b/apps/api/v1/pages/api/memberships/[id]/_delete.ts similarity index 96% rename from apps/api/pages/api/memberships/[id]/_delete.ts rename to apps/api/v1/pages/api/memberships/[id]/_delete.ts index 06acefb3bb9f23..1e624f3adc84e2 100644 --- a/apps/api/pages/api/memberships/[id]/_delete.ts +++ b/apps/api/v1/pages/api/memberships/[id]/_delete.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { membershipIdSchema } from "~/lib/validations/membership"; @@ -34,7 +35,7 @@ import { membershipIdSchema } from "~/lib/validations/membership"; * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const userId_teamId = membershipIdSchema.parse(query); await checkPermissions(req); await prisma.membership.delete({ where: { userId_teamId } }); @@ -42,7 +43,7 @@ export async function deleteHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest) { - const { prisma, isAdmin, userId, query } = req; + const { isAdmin, userId, query } = req; const userId_teamId = membershipIdSchema.parse(query); // Admin User can do anything including deletion of Admin Team Member in any team if (isAdmin) { diff --git a/apps/api/pages/api/memberships/[id]/_get.ts b/apps/api/v1/pages/api/memberships/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/memberships/[id]/_get.ts rename to apps/api/v1/pages/api/memberships/[id]/_get.ts index cf44094bfa2784..c66a09cf135650 100644 --- a/apps/api/pages/api/memberships/[id]/_get.ts +++ b/apps/api/v1/pages/api/memberships/[id]/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { membershipIdSchema, schemaMembershipPublic } from "~/lib/validations/membership"; @@ -34,7 +35,7 @@ import { membershipIdSchema, schemaMembershipPublic } from "~/lib/validations/me * description: Membership was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const userId_teamId = membershipIdSchema.parse(query); const args: Prisma.MembershipFindUniqueOrThrowArgs = { where: { userId_teamId } }; // Just in case the user want to get more info about the team itself diff --git a/apps/api/pages/api/memberships/[id]/_patch.ts b/apps/api/v1/pages/api/memberships/[id]/_patch.ts similarity index 96% rename from apps/api/pages/api/memberships/[id]/_patch.ts rename to apps/api/v1/pages/api/memberships/[id]/_patch.ts index 98115eff39f5c8..3e7dcaffb945cb 100644 --- a/apps/api/pages/api/memberships/[id]/_patch.ts +++ b/apps/api/v1/pages/api/memberships/[id]/_patch.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { membershipEditBodySchema, @@ -39,7 +40,7 @@ import { * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const userId_teamId = membershipIdSchema.parse(query); const data = membershipEditBodySchema.parse(req.body); const args: Prisma.MembershipUpdateArgs = { where: { userId_teamId }, data }; @@ -51,7 +52,7 @@ export async function patchHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { userId: queryUserId, teamId } = membershipIdSchema.parse(req.query); const data = membershipEditBodySchema.parse(req.body); // Admins can just skip this check diff --git a/apps/api/pages/api/event-types/[id]/index.ts b/apps/api/v1/pages/api/memberships/[id]/index.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/index.ts rename to apps/api/v1/pages/api/memberships/[id]/index.ts diff --git a/apps/api/pages/api/memberships/_get.ts b/apps/api/v1/pages/api/memberships/_get.ts similarity index 98% rename from apps/api/pages/api/memberships/_get.ts rename to apps/api/v1/pages/api/memberships/_get.ts index da6e936df3b270..8a42a5dd3af68c 100644 --- a/apps/api/pages/api/memberships/_get.ts +++ b/apps/api/v1/pages/api/memberships/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaMembershipPublic } from "~/lib/validations/membership"; import { @@ -26,7 +27,6 @@ import { * description: No memberships were found */ async function getHandler(req: NextApiRequest) { - const { prisma } = req; const args: Prisma.MembershipFindManyArgs = { where: { /** Admins can query multiple users */ diff --git a/apps/api/pages/api/memberships/_post.ts b/apps/api/v1/pages/api/memberships/_post.ts similarity index 95% rename from apps/api/pages/api/memberships/_post.ts rename to apps/api/v1/pages/api/memberships/_post.ts index 11cba74938de35..e1ee1a622c12bf 100644 --- a/apps/api/pages/api/memberships/_post.ts +++ b/apps/api/v1/pages/api/memberships/_post.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/validations/membership"; @@ -22,7 +23,6 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { prisma } = req; const data = membershipCreateBodySchema.parse(req.body); const args: Prisma.MembershipCreateArgs = { data }; @@ -37,7 +37,7 @@ async function postHandler(req: NextApiRequest) { } async function checkPermissions(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; if (isAdmin) return; const body = membershipCreateBodySchema.parse(req.body); // To prevent auto-accepted invites, limit it to ADMIN users diff --git a/apps/api/pages/api/memberships/index.ts b/apps/api/v1/pages/api/memberships/index.ts similarity index 100% rename from apps/api/pages/api/memberships/index.ts rename to apps/api/v1/pages/api/memberships/index.ts diff --git a/apps/api/pages/api/payments/[id].ts b/apps/api/v1/pages/api/payments/[id].ts similarity index 96% rename from apps/api/pages/api/payments/[id].ts rename to apps/api/v1/pages/api/payments/[id].ts index f8a54ff7f4a17b..0b621cb86d8b6d 100644 --- a/apps/api/pages/api/payments/[id].ts +++ b/apps/api/v1/pages/api/payments/[id].ts @@ -1,5 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@calcom/prisma"; + import { withMiddleware } from "~/lib/helpers/withMiddleware"; import type { PaymentResponse } from "~/lib/types"; import { schemaPaymentPublic } from "~/lib/validations/payment"; @@ -37,7 +39,7 @@ import { * description: Payment was not found */ export async function paymentById( - { method, query, userId, prisma }: NextApiRequest, + { method, query, userId }: NextApiRequest, res: NextApiResponse ) { const safeQuery = schemaQueryIdParseInt.safeParse(query); diff --git a/apps/api/pages/api/payments/index.ts b/apps/api/v1/pages/api/payments/index.ts similarity index 90% rename from apps/api/pages/api/payments/index.ts rename to apps/api/v1/pages/api/payments/index.ts index c6f8c79fecf766..d556245753e058 100644 --- a/apps/api/pages/api/payments/index.ts +++ b/apps/api/v1/pages/api/payments/index.ts @@ -1,5 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@calcom/prisma"; + import { withMiddleware } from "~/lib/helpers/withMiddleware"; import type { PaymentsResponse } from "~/lib/types"; import { schemaPaymentPublic } from "~/lib/validations/payment"; @@ -19,7 +21,7 @@ import { schemaPaymentPublic } from "~/lib/validations/payment"; * 404: * description: No payments were found */ -async function allPayments({ userId, prisma }: NextApiRequest, res: NextApiResponse) { +async function allPayments({ userId }: NextApiRequest, res: NextApiResponse) { const userWithBookings = await prisma.user.findUnique({ where: { id: userId }, include: { bookings: true }, diff --git a/apps/api/pages/api/schedules/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts similarity index 89% rename from apps/api/pages/api/schedules/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts index 42184440d3182d..d622dfcbf68b94 100644 --- a/apps/api/pages/api/schedules/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdParseInt.parse(req.query); // Admins can just skip this check if (isAdmin) return; diff --git a/apps/api/pages/api/schedules/[id]/_delete.ts b/apps/api/v1/pages/api/schedules/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/schedules/[id]/_delete.ts rename to apps/api/v1/pages/api/schedules/[id]/_delete.ts index e48c76c4697f9c..0b2d0198ba9edc 100644 --- a/apps/api/pages/api/schedules/[id]/_delete.ts +++ b/apps/api/v1/pages/api/schedules/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -34,7 +35,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); /* If we're deleting any default user schedule, we unset it */ diff --git a/apps/api/pages/api/schedules/[id]/_get.ts b/apps/api/v1/pages/api/schedules/[id]/_get.ts similarity index 97% rename from apps/api/pages/api/schedules/[id]/_get.ts rename to apps/api/v1/pages/api/schedules/[id]/_get.ts index fdadce2cba8035..8e1a2df5af83d3 100644 --- a/apps/api/pages/api/schedules/[id]/_get.ts +++ b/apps/api/v1/pages/api/schedules/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSchedulePublic } from "~/lib/validations/schedule"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -72,7 +73,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = await prisma.schedule.findUniqueOrThrow({ where: { id }, include: { availability: true } }); return { schedule: schemaSchedulePublic.parse(data) }; diff --git a/apps/api/pages/api/schedules/[id]/_patch.ts b/apps/api/v1/pages/api/schedules/[id]/_patch.ts similarity index 98% rename from apps/api/pages/api/schedules/[id]/_patch.ts rename to apps/api/v1/pages/api/schedules/[id]/_patch.ts index 1c8f52d688e81c..f9009e30bfb3d5 100644 --- a/apps/api/pages/api/schedules/[id]/_patch.ts +++ b/apps/api/v1/pages/api/schedules/[id]/_patch.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSchedulePublic, schemaSingleScheduleBodyParams } from "~/lib/validations/schedule"; import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; @@ -81,7 +82,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdParseInt.parse(query); const data = schemaSingleScheduleBodyParams.parse(req.body); await checkPermissions(req, data); diff --git a/apps/api/pages/api/memberships/[id]/index.ts b/apps/api/v1/pages/api/schedules/[id]/index.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/index.ts rename to apps/api/v1/pages/api/schedules/[id]/index.ts diff --git a/apps/api/pages/api/schedules/_get.ts b/apps/api/v1/pages/api/schedules/_get.ts similarity index 97% rename from apps/api/pages/api/schedules/_get.ts rename to apps/api/v1/pages/api/schedules/_get.ts index 1b06a116e9a987..bbe893516e5796 100644 --- a/apps/api/pages/api/schedules/_get.ts +++ b/apps/api/v1/pages/api/schedules/_get.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSchedulePublic } from "~/lib/validations/schedule"; import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; @@ -76,7 +77,7 @@ export const schemaUserIds = z */ async function handler(req: NextApiRequest) { - const { prisma, userId, isAdmin } = req; + const { userId, isAdmin } = req; const args: Prisma.ScheduleFindManyArgs = isAdmin ? {} : { where: { userId } }; args.include = { availability: true }; diff --git a/apps/api/pages/api/schedules/_post.ts b/apps/api/v1/pages/api/schedules/_post.ts similarity index 98% rename from apps/api/pages/api/schedules/_post.ts rename to apps/api/v1/pages/api/schedules/_post.ts index a50f2cd5b61123..05215e3d47a896 100644 --- a/apps/api/pages/api/schedules/_post.ts +++ b/apps/api/v1/pages/api/schedules/_post.ts @@ -4,6 +4,7 @@ import type { NextApiRequest } from "next"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaCreateScheduleBodyParams, schemaSchedulePublic } from "~/lib/validations/schedule"; @@ -79,7 +80,7 @@ import { schemaCreateScheduleBodyParams, schemaSchedulePublic } from "~/lib/vali */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const body = schemaCreateScheduleBodyParams.parse(req.body); let args: Prisma.ScheduleCreateArgs = { data: { ...body, userId } }; diff --git a/apps/api/pages/api/schedules/index.ts b/apps/api/v1/pages/api/schedules/index.ts similarity index 100% rename from apps/api/pages/api/schedules/index.ts rename to apps/api/v1/pages/api/schedules/index.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts similarity index 96% rename from apps/api/pages/api/selected-calendars/[id]/_delete.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts index e04b67f711261e..899e7a08081e79 100644 --- a/apps/api/pages/api/selected-calendars/[id]/_delete.ts +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; @@ -46,7 +47,7 @@ import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const userId_integration_externalId = selectedCalendarIdSchema.parse(query); await prisma.selectedCalendar.delete({ where: { userId_integration_externalId } }); return { message: `Selected Calendar with id: ${query.id} deleted successfully` }; diff --git a/apps/api/pages/api/selected-calendars/[id]/_get.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts similarity index 96% rename from apps/api/pages/api/selected-calendars/[id]/_get.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_get.ts index a5549f1a214224..9bc044b4bf8052 100644 --- a/apps/api/pages/api/selected-calendars/[id]/_get.ts +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/validations/selected-calendar"; @@ -46,7 +47,7 @@ import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/va * description: SelectedCalendar was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const userId_integration_externalId = selectedCalendarIdSchema.parse(query); const data = await prisma.selectedCalendar.findUniqueOrThrow({ where: { userId_integration_externalId }, diff --git a/apps/api/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts similarity index 97% rename from apps/api/pages/api/selected-calendars/[id]/_patch.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts index c2b526303daa70..84b60d12106b0c 100644 --- a/apps/api/pages/api/selected-calendars/[id]/_patch.ts +++ b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSelectedCalendarPublic, @@ -52,7 +53,7 @@ import { * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, isAdmin } = req; + const { query, isAdmin } = req; const userId_integration_externalId = selectedCalendarIdSchema.parse(query); const { userId: bodyUserId, ...data } = schemaSelectedCalendarUpdateBodyParams.parse(req.body); const args: Prisma.SelectedCalendarUpdateArgs = { where: { userId_integration_externalId }, data }; diff --git a/apps/api/pages/api/schedules/[id]/index.ts b/apps/api/v1/pages/api/selected-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/index.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/index.ts diff --git a/apps/api/pages/api/selected-calendars/_get.ts b/apps/api/v1/pages/api/selected-calendars/_get.ts similarity index 96% rename from apps/api/pages/api/selected-calendars/_get.ts rename to apps/api/v1/pages/api/selected-calendars/_get.ts index c5e2182adf54c3..1d4bdf9d6bf48f 100644 --- a/apps/api/pages/api/selected-calendars/_get.ts +++ b/apps/api/v1/pages/api/selected-calendars/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSelectedCalendarPublic } from "~/lib/validations/selected-calendar"; import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; @@ -31,7 +32,7 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que * description: No selected calendars were found */ async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; /* Admin gets all selected calendar by default, otherwise only the user's ones */ const args: Prisma.SelectedCalendarFindManyArgs = isAdmin ? {} : { where: { userId } }; diff --git a/apps/api/pages/api/selected-calendars/_post.ts b/apps/api/v1/pages/api/selected-calendars/_post.ts similarity index 96% rename from apps/api/pages/api/selected-calendars/_post.ts rename to apps/api/v1/pages/api/selected-calendars/_post.ts index 23b26a76e981cd..d0509df9c1cebb 100644 --- a/apps/api/pages/api/selected-calendars/_post.ts +++ b/apps/api/v1/pages/api/selected-calendars/_post.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaSelectedCalendarBodyParams, @@ -49,7 +50,7 @@ import { * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body); const args: Prisma.SelectedCalendarCreateArgs = { data: { ...body, userId } }; diff --git a/apps/api/pages/api/selected-calendars/index.ts b/apps/api/v1/pages/api/selected-calendars/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/index.ts rename to apps/api/v1/pages/api/selected-calendars/index.ts diff --git a/apps/api/pages/api/slots/_get.test.ts b/apps/api/v1/pages/api/slots/_get.test.ts similarity index 97% rename from apps/api/pages/api/slots/_get.test.ts rename to apps/api/v1/pages/api/slots/_get.test.ts index 58e77fe290fe6e..7484d298e08f1b 100644 --- a/apps/api/pages/api/slots/_get.test.ts +++ b/apps/api/v1/pages/api/slots/_get.test.ts @@ -1,4 +1,4 @@ -import prismock from "../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; diff --git a/apps/api/pages/api/slots/_get.ts b/apps/api/v1/pages/api/slots/_get.ts similarity index 100% rename from apps/api/pages/api/slots/_get.ts rename to apps/api/v1/pages/api/slots/_get.ts diff --git a/apps/api/pages/api/teams/[teamId]/event-types/index.ts b/apps/api/v1/pages/api/slots/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/event-types/index.ts rename to apps/api/v1/pages/api/slots/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts similarity index 88% rename from apps/api/pages/api/teams/[teamId]/_auth-middleware.ts rename to apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts index ec172679c2919b..693eb544a544db 100644 --- a/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts @@ -2,12 +2,13 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; async function authMiddleware(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const { teamId } = schemaQueryTeamId.parse(req.query); /** Admins can skip the ownership verification */ if (isAdmin) return; @@ -21,17 +22,16 @@ export async function checkPermissions( req: NextApiRequest, role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER ) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const { teamId } = schemaQueryTeamId.parse({ teamId: req.query.teamId, version: req.query.version, apiKey: req.query.apiKey, }); - return canUserAccessTeamWithRole(prisma, userId, isAdmin, teamId, role); + return canUserAccessTeamWithRole(userId, isAdmin, teamId, role); } export async function canUserAccessTeamWithRole( - prisma: NextApiRequest["prisma"], userId: number, isAdmin: boolean, teamId: number, diff --git a/apps/api/pages/api/teams/[teamId]/_delete.ts b/apps/api/v1/pages/api/teams/[teamId]/_delete.ts similarity index 95% rename from apps/api/pages/api/teams/[teamId]/_delete.ts rename to apps/api/v1/pages/api/teams/[teamId]/_delete.ts index 13c5c35370f3c3..dfe2ad21edf6d6 100644 --- a/apps/api/pages/api/teams/[teamId]/_delete.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; @@ -36,7 +37,7 @@ import { checkPermissions } from "./_auth-middleware"; * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { teamId } = schemaQueryTeamId.parse(query); await checkPermissions(req); await prisma.team.delete({ where: { id: teamId } }); diff --git a/apps/api/pages/api/teams/[teamId]/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/_get.ts similarity index 95% rename from apps/api/pages/api/teams/[teamId]/_get.ts rename to apps/api/v1/pages/api/teams/[teamId]/_get.ts index cff0e987c907c5..829a4104b60354 100644 --- a/apps/api/pages/api/teams/[teamId]/_get.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId"; import { schemaTeamReadPublic } from "~/lib/validations/team"; @@ -36,7 +37,7 @@ import { schemaTeamReadPublic } from "~/lib/validations/team"; * description: Team was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, isAdmin, userId } = req; + const { isAdmin, userId } = req; const { teamId } = schemaQueryTeamId.parse(req.query); const where: Prisma.TeamWhereInput = { id: teamId }; // Non-admins can only query the teams they're part of diff --git a/apps/api/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts similarity index 94% rename from apps/api/pages/api/teams/[teamId]/_patch.ts rename to apps/api/v1/pages/api/teams/[teamId]/_patch.ts index 9bfbea3a5013bf..2e1fcc5ca92f53 100644 --- a/apps/api/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -1,10 +1,11 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; -import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -55,7 +56,7 @@ import { schemaTeamReadPublic, schemaTeamUpdateBodyParams } from "~/lib/validati * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, body, userId } = req; + const { body, userId } = req; const data = schemaTeamUpdateBodyParams.parse(body); const { teamId } = schemaQueryTeamId.parse(req.query); @@ -104,10 +105,11 @@ export async function patchHandler(req: NextApiRequest) { }; delete data.slug; if (IS_TEAM_BILLING_ENABLED) { - const checkoutSession = await purchaseTeamSubscription({ + const checkoutSession = await purchaseTeamOrOrgSubscription({ teamId: _team.id, seats: _team.members.length, userId, + pricePerSeat: null, }); if (!checkoutSession.url) throw new TRPCError({ diff --git a/apps/api/pages/api/teams/[teamId]/availability/index.ts b/apps/api/v1/pages/api/teams/[teamId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/availability/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/availability/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/event-types/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts similarity index 96% rename from apps/api/pages/api/teams/[teamId]/event-types/_get.ts rename to apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts index 977b4c28dd2d1a..5ef33379c0ce9a 100644 --- a/apps/api/pages/api/teams/[teamId]/event-types/_get.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { z } from "zod"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaEventTypeReadPublic } from "~/lib/validations/event-type"; @@ -41,7 +42,7 @@ const querySchema = z.object({ * description: No event types were found */ async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { teamId } = querySchema.parse(req.query); diff --git a/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts new file mode 100644 index 00000000000000..c53e4b8ef39a6c --- /dev/null +++ b/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts @@ -0,0 +1,9 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +export default withMiddleware()( + defaultHandler({ + GET: import("./_get"), + }) +); diff --git a/apps/api/pages/api/selected-calendars/[id]/index.ts b/apps/api/v1/pages/api/teams/[teamId]/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/publish.ts b/apps/api/v1/pages/api/teams/[teamId]/publish.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/publish.ts rename to apps/api/v1/pages/api/teams/[teamId]/publish.ts diff --git a/apps/api/pages/api/teams/_get.ts b/apps/api/v1/pages/api/teams/_get.ts similarity index 93% rename from apps/api/pages/api/teams/_get.ts rename to apps/api/v1/pages/api/teams/_get.ts index 49af07ac8ee467..c5ebe8cf081a48 100644 --- a/apps/api/pages/api/teams/_get.ts +++ b/apps/api/v1/pages/api/teams/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaTeamsReadPublic } from "~/lib/validations/team"; @@ -29,7 +30,7 @@ import { schemaTeamsReadPublic } from "~/lib/validations/team"; * description: No teams were found */ async function getHandler(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; + const { userId, isAdmin } = req; const where: Prisma.TeamWhereInput = {}; // If user is not ADMIN, return only his data. if (!isAdmin) where.members = { some: { userId } }; diff --git a/apps/api/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts similarity index 96% rename from apps/api/pages/api/teams/_post.ts rename to apps/api/v1/pages/api/teams/_post.ts index f6f95cdc79a73f..8fcc00fe2211f7 100644 --- a/apps/api/pages/api/teams/_post.ts +++ b/apps/api/v1/pages/api/teams/_post.ts @@ -2,9 +2,11 @@ import type { NextApiRequest } from "next"; import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { schemaMembershipPublic } from "~/lib/validations/membership"; @@ -78,7 +80,7 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { prisma, body, userId, isAdmin } = req; + const { body, userId, isAdmin } = req; const { ownerId, ...data } = schemaTeamCreateBodyParams.parse(body); await checkPermissions(req); @@ -196,8 +198,9 @@ const generateTeamCheckoutSession = async ({ customer_update: { address: "auto", }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode automatic_tax: { - enabled: true, + enabled: IS_PRODUCTION, }, metadata: { pendingPaymentTeamId, diff --git a/apps/api/pages/api/teams/index.ts b/apps/api/v1/pages/api/teams/index.ts similarity index 100% rename from apps/api/pages/api/teams/index.ts rename to apps/api/v1/pages/api/teams/index.ts diff --git a/apps/api/pages/api/users/[userId]/_delete.ts b/apps/api/v1/pages/api/users/[userId]/_delete.ts similarity index 96% rename from apps/api/pages/api/users/[userId]/_delete.ts rename to apps/api/v1/pages/api/users/[userId]/_delete.ts index 6b54fd3beefbf6..90d38aad366b83 100644 --- a/apps/api/pages/api/users/[userId]/_delete.ts +++ b/apps/api/v1/pages/api/users/[userId]/_delete.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { deleteUser } from "@calcom/features/users/lib/userDeletionService"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; @@ -37,7 +38,7 @@ import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; + const { isAdmin } = req; const query = schemaQueryUserId.parse(req.query); // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); diff --git a/apps/api/pages/api/users/[userId]/_get.ts b/apps/api/v1/pages/api/users/[userId]/_get.ts similarity index 96% rename from apps/api/pages/api/users/[userId]/_get.ts rename to apps/api/v1/pages/api/users/[userId]/_get.ts index 5be3926ae06488..215cf8173561a1 100644 --- a/apps/api/pages/api/users/[userId]/_get.ts +++ b/apps/api/v1/pages/api/users/[userId]/_get.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; import { schemaUserReadPublic } from "~/lib/validations/user"; @@ -37,7 +38,7 @@ import { schemaUserReadPublic } from "~/lib/validations/user"; * description: User was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; + const { isAdmin } = req; const query = schemaQueryUserId.parse(req.query); // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user diff --git a/apps/api/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts similarity index 98% rename from apps/api/pages/api/users/[userId]/_patch.ts rename to apps/api/v1/pages/api/users/[userId]/_patch.ts index ff917c2048d69d..e622a43114f74f 100644 --- a/apps/api/pages/api/users/[userId]/_patch.ts +++ b/apps/api/v1/pages/api/users/[userId]/_patch.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId"; import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user"; @@ -94,7 +95,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation * description: Insufficient permissions to access resource. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; + const { isAdmin } = req; const query = schemaQueryUserId.parse(req.query); // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); diff --git a/apps/api/pages/api/users/[userId]/availability/index.ts b/apps/api/v1/pages/api/users/[userId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/availability/index.ts rename to apps/api/v1/pages/api/users/[userId]/availability/index.ts diff --git a/apps/api/pages/api/users/[userId]/index.ts b/apps/api/v1/pages/api/users/[userId]/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/index.ts rename to apps/api/v1/pages/api/users/[userId]/index.ts diff --git a/apps/api/pages/api/users/_get.ts b/apps/api/v1/pages/api/users/_get.ts similarity index 98% rename from apps/api/pages/api/users/_get.ts rename to apps/api/v1/pages/api/users/_get.ts index 3ffa5fce794a66..dcb26d70b68aa0 100644 --- a/apps/api/pages/api/users/_get.ts +++ b/apps/api/v1/pages/api/users/_get.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { withMiddleware } from "~/lib/helpers/withMiddleware"; import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail"; @@ -44,7 +45,6 @@ import { schemaUsersReadPublic } from "~/lib/validations/user"; export async function getHandler(req: NextApiRequest) { const { userId, - prisma, isAdmin, pagination: { take, skip }, } = req; diff --git a/apps/api/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts similarity index 98% rename from apps/api/pages/api/users/_post.ts rename to apps/api/v1/pages/api/users/_post.ts index bcc4f24315209b..9b23ebeca5bb07 100644 --- a/apps/api/pages/api/users/_post.ts +++ b/apps/api/v1/pages/api/users/_post.ts @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaUserCreateBodyParams } from "~/lib/validations/user"; @@ -87,7 +88,7 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user"; * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { prisma, isAdmin } = req; + const { isAdmin } = req; // If user is not ADMIN, return unauthorized. if (!isAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" }); const data = await schemaUserCreateBodyParams.parseAsync(req.body); diff --git a/apps/api/pages/api/users/index.ts b/apps/api/v1/pages/api/users/index.ts similarity index 100% rename from apps/api/pages/api/users/index.ts rename to apps/api/v1/pages/api/users/index.ts diff --git a/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts similarity index 90% rename from apps/api/pages/api/webhooks/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts index 6f2eb04db50f90..ce45765eaa0770 100644 --- a/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts +++ b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts @@ -1,11 +1,12 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; async function authMiddleware(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { id } = schemaQueryIdAsString.parse(req.query); // Admins can just skip this check if (isAdmin) return; diff --git a/apps/api/pages/api/webhooks/[id]/_delete.ts b/apps/api/v1/pages/api/webhooks/[id]/_delete.ts similarity index 95% rename from apps/api/pages/api/webhooks/[id]/_delete.ts rename to apps/api/v1/pages/api/webhooks/[id]/_delete.ts index 4b1ea3404e1353..894a1b5e3c8a20 100644 --- a/apps/api/pages/api/webhooks/[id]/_delete.ts +++ b/apps/api/v1/pages/api/webhooks/[id]/_delete.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; @@ -36,7 +37,7 @@ import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; * description: Authorization information is missing or invalid. */ export async function deleteHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdAsString.parse(query); await prisma.webhook.delete({ where: { id } }); return { message: `Webhook with id: ${id} deleted successfully` }; diff --git a/apps/api/pages/api/webhooks/[id]/_get.ts b/apps/api/v1/pages/api/webhooks/[id]/_get.ts similarity index 95% rename from apps/api/pages/api/webhooks/[id]/_get.ts rename to apps/api/v1/pages/api/webhooks/[id]/_get.ts index 3bde62987a958f..7c079c2df006ed 100644 --- a/apps/api/pages/api/webhooks/[id]/_get.ts +++ b/apps/api/v1/pages/api/webhooks/[id]/_get.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; @@ -37,7 +38,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; * description: Webhook was not found */ export async function getHandler(req: NextApiRequest) { - const { prisma, query } = req; + const { query } = req; const { id } = schemaQueryIdAsString.parse(query); const data = await prisma.webhook.findUniqueOrThrow({ where: { id } }); return { webhook: schemaWebhookReadPublic.parse(data) }; diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts similarity index 97% rename from apps/api/pages/api/webhooks/[id]/_patch.ts rename to apps/api/v1/pages/api/webhooks/[id]/_patch.ts index 073a7f0704219e..f41f7751505263 100644 --- a/apps/api/pages/api/webhooks/[id]/_patch.ts +++ b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString"; import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; @@ -67,7 +68,7 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali * description: Authorization information is missing or invalid. */ export async function patchHandler(req: NextApiRequest) { - const { prisma, query, userId, isAdmin } = req; + const { query, userId, isAdmin } = req; const { id } = schemaQueryIdAsString.parse(query); const { eventTypeId, diff --git a/apps/api/pages/api/teams/[teamId]/index.ts b/apps/api/v1/pages/api/webhooks/[id]/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/index.ts rename to apps/api/v1/pages/api/webhooks/[id]/index.ts diff --git a/apps/api/pages/api/webhooks/_get.ts b/apps/api/v1/pages/api/webhooks/_get.ts similarity index 96% rename from apps/api/pages/api/webhooks/_get.ts rename to apps/api/v1/pages/api/webhooks/_get.ts index 8708c303e8e1a1..79b712e742d5a0 100644 --- a/apps/api/pages/api/webhooks/_get.ts +++ b/apps/api/v1/pages/api/webhooks/_get.ts @@ -3,6 +3,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId"; import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; @@ -33,7 +34,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook"; * description: No webhooks were found */ async function getHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const args: Prisma.WebhookFindManyArgs = isAdmin ? {} : { where: { OR: [{ eventType: { userId } }, { userId }] } }; diff --git a/apps/api/pages/api/webhooks/_post.ts b/apps/api/v1/pages/api/webhooks/_post.ts similarity index 98% rename from apps/api/pages/api/webhooks/_post.ts rename to apps/api/v1/pages/api/webhooks/_post.ts index 05e46d3a951691..36e470e0c9eb9f 100644 --- a/apps/api/pages/api/webhooks/_post.ts +++ b/apps/api/v1/pages/api/webhooks/_post.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/validations/webhook"; @@ -65,7 +66,7 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { userId, isAdmin, prisma } = req; + const { userId, isAdmin } = req; const { eventTypeId, userId: bodyUserId, diff --git a/apps/api/pages/api/webhooks/index.ts b/apps/api/v1/pages/api/webhooks/index.ts similarity index 100% rename from apps/api/pages/api/webhooks/index.ts rename to apps/api/v1/pages/api/webhooks/index.ts diff --git a/apps/api/scripts/vercel-deploy.sh b/apps/api/v1/scripts/vercel-deploy.sh similarity index 100% rename from apps/api/scripts/vercel-deploy.sh rename to apps/api/v1/scripts/vercel-deploy.sh diff --git a/apps/api/sentry.client.config.ts b/apps/api/v1/sentry.client.config.ts similarity index 100% rename from apps/api/sentry.client.config.ts rename to apps/api/v1/sentry.client.config.ts diff --git a/apps/api/sentry.edge.config.ts b/apps/api/v1/sentry.edge.config.ts similarity index 100% rename from apps/api/sentry.edge.config.ts rename to apps/api/v1/sentry.edge.config.ts diff --git a/apps/api/sentry.server.config.ts b/apps/api/v1/sentry.server.config.ts similarity index 100% rename from apps/api/sentry.server.config.ts rename to apps/api/v1/sentry.server.config.ts diff --git a/apps/api/test/README.md b/apps/api/v1/test/README.md similarity index 100% rename from apps/api/test/README.md rename to apps/api/v1/test/README.md diff --git a/apps/api/test/docker-compose.yml b/apps/api/v1/test/docker-compose.yml similarity index 100% rename from apps/api/test/docker-compose.yml rename to apps/api/v1/test/docker-compose.yml diff --git a/apps/api/test/jest-resolver.js b/apps/api/v1/test/jest-resolver.js similarity index 100% rename from apps/api/test/jest-resolver.js rename to apps/api/v1/test/jest-resolver.js diff --git a/apps/api/test/jest-setup.js b/apps/api/v1/test/jest-setup.js similarity index 100% rename from apps/api/test/jest-setup.js rename to apps/api/v1/test/jest-setup.js diff --git a/apps/api/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts similarity index 99% rename from apps/api/test/lib/bookings/_post.test.ts rename to apps/api/v1/test/lib/bookings/_post.test.ts index 64abddcfe3462b..e34defc601fe6a 100644 --- a/apps/api/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -1,5 +1,5 @@ // TODO: Fix tests (These test were never running due to the vitest workspace config) -import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; diff --git a/apps/api/v1/test/lib/event-types/[id]/_get.test.ts b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts new file mode 100644 index 00000000000000..84c057c2228ff0 --- /dev/null +++ b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts @@ -0,0 +1,143 @@ +import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test } from "vitest"; + +import { buildEventType } from "@calcom/lib/test/builder"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import handler from "../../../../pages/api/event-types/[id]/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +describe("GET /api/event-types/[id]", () => { + describe("Errors", () => { + test("Returns 403 if user not admin/team member/event owner", async () => { + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: 123456, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: 123456, + userId: 444444, + }) + ); + + req.userId = 333333; + await handler(req, res); + + expect(res.statusCode).toBe(403); + }); + }); + + describe("Success", async () => { + test("Returns event type if user is admin", async () => { + const eventTypeId = 123456; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + }) + ); + + req.isAdmin = true; + req.userId = 333333; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + }); + + test("Returns event type if user is in team associated with event type", async () => { + const eventTypeId = 123456; + const teamId = 9999; + const userId = 333333; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + teamId, + }) + ); + + prismaMock.team.findFirst.mockResolvedValue({ + id: teamId, + members: [ + { + userId, + }, + ], + }); + + req.isAdmin = false; + req.userId = userId; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + expect(prismaMock.team.findFirst).toHaveBeenCalledWith({ + where: { + id: teamId, + members: { + some: { + userId: req.userId, + role: { + in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], + }, + }, + }, + }, + }); + }); + + test("Returns event type if user is the event type owner", async () => { + const eventTypeId = 123456; + const userId = 333333; + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: eventTypeId, + }, + }); + + prismaMock.eventType.findUnique.mockResolvedValue( + buildEventType({ + id: eventTypeId, + userId, + scheduleId: 1111, + }) + ); + + req.isAdmin = false; + req.userId = userId; + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId); + expect(prismaMock.team.findFirst).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/test/lib/middleware/addRequestId.test.ts b/apps/api/v1/test/lib/middleware/addRequestId.test.ts similarity index 100% rename from apps/api/test/lib/middleware/addRequestId.test.ts rename to apps/api/v1/test/lib/middleware/addRequestId.test.ts diff --git a/apps/api/test/lib/middleware/httpMethods.test.ts b/apps/api/v1/test/lib/middleware/httpMethods.test.ts similarity index 100% rename from apps/api/test/lib/middleware/httpMethods.test.ts rename to apps/api/v1/test/lib/middleware/httpMethods.test.ts diff --git a/apps/api/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts similarity index 100% rename from apps/api/test/lib/middleware/verifyApiKey.test.ts rename to apps/api/v1/test/lib/middleware/verifyApiKey.test.ts diff --git a/apps/api/tsconfig.json b/apps/api/v1/tsconfig.json similarity index 73% rename from apps/api/tsconfig.json rename to apps/api/v1/tsconfig.json index c6b3666313f6f3..9cca89adf506e9 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/v1/tsconfig.json @@ -7,14 +7,15 @@ "paths": { "~/*": ["*"], "@prisma/client/*": ["@calcom/prisma/client/*"] - } + }, + "experimentalDecorators": true }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", - "../../packages/types/*.d.ts", - "../../packages/types/next-auth.d.ts" + "../../../packages/types/*.d.ts", + "../../../packages/types/next-auth.d.ts" ], "exclude": ["node_modules", "templates", "auth"] } diff --git a/apps/api/vercel.json b/apps/api/v1/vercel.json similarity index 100% rename from apps/api/vercel.json rename to apps/api/v1/vercel.json diff --git a/apps/api/v2/.dockerignore b/apps/api/v2/.dockerignore new file mode 100644 index 00000000000000..569ce539708a55 --- /dev/null +++ b/apps/api/v2/.dockerignore @@ -0,0 +1,2 @@ +**/node_modules +**/dist \ No newline at end of file diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example new file mode 100644 index 00000000000000..e06b413342bb5f --- /dev/null +++ b/apps/api/v2/.env.example @@ -0,0 +1,13 @@ +NODE_ENV= +API_PORT= +API_URL= +DATABASE_READ_URL= +DATABASE_WRITE_URL= +LOG_LEVEL= +NEXTAUTH_SECRET= +DATABASE_URL= +JWT_SECRET= +SENTRY_DSN= + +# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2 +NEXT_PUBLIC_SENTRY_DSN= \ No newline at end of file diff --git a/apps/api/v2/.eslintrc.js b/apps/api/v2/.eslintrc.js new file mode 100644 index 00000000000000..1c4289dc6b6b03 --- /dev/null +++ b/apps/api/v2/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + sourceType: "module", + }, + plugins: ["@typescript-eslint/eslint-plugin"], + extends: ["plugin:@typescript-eslint/recommended"], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: [".eslintrc.js"], + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + overrides: [ + { + files: ["./src/**/*.controller.ts"], + excludedFiles: "*.spec.js", + rules: { + "@typescript-eslint/explicit-function-return-type": "error", + }, + }, + ], +}; diff --git a/apps/api/v2/.gitignore b/apps/api/v2/.gitignore new file mode 100644 index 00000000000000..0cf21bfcd21152 --- /dev/null +++ b/apps/api/v2/.gitignore @@ -0,0 +1,44 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.* +!.env.example +!.env.appStore.example \ No newline at end of file diff --git a/apps/api/v2/.prettierrc.js b/apps/api/v2/.prettierrc.js new file mode 100644 index 00000000000000..ba4100a4efe045 --- /dev/null +++ b/apps/api/v2/.prettierrc.js @@ -0,0 +1,6 @@ +const rootConfig = require("../../../packages/config/prettier-preset"); + +module.exports = { + ...rootConfig, + importOrderParserPlugins: ["typescript", "decorators-legacy"], +}; diff --git a/apps/api/v2/Dockerfile b/apps/api/v2/Dockerfile new file mode 100644 index 00000000000000..79626c1ac9a536 --- /dev/null +++ b/apps/api/v2/Dockerfile @@ -0,0 +1,28 @@ +FROM node:18-alpine as build + +ARG DATABASE_DIRECT_URL +ARG DATABASE_URL + +WORKDIR /calcom + +RUN set -eux; + +ENV NODE_ENV="production" +ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL} +ENV DATABASE_URL=${DATABASE_URL} + +COPY . . + +RUN yarn install + +# Build prisma schema and make sure that it is linked to v2 node_modules +RUN yarn workspace @calcom/api-v2 run generate-schemas +RUN rm -rf apps/api/v2/node_modules +RUN yarn install + +RUN yarn workspace @calcom/api-v2 run build + +EXPOSE 80 + +CMD [ "yarn", "workspace", "@calcom/api-v2", "start:prod"] diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md new file mode 100644 index 00000000000000..64af00f7fb28dd --- /dev/null +++ b/apps/api/v2/README.md @@ -0,0 +1,83 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Prisma setup + +```bash +$ yarn prisma generate +``` + +## Env setup + +Copy `.env.example` to `.env` and fill values. + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/api/v2/docker-compose.yaml b/apps/api/v2/docker-compose.yaml new file mode 100644 index 00000000000000..cd30f07b5665b8 --- /dev/null +++ b/apps/api/v2/docker-compose.yaml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + redis: + image: redis:latest + container_name: redis_container + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + +volumes: + redis_data: diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json new file mode 100644 index 00000000000000..3f5b2573324b3b --- /dev/null +++ b/apps/api/v2/jest-e2e.json @@ -0,0 +1,14 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "moduleNameMapper": { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1" + }, + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "setupFiles": ["/test/setEnvVars.ts"] +} diff --git a/apps/api/v2/jest.config.json b/apps/api/v2/jest.config.json new file mode 100644 index 00000000000000..a7b6a8c8885869 --- /dev/null +++ b/apps/api/v2/jest.config.json @@ -0,0 +1,14 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "moduleNameMapper": { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1" + }, + "testEnvironment": "node", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "setupFiles": ["/test/setEnvVars.ts"] +} diff --git a/apps/api/v2/nest-cli.json b/apps/api/v2/nest-cli.json new file mode 100644 index 00000000000000..1eecbdbf6888f3 --- /dev/null +++ b/apps/api/v2/nest-cli.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true } + } + ] + } +} diff --git a/apps/api/v2/next-i18next.config.js b/apps/api/v2/next-i18next.config.js new file mode 100644 index 00000000000000..a07cf209817826 --- /dev/null +++ b/apps/api/v2/next-i18next.config.js @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require("path"); +const i18nConfig = require("@calcom/config/next-i18next.config"); + +/** @type {import("next-i18next").UserConfig} */ +const config = { + ...i18nConfig, + localePath: path.resolve("../../web/public/static/locales"), +}; + +module.exports = config; diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json new file mode 100644 index 00000000000000..d88131c3e10fa1 --- /dev/null +++ b/apps/api/v2/package.json @@ -0,0 +1,88 @@ +{ + "name": "@calcom/api-v2", + "version": "0.0.1", + "description": "Platform API for Cal.com", + "author": "Cal.com Inc.", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "yarn dev:build && nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch", + "dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build", + "dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/src/main", + "test": "yarn dev:build && jest", + "test:watch": "yarn dev:build && jest --watch", + "test:cov": "yarn dev:build && jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "yarn dev:build && jest --runInBand --config ./jest-e2e.json", + "prisma": "yarn workspace @calcom/prisma prisma", + "generate-schemas": "yarn prisma generate && yarn prisma format", + "copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts" + }, + "dependencies": { + "@calcom/platform-constants": "*", + "@calcom/platform-libraries": "*", + "@calcom/platform-types": "*", + "@calcom/platform-utils": "*", + "@calcom/prisma": "*", + "@golevelup/ts-jest": "^0.4.0", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.2", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "^5.1.1", + "@sentry/node": "^7.86.0", + "@sentry/tracing": "^7.86.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "cookie-parser": "^1.4.6", + "dotenv": "^16.3.1", + "fs-extra": "^11.2.0", + "googleapis": "^84.0.0", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "luxon": "^3.4.4", + "nest-winston": "^1.9.4", + "nestjs-throttler-storage-redis": "^0.4.1", + "next-auth": "^4.22.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "uuid": "^8.3.2", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.6", + "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.5.10", + "@types/luxon": "^3.3.7", + "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", + "@types/supertest": "^2.0.12", + "jest": "^29.7.0", + "prettier": "^2.8.6", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^4.9.4" + }, + "prisma": { + "schema": "../../../packages/prisma/schema.prisma" + } +} diff --git a/apps/api/v2/src/app.controller.ts b/apps/api/v2/src/app.controller.ts new file mode 100644 index 00000000000000..080e1ac4b77eb8 --- /dev/null +++ b/apps/api/v2/src/app.controller.ts @@ -0,0 +1,14 @@ +import { getEnv } from "@/env"; +import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common"; +import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; + +@Controller() +@DocsTags("Health - development only") +@DocsExcludeController(getEnv("NODE_ENV") === "production") +export class AppController { + @Get("health") + @Version(VERSION_NEUTRAL) + getHealth(): "OK" { + return "OK"; + } +} diff --git a/apps/api/v2/src/app.e2e-spec.ts b/apps/api/v2/src/app.e2e-spec.ts new file mode 100644 index 00000000000000..41bc985250b380 --- /dev/null +++ b/apps/api/v2/src/app.e2e-spec.ts @@ -0,0 +1,26 @@ +import { AppModule } from "@/app.module"; +import { INestApplication } from "@nestjs/common"; +import { TestingModule } from "@nestjs/testing"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; + +describe("AppController (e2e)", () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it("/ (GET)", () => { + return request(app.getHttpServer()).get("/health").expect("OK"); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/apps/api/v2/src/app.logger.middleware.ts b/apps/api/v2/src/app.logger.middleware.ts new file mode 100644 index 00000000000000..e69a0282f99d86 --- /dev/null +++ b/apps/api/v2/src/app.logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, NextFunction } from "express"; + +import { Response } from "@calcom/platform-types"; + +@Injectable() +export class AppLoggerMiddleware implements NestMiddleware { + private logger = new Logger("HTTP"); + + use(request: Request, response: Response, next: NextFunction): void { + const { ip, method, originalUrl } = request; + const userAgent = request.get("user-agent") || ""; + + response.on("close", () => { + const { statusCode } = response; + const contentLength = response.get("content-length"); + this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`); + }); + next(); + } +} diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts new file mode 100644 index 00000000000000..a589abe5260121 --- /dev/null +++ b/apps/api/v2/src/app.module.ts @@ -0,0 +1,49 @@ +import { AppLoggerMiddleware } from "@/app.logger.middleware"; +import { RewriterMiddleware } from "@/app.rewrites.middleware"; +import appConfig from "@/config/app"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { EndpointsModule } from "@/modules/endpoints.module"; +import { JwtModule } from "@/modules/jwt/jwt.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { RouterModule } from "@nestjs/core"; + +import { AppController } from "./app.controller"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [appConfig], + }), + // ThrottlerModule.forRootAsync({ + // imports: [ConfigModule], + // inject: [ConfigService], + // useFactory: (config: ConfigService) => ({ + // throttlers: [ + // { + // name: "short", + // ttl: seconds(10), + // limit: 3, + // }, + // ], + // storage: new ThrottlerStorageRedisService(config.get("db.redisUrl", { infer: true })), + // }), + // }), + PrismaModule, + EndpointsModule, + AuthModule, + JwtModule, + //register prefix for all routes in EndpointsModule + RouterModule.register([{ path: "/v2", module: EndpointsModule }]), + ], + controllers: [AppController], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(AppLoggerMiddleware).forRoutes("*"); + consumer.apply(RewriterMiddleware).forRoutes("/"); + } +} diff --git a/apps/api/v2/src/app.rewrites.middleware.ts b/apps/api/v2/src/app.rewrites.middleware.ts new file mode 100644 index 00000000000000..d58bf352ec44ec --- /dev/null +++ b/apps/api/v2/src/app.rewrites.middleware.ts @@ -0,0 +1,12 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import { Request, Response } from "express"; + +@Injectable() +export class RewriterMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => void) { + if (req.url.startsWith("/api/v2")) { + req.url = req.url.replace("/api/v2", "/v2"); + } + next(); + } +} diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts new file mode 100644 index 00000000000000..417839abbc186f --- /dev/null +++ b/apps/api/v2/src/app.ts @@ -0,0 +1,66 @@ +import { getEnv } from "@/env"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { SentryFilter } from "@/filters/sentry-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import type { ValidationError } from "@nestjs/common"; +import { BadRequestException, RequestMethod, ValidationPipe, VersioningType } from "@nestjs/common"; +import { HttpAdapterHost } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import * as Sentry from "@sentry/node"; +import * as cookieParser from "cookie-parser"; +import helmet from "helmet"; + +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +import { TRPCExceptionFilter } from "./filters/trpc-exception.filter"; + +export const bootstrap = (app: NestExpressApplication): NestExpressApplication => { + app.enableShutdownHooks(); + app.enableVersioning({ + type: VersioningType.URI, + prefix: "v", + defaultVersion: "1", + }); + + app.use(helmet()); + + app.enableCors({ + origin: "*", + methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"], + allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"], + maxAge: 86_400, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + validationError: { + target: true, + value: true, + }, + exceptionFactory(errors: ValidationError[]) { + return new BadRequestException({ errors }); + }, + }) + ); + + if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: getEnv("SENTRY_DSN"), + }); + } + + // Exception filters, new filters go at the bottom, keep the order + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new SentryFilter(httpAdapter)); + app.useGlobalFilters(new PrismaExceptionFilter()); + app.useGlobalFilters(new ZodExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalFilters(new TRPCExceptionFilter()); + + app.use(cookieParser()); + + return app; +}; diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts new file mode 100644 index 00000000000000..904579396aadc2 --- /dev/null +++ b/apps/api/v2/src/config/app.ts @@ -0,0 +1,30 @@ +import { getEnv } from "@/env"; + +import type { AppConfig } from "./type"; + +const loadConfig = (): AppConfig => { + return { + env: { + type: getEnv("NODE_ENV", "development"), + }, + api: { + port: Number(getEnv("API_PORT", "5555")), + path: getEnv("API_URL", "http://localhost"), + url: `${getEnv("API_URL", "http://localhost")}${ + process.env.API_PORT && getEnv("NODE_ENV", "development") === "development" + ? `:${Number(getEnv("API_PORT", "5555"))}` + : "" + }/v2`, + }, + db: { + readUrl: getEnv("DATABASE_READ_URL"), + writeUrl: getEnv("DATABASE_WRITE_URL"), + redisUrl: getEnv("REDIS_URL"), + }, + next: { + authSecret: getEnv("NEXTAUTH_SECRET"), + }, + }; +}; + +export default loadConfig; diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts new file mode 100644 index 00000000000000..e7690ac63fd675 --- /dev/null +++ b/apps/api/v2/src/config/type.ts @@ -0,0 +1,18 @@ +export type AppConfig = { + env: { + type: "production" | "development"; + }; + api: { + port: number; + path: string; + url: string; + }; + db: { + readUrl: string; + writeUrl: string; + redisUrl: string; + }; + next: { + authSecret: string; + }; +}; diff --git a/apps/api/v2/src/ee/LICENSE b/apps/api/v2/src/ee/LICENSE new file mode 100644 index 00000000000000..a8c6744758303a --- /dev/null +++ b/apps/api/v2/src/ee/LICENSE @@ -0,0 +1,42 @@ +The Cal.com Commercial License (the “Commercial License”) +Copyright (c) 2020-present Cal.com, Inc + +With regard to the Cal.com Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Cal.com Subscription Terms available +at https://cal.com/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"), +and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription") +for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Cal.com and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Cal.com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +This Commercial License applies only to the part of this Software that is not distributed under +the AGPLv3 license. Any part of this Software distributed under the MIT license or which +is served client-side as an image, font, cascading stylesheet (CSS), file which produces +or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or +in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall +be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Cal.com Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/apps/api/v2/src/ee/README.md b/apps/api/v2/src/ee/README.md new file mode 100644 index 00000000000000..44bff8c9c04b5f --- /dev/null +++ b/apps/api/v2/src/ee/README.md @@ -0,0 +1,18 @@ + + + +# Enterprise Edition of API + +Welcome to the Enterprise Edition ("/ee") of the Cal.com API. + +Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All "Multiplayer APIs" are under a commercial license. + +The [/ee](https://github.com/calcom/cal.com/tree/main/apps/api/v2/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://cal.com/pricing) plan and enterprise-grade features for [Enterprise](https://cal.com/enterprise) such as SSO, SAML, OIDC, SCIM, SIEM and much more or [Platform](https://cal.com/platform) plan to build a marketplace. + +> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://console.cal.com/) first❗_ diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts new file mode 100644 index 00000000000000..9725cac786469d --- /dev/null +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -0,0 +1,14 @@ +import { BookingsController } from "@/ee/bookings/controllers/bookings.controller"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, TokensModule], + providers: [TokensRepository, OAuthFlowService, OAuthClientRepository], + controllers: [BookingsController], +}) +export class BookingsModule {} diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..2083867b044d96 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts @@ -0,0 +1,271 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; +import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { handleNewBooking } from "@calcom/platform-libraries"; +import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types"; + +describe("Bookings Endpoints", () => { + describe("User Authenticated", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = "bookings-controller-e2e@api.com"; + let user: User; + + let eventTypeId: number; + + let createdBooking: Awaited>; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const userSchedule: CreateScheduleInput = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { title: "peer coding", slug: "peer-coding", length: 60 }, + user.id + ); + eventTypeId = event.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a booking", async () => { + const bookingStart = "2040-05-21T09:30:00.000Z"; + const bookingEnd = "2040-05-21T10:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = {}; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/api/v2/ee/bookings") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(bookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(bookingMetadata); + + createdBooking = responseBody.data; + }); + }); + + it("should get bookings", async () => { + return request(app.getHttpServer()) + .get("/api/v2/ee/bookings?filters[status]=upcoming") + .then((response) => { + console.log("asap responseBody", JSON.stringify(response.body, null, 2)); + const responseBody: GetBookingsOutput = response.body; + const fetchedBooking = responseBody.data.bookings[0]; + + expect(responseBody.data.bookings.length).toEqual(1); + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(fetchedBooking).toBeDefined(); + + expect(fetchedBooking.id).toEqual(createdBooking.id); + expect(fetchedBooking.uid).toEqual(createdBooking.uid); + expect(fetchedBooking.startTime).toEqual(createdBooking.startTime); + expect(fetchedBooking.endTime).toEqual(createdBooking.endTime); + expect(fetchedBooking.user?.email).toEqual(userEmail); + }); + }); + + it("should get booking", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/ee/bookings/${createdBooking.uid}`) + .then((response) => { + const responseBody: GetBookingOutput = response.body; + const bookingInfo = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(bookingInfo?.id).toBeDefined(); + expect(bookingInfo?.uid).toBeDefined(); + expect(bookingInfo?.id).toEqual(createdBooking.id); + expect(bookingInfo?.uid).toEqual(createdBooking.uid); + expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(bookingInfo?.startTime).toEqual(createdBooking.startTime); + }); + }); + + // note(Lauris) : found this test broken here - first thing to fix is that recurring endpoint accepts an array not 1 object. + // it("should create a recurring booking", async () => { + // const bookingStart = "2040-05-25T09:30:00.000Z"; + // const bookingEnd = "2040-05-25T10:30:00.000Z"; + // const bookingEventTypeId = 7; + // const bookingTimeZone = "Europe/London"; + // const bookingLanguage = "en"; + // const bookingHashedLink = ""; + // const bookingRecurringCount = 5; + // const currentBookingRecurringIndex = 0; + + // const body = { + // start: bookingStart, + // end: bookingEnd, + // eventTypeId: bookingEventTypeId, + // timeZone: bookingTimeZone, + // language: bookingLanguage, + // metadata: {}, + // hashedLink: bookingHashedLink, + // recurringCount: bookingRecurringCount, + // currentRecurringIndex: currentBookingRecurringIndex, + // }; + + // return request(app.getHttpServer()) + // .post("/api/v2/ee/bookings/reccuring") + // .send(body) + // .expect(201) + // .then((response) => { + // const responseBody: ApiResponse>> = + // response.body; + + // expect(responseBody.status).toEqual("recurring"); + // }); + // }); + + // note(Lauris) : found this test broken here - first thing to fix is that the eventTypeId must be team event type, because + // instant bookings only work for teams. + // it("should create an instant booking", async () => { + // const bookingStart = "2040-05-25T09:30:00.000Z"; + // const bookingEnd = "2040-25T10:30:00.000Z"; + // const bookingEventTypeId = 7; + // const bookingTimeZone = "Europe/London"; + // const bookingLanguage = "en"; + // const bookingHashedLink = ""; + + // const body = { + // start: bookingStart, + // end: bookingEnd, + // eventTypeId: bookingEventTypeId, + // timeZone: bookingTimeZone, + // language: bookingLanguage, + // metadata: {}, + // hashedLink: bookingHashedLink, + // }; + + // return request(app.getHttpServer()) + // .post("/api/v2/ee/bookings/instant") + // .send(body) + // .expect(201) + // .then((response) => { + // const responseBody: ApiResponse>> = response.body; + + // expect(responseBody.status).toEqual("instant"); + // }); + // }); + + it("should cancel a booking", async () => { + const bookingId = createdBooking.id; + + const body = { + allRemainingBookings: false, + cancellationReason: "Was fighting some unforseen rescheduling demons", + }; + + return request(app.getHttpServer()) + .post(`/api/v2/ee/bookings/${bookingId}/cancel`) + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> = + response.body; + + expect(bookingId).toBeDefined(); + expect(responseBody.status).toEqual(SUCCESS_STATUS); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts new file mode 100644 index 00000000000000..29620555633e9a --- /dev/null +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -0,0 +1,286 @@ +import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; +import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input"; +import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; +import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { + Controller, + Post, + Logger, + Req, + InternalServerErrorException, + Body, + Headers, + HttpException, + Param, + Get, + Query, + NotFoundException, + UseGuards, +} from "@nestjs/common"; +import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; +import { User } from "@prisma/client"; +import { Request } from "express"; +import { NextApiRequest } from "next/types"; + +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + getAllUserBookings, + getBookingInfo, + handleCancelBooking, + getBookingForReschedule, +} from "@calcom/platform-libraries"; +import { + handleNewBooking, + BookingResponse, + HttpError, + handleNewRecurringBooking, + handleInstantMeeting, +} from "@calcom/platform-libraries"; +import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types"; +import { ApiResponse } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +type BookingRequest = Request & { + userId?: number; +}; + +type OAuthRequestParams = { + platformClientId: string; + platformRescheduleUrl: string; + platformCancelUrl: string; + platformBookingUrl: string; +}; + +const DEFAULT_PLATFORM_PARAMS = { + platformClientId: "", + platformCancelUrl: "", + platformRescheduleUrl: "", + platformBookingUrl: "", + areEmailsEnabled: true, +}; + +@Controller({ + path: "ee/bookings", + version: "2", +}) +@UseGuards(PermissionsGuard) +@DocsTags("Bookings") +export class BookingsController { + private readonly logger = new Logger("ee bookings controller"); + + constructor( + private readonly oAuthFlowService: OAuthFlowService, + private readonly prismaReadService: PrismaReadService, + private readonly oAuthClientRepository: OAuthClientRepository + ) {} + + @Get("/") + @UseGuards(AccessTokenGuard) + @Permissions([BOOKING_READ]) + @ApiQuery({ name: "filters[status]", enum: Status, required: true }) + @ApiQuery({ name: "limit", type: "number", required: false }) + @ApiQuery({ name: "cursor", type: "number", required: false }) + async getBookings( + @GetUser() user: User, + @Query() queryParams: GetBookingsInput + ): Promise { + const { filters, cursor, limit } = queryParams; + const bookings = await getAllUserBookings({ + bookingListingByStatus: filters.status, + skip: cursor ?? 0, + take: limit ?? 10, + filters, + ctx: { + user: { email: user.email, id: user.id }, + prisma: this.prismaReadService.prisma as unknown as PrismaClient, + }, + }); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } + + @Get("/:bookingUid") + async getBooking(@Param("bookingUid") bookingUid: string): Promise { + const { bookingInfo } = await getBookingInfo(bookingUid); + + if (!bookingInfo) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: bookingInfo, + }; + } + + @Get("/:bookingUid/reschedule") + async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise> { + const booking = await getBookingForReschedule(bookingUid); + + if (!booking) { + throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); + } + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Post("/") + async createBooking( + @Req() req: BookingRequest, + @Body() _: CreateBookingInput, + @Headers(X_CAL_CLIENT_ID) clientId?: string + ): Promise> { + const oAuthClientId = clientId?.toString(); + try { + const booking = await handleNewBooking(await this.createNextApiBookingRequest(req, oAuthClientId)); + return { + status: SUCCESS_STATUS, + data: booking, + }; + } catch (err) { + handleBookingErrors(err); + } + throw new InternalServerErrorException("Could not create booking."); + } + + @Post("/:bookingId/cancel") + async cancelBooking( + @Req() req: BookingRequest, + @Param("bookingId") bookingId: string, + @Body() _: CancelBookingInput, + @Headers(X_CAL_CLIENT_ID) clientId?: string + ): Promise { + const oAuthClientId = clientId?.toString(); + if (bookingId) { + try { + await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId)); + return { + status: SUCCESS_STATUS, + }; + } catch (err) { + handleBookingErrors(err); + } + } else { + throw new NotFoundException("Booking ID is required."); + } + throw new InternalServerErrorException("Could not cancel booking."); + } + + @Post("/reccuring") + async createReccuringBooking( + @Req() req: BookingRequest, + @Body() _: CreateReccuringBookingInput[], + @Headers(X_CAL_CLIENT_ID) clientId?: string + ): Promise> { + const oAuthClientId = clientId?.toString(); + try { + const createdBookings: BookingResponse[] = await handleNewRecurringBooking( + await this.createNextApiBookingRequest(req, oAuthClientId) + ); + return { + status: SUCCESS_STATUS, + data: createdBookings, + }; + } catch (err) { + handleBookingErrors(err, "recurring"); + } + throw new InternalServerErrorException("Could not create recurring booking."); + } + + @Post("/instant") + async createInstantBooking( + @Req() req: BookingRequest, + @Body() _: CreateBookingInput, + @Headers(X_CAL_CLIENT_ID) clientId?: string + ): Promise>>> { + const oAuthClientId = clientId?.toString(); + req.userId = (await this.getOwnerId(req)) ?? -1; + try { + const instantMeeting = await handleInstantMeeting( + await this.createNextApiBookingRequest(req, oAuthClientId) + ); + return { + status: SUCCESS_STATUS, + data: instantMeeting, + }; + } catch (err) { + handleBookingErrors(err, "instant"); + } + throw new InternalServerErrorException("Could not create instant booking."); + } + + async getOwnerId(req: Request): Promise { + try { + const accessToken = req.get("Authorization")?.replace("Bearer ", ""); + if (accessToken) { + return this.oAuthFlowService.getOwnerId(accessToken); + } + } catch (err) { + this.logger.error(err); + } + } + + async getOAuthClientsParams( + req: BookingRequest, + clientId: string + ): Promise { + const res = DEFAULT_PLATFORM_PARAMS; + try { + const client = await this.oAuthClientRepository.getOAuthClient(clientId); + // fetch oAuthClient from db and use data stored in db to set these values + if (client) { + res.platformClientId = clientId; + res.platformCancelUrl = client.bookingCancelRedirectUri ?? ""; + res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? ""; + res.platformBookingUrl = client.bookingRedirectUri ?? ""; + res.areEmailsEnabled = client.areEmailsEnabled; + } + return res; + } catch (err) { + this.logger.error(err); + return res; + } + } + + async createNextApiBookingRequest( + req: BookingRequest, + oAuthClientId?: string + ): Promise { + const userId = (await this.getOwnerId(req)) ?? -1; + const oAuthParams = oAuthClientId + ? await this.getOAuthClientsParams(req, oAuthClientId) + : DEFAULT_PLATFORM_PARAMS; + Object.assign(req, { userId, ...oAuthParams }); + req.body = { ...req.body, areEmailsEnabled: oAuthParams.areEmailsEnabled }; + return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; + } +} + +function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void { + const errMsg = `Error while creating ${type ? type + " " : ""}booking.`; + if (err instanceof HttpError) { + const httpError = err as HttpError; + throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); + } + + if (err instanceof Error) { + const error = err as Error; + throw new InternalServerErrorException(error?.message ?? errMsg); + } + + throw new InternalServerErrorException(errMsg); +} diff --git a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts new file mode 100644 index 00000000000000..b36d6c3ebe3292 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts @@ -0,0 +1,101 @@ +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsTimeZone, + IsNumber, + IsString, + IsOptional, + IsArray, + IsObject, + IsEmail, + ValidateNested, +} from "class-validator"; + +class Location { + @IsString() + optionValue!: string; + + @IsString() + value!: string; +} + +class Response { + @IsString() + name!: string; + + @IsEmail() + email!: string; + + @IsArray() + @IsString({ each: true }) + guests!: string[]; + + @IsOptional() + @ValidateNested() + @Type(() => Location) + location?: Location; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateBookingInput { + @IsString() + @IsOptional() + end?: string; + + @IsString() + start!: string; + + @IsNumber() + eventTypeId!: number; + + @IsString() + @IsOptional() + eventTypeSlug?: string; + + @IsString() + @IsOptional() + rescheduleUid?: string; + + @IsString() + @IsOptional() + recurringEventId?: string; + + @IsTimeZone() + timeZone!: string; + + @Transform(({ value }: { value: string | string[] }) => { + return typeof value === "string" ? [value] : value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + user?: string[]; + + @IsString() + language!: string; + + @IsString() + @IsOptional() + bookingUid?: string; + + @IsObject() + metadata!: Record; + + @IsBoolean() + @IsOptional() + hasHashedBookingLink?: boolean; + + @IsString() + @IsOptional() + hashedLink!: string | null; + + @IsString() + @IsOptional() + seatReferenceUid?: string; + + @Type(() => Response) + responses!: Response; +} diff --git a/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts new file mode 100644 index 00000000000000..3c7478b39be383 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts @@ -0,0 +1,24 @@ +import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; +import { IsBoolean, IsNumber, IsOptional } from "class-validator"; + +import type { AppsStatus } from "@calcom/platform-libraries"; + +export class CreateReccuringBookingInput extends CreateBookingInput { + @IsBoolean() + @IsOptional() + noEmail?: boolean; + + @IsOptional() + @IsNumber() + recurringCount?: number; + + @IsOptional() + appsStatus?: AppsStatus[] | undefined; + + @IsOptional() + allRecurringDates?: Record[]; + + @IsOptional() + @IsNumber() + currentRecurringIndex?: number; +} diff --git a/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts b/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts new file mode 100644 index 00000000000000..c770a583059624 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts @@ -0,0 +1,173 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsEnum, + IsInt, + IsOptional, + IsObject, + ValidateNested, + IsArray, + IsUrl, + IsDateString, + IsEmail, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Metadata { + @IsUrl() + videoCallUrl!: string; +} + +class Location { + @IsString() + optionValue!: string; + + @IsString() + value!: string; +} + +class Response { + @IsString() + name!: string; + + @IsEmail() + email!: string; + + @IsString() + notes!: string; + + @IsArray() + @IsString({ each: true }) + guests!: string[]; + + @ValidateNested() + @Type(() => Location) + location!: Location; +} + +class User { + @IsInt() + id!: number; + + @IsString() + name!: string | null; + + @IsEmail() + email!: string; + + @IsString() + username!: string | null; + + @IsString() + timeZone!: string; +} + +class Attendee { + @IsString() + name!: string; + + @IsEmail() + email!: string; + + @IsString() + timeZone!: string; +} + +class EventType { + @IsOptional() + @IsString() + eventName!: string | null; + + @IsString() + slug!: string; + + @IsOptional() + @IsString() + timeZone!: string | null; +} + +class GetBookingData { + @IsString() + title!: string; + + @IsInt() + id!: number; + + @IsString() + uid!: string; + + @IsString() + description!: string | null; + + @IsObject() + customInputs!: any; + + @IsOptional() + @IsString() + smsReminderNumber!: string | null; + + @IsOptional() + @IsString() + recurringEventId!: string | null; + + @IsDateString() + startTime!: Date; + + @IsDateString() + endTime!: Date; + + @IsUrl() + location!: string | null; + + @IsString() + status!: string; + + metadata!: Metadata | any; + + @IsOptional() + @IsString() + cancellationReason!: string | null; + + @ValidateNested() + @Type(() => Response) + responses!: Response | any; + + @IsOptional() + @IsString() + rejectionReason!: string | null; + + @IsString() + @IsEmail() + userPrimaryEmail!: string | null; + + @ValidateNested() + @Type(() => User) + user!: User | null; + + @ValidateNested() + @Type(() => Attendee) + @IsArray() + attendees!: Attendee[]; + + @IsInt() + eventTypeId!: number | null; + + @ValidateNested() + @Type(() => EventType) + eventType!: EventType | null; +} + +export class GetBookingOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: GetBookingData, + }) + @ValidateNested() + @Type(() => GetBookingData) + data!: GetBookingData; +} diff --git a/apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts b/apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts new file mode 100644 index 00000000000000..09e555fbb135f5 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts @@ -0,0 +1,229 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsString, + IsEnum, + IsInt, + IsBoolean, + IsUrl, + IsOptional, + IsObject, + ValidateNested, + IsArray, + IsDateString, + IsEmail, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +enum Status { + CANCELLED = "CANCELLED", + REJECTED = "REJECTED", + ACCEPTED = "ACCEPTED", + PENDING = "PENDING", + AWAITING_HOST = "AWAITING_HOST", +} + +class Attendee { + @IsInt() + id!: number; + + @IsEmail() + email!: string; + + @IsString() + name!: string; + + @IsString() + timeZone!: string; + + @IsString() + locale!: string | null; + + @IsInt() + bookingId!: number | null; +} + +class EventType { + @IsString() + @IsOptional() + slug?: string; + + @IsOptional() + @IsInt() + id?: number; + + @IsOptional() + @IsString() + eventName?: string | null; + + @IsInt() + price!: number; + + @IsOptional() + recurringEvent?: any; + + @IsString() + currency!: string; + + @IsObject() + metadata!: any; + + @IsBoolean() + @IsOptional() + seatsShowAttendees?: boolean | undefined | null; + + @IsBoolean() + @IsOptional() + seatsShowAvailabilityCount?: boolean | undefined | null; + + @IsOptional() + team?: any | null; +} + +class Reference { + @IsInt() + id!: number; + + @IsString() + type!: string; + + @IsString() + uid!: string; + + @IsOptional() + @IsString() + meetingId?: string | null; + + @IsOptional() + @IsString() + thirdPartyRecurringEventId?: string | null; + + @IsString() + meetingPassword!: string | null; + + @IsOptional() + @IsString() + meetingUrl?: string | null; + + @IsInt() + bookingId!: number | null; + + @IsEmail() + externalCalendarId!: string | null; + + @IsOptional() + deleted?: any; + + @IsInt() + credentialId!: number | null; +} + +class User { + @IsInt() + id!: number; + + @IsString() + name!: string | null; + + @IsEmail() + email!: string; +} + +class GetBookingsDataEntry { + @IsInt() + id!: number; + + @IsString() + title!: string; + + @IsOptional() + @IsEmail() + userPrimaryEmail?: string | null; + + @IsString() + description!: string | null; + + @IsObject() + customInputs!: object | any; + + @IsDateString() + startTime!: string; + + @IsDateString() + endTime!: string; + + @ValidateNested({ each: true }) + @Type(() => Attendee) + @IsArray() + attendees!: Attendee[]; + + metadata!: any; + + @IsString() + uid!: string; + + @IsOptional() + @IsString() + recurringEventId!: string | null; + + @IsUrl() + location!: string | null; + + @ValidateNested() + @Type(() => EventType) + eventType!: EventType; + + @IsEnum(Status) + status!: "CANCELLED" | "REJECTED" | "ACCEPTED" | "PENDING" | "AWAITING_HOST"; + + @IsBoolean() + paid!: boolean; + + @IsArray() + payment!: any[]; + + @ValidateNested() + @Type(() => Reference) + @IsArray() + references!: Reference[]; + + @IsBoolean() + isRecorded!: boolean; + + @IsArray() + seatsReferences!: any[]; + + @ValidateNested() + @Type(() => User) + user!: User | null; + + @IsOptional() + rescheduled?: any; +} + +class GetBookingsData { + @ValidateNested() + @Type(() => GetBookingsDataEntry) + @IsArray() + bookings!: GetBookingsDataEntry[]; + + @IsArray() + recurringInfo!: any[]; + + @IsInt() + nextCursor!: number | null; +} + +export class GetBookingsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: GetBookingsData, + }) + @ValidateNested() + @Type(() => GetBookingsData) + data!: GetBookingsData; +} diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts new file mode 100644 index 00000000000000..fe6b382605aace --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -0,0 +1,14 @@ +import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, UsersModule], + providers: [CredentialsRepository, CalendarsService], + controllers: [CalendarsController], + exports: [CalendarsService], +}) +export class CalendarsModule {} diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts new file mode 100644 index 00000000000000..39bcf9e1ab6379 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -0,0 +1,58 @@ +import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; +import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, Get, UseGuards, Query } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { CalendarBusyTimesInput } from "@calcom/platform-types"; + +@Controller({ + path: "ee/calendars", + version: "2", +}) +@UseGuards(AccessTokenGuard) +@DocsTags("Calendars") +export class CalendarsController { + constructor(private readonly calendarsService: CalendarsService) {} + + @Get("/busy-times") + async getBusyTimes( + @Query() queryParams: CalendarBusyTimesInput, + @GetUser() user: UserWithProfile + ): Promise { + const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams; + if (!dateFrom || !dateTo) { + return { + status: SUCCESS_STATUS, + data: [], + }; + } + + const busyTimes = await this.calendarsService.getBusyTimes( + calendarsToLoad, + user.id, + dateFrom, + dateTo, + loggedInUsersTz + ); + + return { + status: SUCCESS_STATUS, + data: busyTimes, + }; + } + + @Get("/") + async getCalendars(@GetUser("id") userId: number): Promise { + const calendars = await this.calendarsService.getCalendars(userId); + + return { + status: SUCCESS_STATUS, + data: calendars, + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts b/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts new file mode 100644 index 00000000000000..b705c93403e801 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/busy-times.output.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class BusyTimesOutput { + @IsDate() + start!: Date; + + @IsDate() + end!: Date; + + @IsOptional() + @IsString() + source?: string | null; +} + +export class GetBusyTimesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @Type(() => BusyTimesOutput) + @IsArray() + data!: BusyTimesOutput[]; +} diff --git a/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts b/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts new file mode 100644 index 00000000000000..f71a469eeb7cfe --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/connected-calendars.output.ts @@ -0,0 +1,218 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsObject, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Integration { + @IsOptional() + @IsObject() + appData?: object | null; + + @IsOptional() + @IsString() + dirName?: string; + + @IsOptional() + @IsString() + __template?: string; + + @IsString() + name!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsBoolean() + installed?: boolean; + + @IsString() + type!: string; + + @IsOptional() + @IsString() + title?: string; + + @IsString() + variant!: string; + + @IsOptional() + @IsString() + category?: string; + + @IsArray() + @IsString({ each: true }) + categories!: string[]; + + @IsString() + logo!: string; + + @IsString() + publisher!: string; + + @IsString() + slug!: string; + + @IsUrl() + url!: string; + + @IsEmail() + email!: string; + + @IsOptional() + @IsObject() + locationOption!: object | null; +} + +class Primary { + @IsEmail() + externalId!: string; + + @IsString() + @IsOptional() + integration?: string; + + @IsOptional() + @IsEmail() + name?: string; + + @IsBoolean() + primary!: boolean | null; + + @IsBoolean() + readOnly!: boolean; + + @IsEmail() + @IsOptional() + email?: string; + + @IsBoolean() + isSelected!: boolean; + + @IsInt() + credentialId!: number; +} + +class Calendar { + @IsEmail() + externalId!: string; + + @IsString() + @IsOptional() + integration?: string; + + @IsEmail() + @IsOptional() + name?: string; + + @IsOptional() + @IsBoolean() + primary?: boolean | null; + + @IsBoolean() + readOnly!: boolean; + + @IsEmail() + @IsOptional() + email?: string; + + @IsBoolean() + isSelected!: boolean; + + @IsInt() + credentialId!: number; +} + +class ConnectedCalendar { + @ValidateNested() + @IsObject() + integration!: Integration; + + @IsInt() + credentialId!: number; + + @ValidateNested() + @IsObject() + @IsOptional() + primary?: Primary; + + @ValidateNested({ each: true }) + @IsArray() + @IsOptional() + calendars?: Calendar[]; +} + +class DestinationCalendar { + @IsInt() + id!: number; + + @IsString() + integration!: string; + + @IsEmail() + externalId!: string; + + @IsEmail() + primaryEmail!: string | null; + + @IsInt() + userId!: number | null; + + @IsOptional() + @IsInt() + eventTypeId!: number | null; + + @IsInt() + credentialId!: number | null; + + @IsString() + @IsOptional() + name?: string | null; + + @IsBoolean() + @IsOptional() + primary?: boolean; + + @IsBoolean() + @IsOptional() + readOnly?: boolean; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + integrationTitle?: string; +} + +class ConnectedCalendarsData { + @ValidateNested({ each: true }) + @IsArray() + connectedCalendars!: ConnectedCalendar[]; + + @ValidateNested() + @IsObject() + destinationCalendar!: DestinationCalendar; +} +export class ConnectedCalendarsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @Type(() => ConnectedCalendarsData) + data!: ConnectedCalendarsData; +} diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts new file mode 100644 index 00000000000000..6fee7cb40e8910 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -0,0 +1,113 @@ +import { + CredentialsRepository, + CredentialsWithUserEmail, +} from "@/modules/credentials/credentials.repository"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Injectable, + InternalServerErrorException, + UnauthorizedException, + NotFoundException, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { DateTime } from "luxon"; + +import { getConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { getBusyCalendarTimes } from "@calcom/platform-libraries"; +import { Calendar } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class CalendarsService { + constructor( + private readonly usersRepository: UsersRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService + ) {} + + async getCalendars(userId: number) { + const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId); + if (!userWithCalendars) { + throw new NotFoundException("User not found"); + } + + return getConnectedDestinationCalendars( + userWithCalendars, + false, + this.dbWrite.prisma as unknown as PrismaClient + ); + } + + async getBusyTimes( + calendarsToLoad: Calendar[], + userId: User["id"], + dateFrom: string, + dateTo: string, + timezone: string + ) { + const credentials = await this.getUniqCalendarCredentials(calendarsToLoad, userId); + const composedSelectedCalendars = await this.getCalendarsWithCredentials( + credentials, + calendarsToLoad, + userId + ); + try { + const calendarBusyTimes = await getBusyCalendarTimes( + "", + credentials, + dateFrom, + dateTo, + composedSelectedCalendars + ); + const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => { + const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone); + const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone); + const busyTimeStartDate = busyTimeStart.toJSDate(); + const busyTimeEndDate = busyTimeEnd.toJSDate(); + return { + ...busyTime, + start: busyTimeStartDate, + end: busyTimeEndDate, + }; + }); + return calendarBusyTimesConverted; + } catch (error) { + throw new InternalServerErrorException( + "Unable to fetch connected calendars events. Please try again later." + ); + } + } + + async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) { + const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId))); + const credentials = await this.credentialsRepository.getUserCredentialsByIds(userId, uniqueCredentialIds); + + if (credentials.length !== uniqueCredentialIds.length) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + + return credentials; + } + + async getCalendarsWithCredentials( + credentials: CredentialsWithUserEmail, + calendarsToLoad: Calendar[], + userId: User["id"] + ) { + const composedSelectedCalendars = calendarsToLoad.map((calendar) => { + const credential = credentials.find((item) => item.id === calendar.credentialId); + if (!credential) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + return { + ...calendar, + userId, + integration: credential.type, + }; + }); + return composedSelectedCalendars; + } +} diff --git a/apps/api/v2/src/ee/event-types/constants/constants.ts b/apps/api/v2/src/ee/event-types/constants/constants.ts new file mode 100644 index 00000000000000..690c1b4deceb6d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/constants/constants.ts @@ -0,0 +1,16 @@ +export const DEFAULT_EVENT_TYPES = { + thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" }, + thirtyMinutesVideo: { + length: 30, + slug: "thirty-minutes-video", + title: "30 Minutes", + locations: [{ type: "integrations:daily" }], + }, + sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" }, + sixtyMinutesVideo: { + length: 60, + slug: "sixty-minutes-video", + title: "60 Minutes", + locations: [{ type: "integrations:daily" }], + }, +}; diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts new file mode 100644 index 00000000000000..77678f838786ba --- /dev/null +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts @@ -0,0 +1,286 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input"; +import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output"; +import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output"; +import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, 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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Event types Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = "event-types-test-e2e@api.com"; + const name = "bob-the-builder"; + const username = name; + let eventType: EventType; + let user: User; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + name, + username, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create an event type", async () => { + const body: CreateEventTypeInput = { + title: "Test Event Type", + slug: "test-event-type", + description: "A description of the test event type.", + length: 60, + hidden: false, + locations: [ + { + type: "Online", + link: "https://example.com/meet", + displayLocationPublicly: true, + }, + ], + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.data).toHaveProperty("id"); + expect(responseBody.data.title).toEqual(body.title); + eventType = responseBody.data; + }); + }); + + it("should update event type", async () => { + const newTitle = "Updated title"; + + const body: UpdateEventTypeInput = { + title: newTitle, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventType.id}`) + .send(body) + .expect(200) + .then(async () => { + eventType.title = newTitle; + }); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypeOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.eventType.id).toEqual(eventType.id); + expect(responseBody.data.eventType.title).toEqual(eventType.title); + expect(responseBody.data.eventType.slug).toEqual(eventType.slug); + expect(responseBody.data.eventType.userId).toEqual(user.id); + }); + + it(`/GET/:username/public`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/public`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypesPublicOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toEqual(1); + expect(responseBody.data?.[0]?.id).toEqual(eventType.id); + expect(responseBody.data?.[0]?.title).toEqual(eventType.title); + expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug); + expect(responseBody.data?.[0]?.length).toEqual(eventType.length); + }); + + it(`/GET/:username/:eventSlug/public`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/${eventType.slug}/public`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: GetEventTypePublicOutput = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.id).toEqual(eventType.id); + expect(responseBody.data?.title).toEqual(eventType.title); + expect(responseBody.data?.slug).toEqual(eventType.slug); + expect(responseBody.data?.length).toEqual(eventType.length); + }); + + it(`/GET/`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.eventTypeGroups).toBeDefined(); + expect(responseBody.data.eventTypeGroups).toBeDefined(); + expect(responseBody.data.eventTypeGroups[0]).toBeDefined(); + expect(responseBody.data.eventTypeGroups[0].profile).toBeDefined(); + expect(responseBody.data.eventTypeGroups?.[0]?.profile?.name).toEqual(name); + expect(responseBody.data.eventTypeGroups?.[0]?.eventTypes?.[0]?.id).toEqual(eventType.id); + expect(responseBody.data.profiles?.[0]?.name).toEqual(name); + }); + + it(`/GET/public/:username/`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${username}/public`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].id).toEqual(eventType.id); + }); + + it(`/GET/:id not existing`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/event-types/1000`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(404); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await eventTypesRepositoryFixture.delete(eventType.id); + } catch (e) { + // Event type might have been deleted by the test + } + try { + await userRepositoryFixture.delete(user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts new file mode 100644 index 00000000000000..3c8c7076fbe996 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts @@ -0,0 +1,178 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input"; +import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input"; +import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output"; +import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output"; +import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output"; +import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output"; +import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output"; +import { GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output"; +import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output"; +import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Param, + Post, + Body, + NotFoundException, + Patch, + HttpCode, + HttpStatus, + Delete, + Query, + InternalServerErrorException, +} from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; + +import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getPublicEvent } from "@calcom/platform-libraries"; +import { getEventTypesByViewer } from "@calcom/platform-libraries"; +import { PrismaClient } from "@calcom/prisma"; + +@Controller({ + path: "event-types", + version: "2", +}) +@UseGuards(PermissionsGuard) +@DocsTags("Event types") +export class EventTypesController { + constructor( + private readonly eventTypesService: EventTypesService, + private readonly prismaReadService: PrismaReadService + ) {} + + @Post("/") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(AccessTokenGuard) + async createEventType( + @Body() body: CreateEventTypeInput, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.createUserEventType(user, body); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/:eventTypeId") + @Permissions([EVENT_TYPE_READ]) + @UseGuards(AccessTokenGuard) + async getEventType( + @Param("eventTypeId") eventTypeId: string, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId)); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/") + @Permissions([EVENT_TYPE_READ]) + @UseGuards(AccessTokenGuard) + async getEventTypes(@GetUser() user: UserWithProfile): Promise { + const eventTypes = await getEventTypesByViewer({ + id: user.id, + profile: { + upId: `usr-${user.id}`, + }, + }); + + return { + status: SUCCESS_STATUS, + data: eventTypes, + }; + } + + @Get("/:username/:eventSlug/public") + async getPublicEventType( + @Param("username") username: string, + @Param("eventSlug") eventSlug: string, + @Query() queryParams: GetPublicEventTypeQueryParams + ): Promise { + try { + const event = await getPublicEvent( + username.toLowerCase(), + eventSlug, + queryParams.isTeamEvent, + queryParams.org || null, + this.prismaReadService.prisma as unknown as PrismaClient, + // We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license + // If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink` + true + ); + return { + data: event, + status: SUCCESS_STATUS, + }; + } catch (err) { + if (err instanceof Error) { + throw new NotFoundException(err.message); + } + } + throw new InternalServerErrorException("Could not find public event."); + } + + @Get("/:username/public") + async getPublicEventTypes(@Param("username") username: string): Promise { + const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username); + + return { + status: SUCCESS_STATUS, + data: eventTypes, + }; + } + + @Patch("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(AccessTokenGuard) + @HttpCode(HttpStatus.OK) + async updateEventType( + @Param("eventTypeId") eventTypeId: number, + @Body() body: UpdateEventTypeInput, + @GetUser() user: UserWithProfile + ): Promise { + const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Delete("/:eventTypeId") + @Permissions([EVENT_TYPE_WRITE]) + @UseGuards(AccessTokenGuard) + async deleteEventType( + @Param("eventTypeId") eventTypeId: number, + @GetUser("id") userId: number + ): Promise { + const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId); + + return { + status: SUCCESS_STATUS, + data: { + id: eventType.id, + length: eventType.length, + slug: eventType.slug, + title: eventType.title, + }, + }; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types.module.ts new file mode 100644 index 00000000000000..5163794ae9135a --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types.module.ts @@ -0,0 +1,17 @@ +import { EventTypesController } from "@/ee/event-types/controllers/event-types.controller"; +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule], + providers: [EventTypesRepository, EventTypesService], + controllers: [EventTypesController], + exports: [EventTypesService, EventTypesRepository], +}) +export class EventTypesModule {} diff --git a/apps/api/v2/src/ee/event-types/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types.repository.ts new file mode 100644 index 00000000000000..d184ffae863a0a --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types.repository.ts @@ -0,0 +1,77 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { getEventTypeById } from "@calcom/platform-libraries"; + +@Injectable() +export class EventTypesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserEventType( + userId: number, + body: Pick + ) { + return this.dbWrite.prisma.eventType.create({ + data: { + ...body, + userId, + users: { connect: { id: userId } }, + }, + }); + } + + async getEventTypeWithSeats(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + } + + async getUserEventType(userId: number, eventTypeId: number) { + return this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId, + }, + }); + } + + async getUserEventTypeForAtom( + user: UserWithProfile, + isUserOrganizationAdmin: boolean, + eventTypeId: number + ) { + return await getEventTypeById({ + currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId, + eventTypeId, + userId: user.id, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbRead.prisma, + isUserOrganizationAdmin, + isTrpcCall: true, + }); + } + + async getEventTypeById(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } }); + } + + async getUserEventTypeBySlug(userId: number, slug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + userId_slug: { + userId: userId, + slug: slug, + }, + }, + }); + } + + async deleteEventType(eventTypeId: number) { + return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts new file mode 100644 index 00000000000000..469ea1f4efdc13 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts @@ -0,0 +1,53 @@ +import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input"; +import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator"; + +export const CREATE_EVENT_LENGTH_EXAMPLE = 60; +export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class"; +export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!"; +export const CREATE_EVENT_DESCRIPTION_EXAMPLE = + "Discover the culinary wonders of the Argentina by making the best flan ever!"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. +export class CreateEventTypeInput { + @IsNumber() + @Min(1) + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsOptional() + @IsString() + @DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE }) + description?: string; + + @IsOptional() + @IsBoolean() + @ApiHideProperty() + hidden?: boolean; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation) + @IsArray() + locations?: EventTypeLocation[]; + + // @ApiHideProperty() + // @IsOptional() + // @IsNumber() + // teamId?: number; + + // @ApiHideProperty() + // @IsOptional() + // @IsEnum(SchedulingType) + // schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type"; +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts b/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts new file mode 100644 index 00000000000000..819f010f126f52 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts @@ -0,0 +1,7 @@ +export enum Editable { + system = "system", + systemButOptional = "system-but-optional", + systemButHidden = "system-but-hidden", + user = "user", + userReadonly = "user-readonly", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts new file mode 100644 index 00000000000000..e24f7ad635ef99 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts @@ -0,0 +1,16 @@ +export enum BaseField { + number = "number", + boolean = "boolean", + address = "address", + name = "name", + text = "text", + textarea = "textarea", + email = "email", + phone = "phone", + multiemail = "multiemail", + select = "select", + multiselect = "multiselect", + checkbox = "checkbox", + radio = "radio", + radioInput = "radioInput", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts b/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts new file mode 100644 index 00000000000000..830bb7abfc9d09 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts @@ -0,0 +1,9 @@ +export enum Frequency { + YEARLY = 0, + MONTHLY = 1, + WEEKLY = 2, + DAILY = 3, + HOURLY = 4, + MINUTELY = 5, + SECONDLY = 6, +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts new file mode 100644 index 00000000000000..95c0e138b7be99 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts @@ -0,0 +1,5 @@ +export enum PeriodType { + UNLIMITED = "UNLIMITED", + ROLLING = "ROLLING", + RANGE = "RANGE", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts new file mode 100644 index 00000000000000..cc581daa4fa07b --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts @@ -0,0 +1,5 @@ +export enum SchedulingType { + ROUND_ROBIN = "ROUND_ROBIN", + COLLECTIVE = "COLLECTIVE", + MANAGED = "MANAGED", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts b/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts new file mode 100644 index 00000000000000..a2584ec20e7fb3 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts @@ -0,0 +1,41 @@ +import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger"; +import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. + +export class EventTypeLocation { + @IsString() + @DocsProperty({ example: "link" }) + type!: string; + + @IsOptional() + @IsString() + @ApiHideProperty() + address?: string; + + @IsOptional() + @IsUrl() + @DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" }) + link?: string; + + @IsOptional() + @IsBoolean() + @ApiHideProperty() + displayLocationPublicly?: boolean; + + @IsOptional() + @IsString() + @ApiHideProperty() + hostPhoneNumber?: string; + + @IsOptional() + @IsNumber() + @ApiHideProperty() + credentialId?: number; + + @IsOptional() + @IsString() + @ApiHideProperty() + teamName?: string; +} diff --git a/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts b/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts new file mode 100644 index 00000000000000..a94dc9ea2698ce --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; + +export class GetPublicEventTypeQueryParams { + @Transform(({ value }: { value: string }) => value === "true") + @IsBoolean() + @IsOptional() + @ApiProperty({ required: false }) + isTeamEvent?: boolean; + + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + org?: string; +} diff --git a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts new file mode 100644 index 00000000000000..d03e451d44f369 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts @@ -0,0 +1,412 @@ +import { Editable } from "@/ee/event-types/inputs/enums/editable"; +import { BaseField } from "@/ee/event-types/inputs/enums/field-type"; +import { Frequency } from "@/ee/event-types/inputs/enums/frequency"; +import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input"; +import { Type } from "class-transformer"; +import { + IsString, + IsBoolean, + IsOptional, + ValidateNested, + Min, + IsInt, + IsEnum, + IsArray, + IsDate, + IsNumber, +} from "class-validator"; + +// note(Lauris): We will gradually expose more properties if any customer needs them. +// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput. + +class Option { + @IsString() + value!: string; + + @IsString() + label!: string; +} + +class Source { + @IsString() + id!: string; + + @IsString() + type!: string; + + @IsString() + label!: string; + + @IsOptional() + @IsString() + editUrl?: string; + + @IsOptional() + @IsBoolean() + fieldRequired?: boolean; +} + +class View { + @IsString() + id!: string; + + @IsString() + label!: string; + + @IsOptional() + @IsString() + description?: string; +} + +class OptionsInput { + @IsString() + type!: "address" | "text" | "phone"; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsString() + placeholder?: string; +} + +class VariantField { + @IsString() + type!: BaseField; + + @IsString() + name!: string; + + @IsOptional() + @IsString() + label?: string; + + @IsOptional() + @IsString() + labelAsSafeHtml?: string; + + @IsOptional() + @IsString() + placeholder?: string; + + @IsOptional() + @IsBoolean() + required?: boolean; +} + +class Variant { + @ValidateNested({ each: true }) + @Type(() => VariantField) + fields!: VariantField[]; +} + +class VariantsConfig { + variants!: Record; +} + +export class BookingField { + @IsEnum(BaseField) + type!: BaseField; + + @IsString() + name!: string; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => Option) + options?: Option[]; + + @IsOptional() + @IsString() + label?: string; + + @IsOptional() + @IsString() + labelAsSafeHtml?: string; + + @IsOptional() + @IsString() + defaultLabel?: string; + + @IsOptional() + @IsString() + placeholder?: string; + + @IsOptional() + @IsBoolean() + required?: boolean; + + @IsOptional() + @IsString() + getOptionsAt?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OptionsInput) + optionsInputs?: Record; + + @IsOptional() + @IsString() + variant?: string; + + @IsOptional() + @ValidateNested() + @Type(() => VariantsConfig) + variantsConfig?: VariantsConfig; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => View) + views?: View[]; + + @IsOptional() + @IsBoolean() + hideWhenJustOneOption?: boolean; + + @IsOptional() + @IsBoolean() + hidden?: boolean; + + @IsOptional() + @IsEnum(Editable) + editable?: Editable; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => Source) + sources?: Source[]; +} + +export class RecurringEvent { + @IsDate() + @IsOptional() + dtstart?: Date; + + @IsInt() + interval!: number; + + @IsInt() + count!: number; + + @IsEnum(Frequency) + freq!: Frequency; + + @IsDate() + @IsOptional() + until?: Date; + + @IsString() + @IsOptional() + tzid?: string; +} + +export class IntervalLimits { + @IsNumber() + @IsOptional() + PER_DAY?: number; + + @IsNumber() + @IsOptional() + PER_WEEK?: number; + + @IsNumber() + @IsOptional() + PER_MONTH?: number; + + @IsNumber() + @IsOptional() + PER_YEAR?: number; +} + +export class UpdateEventTypeInput { + @IsInt() + @Min(1) + @IsOptional() + length?: number; + + @IsString() + @IsOptional() + slug?: string; + + @IsString() + @IsOptional() + title?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsBoolean() + @IsOptional() + hidden?: boolean; + + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation) + @IsOptional() + locations?: EventTypeLocation[]; + + // @IsInt() + // @IsOptional() + // position?: number; + + // @IsInt() + // @IsOptional() + // offsetStart?: number; + + // @IsInt() + // @IsOptional() + // userId?: number; + + // @IsInt() + // @IsOptional() + // profileId?: number; + + // @IsInt() + // @IsOptional() + // teamId?: number; + + // @IsString() + // @IsOptional() + // eventName?: string; + + // @IsInt() + // @IsOptional() + // parentId?: number; + + // @IsOptional() + // @IsArray() + // @ValidateNested({ each: true }) + // @Type(() => BookingField) + // bookingFields?: BookingField[]; + + // @IsString() + // @IsOptional() + // timeZone?: string; + + // @IsEnum(PeriodType) + // @IsOptional() + // periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type"; + + // @IsDate() + // @IsOptional() + // periodStartDate?: Date; + + // @IsDate() + // @IsOptional() + // periodEndDate?: Date; + + // @IsInt() + // @IsOptional() + // periodDays?: number; + + // @IsBoolean() + // @IsOptional() + // periodCountCalendarDays?: boolean; + + // @IsBoolean() + // @IsOptional() + // lockTimeZoneToggleOnBookingPage?: boolean; + + // @IsBoolean() + // @IsOptional() + // requiresConfirmation?: boolean; + + // @IsBoolean() + // @IsOptional() + // requiresBookerEmailVerification?: boolean; + + // @ValidateNested() + // @Type(() => RecurringEvent) + // @IsOptional() + // recurringEvent?: RecurringEvent; + + // @IsBoolean() + // @IsOptional() + // disableGuests?: boolean; + + // @IsBoolean() + // @IsOptional() + // hideCalendarNotes?: boolean; + + // @IsInt() + // @Min(0) + // @IsOptional() + // minimumBookingNotice?: number; + + // @IsInt() + // @IsOptional() + // beforeEventBuffer?: number; + + // @IsInt() + // @IsOptional() + // afterEventBuffer?: number; + + // @IsInt() + // @IsOptional() + // seatsPerTimeSlot?: number; + + // @IsBoolean() + // @IsOptional() + // onlyShowFirstAvailableSlot?: boolean; + + // @IsBoolean() + // @IsOptional() + // seatsShowAttendees?: boolean; + + // @IsBoolean() + // @IsOptional() + // seatsShowAvailabilityCount?: boolean; + + // @IsEnum(SchedulingType) + // @IsOptional() + // schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type"; + + // @IsInt() + // @IsOptional() + // scheduleId?: number; + + // @IsInt() + // @IsOptional() + // price?: number; + + // @IsString() + // @IsOptional() + // currency?: string; + + // @IsInt() + // @IsOptional() + // slotInterval?: number; + + // @IsString() + // @IsOptional() + // @IsUrl() + // successRedirectUrl?: string; + + // @ValidateNested() + // @Type(() => IntervalLimits) + // @IsOptional() + // bookingLimits?: IntervalLimits; + + // @ValidateNested() + // @Type(() => IntervalLimits) + // @IsOptional() + // durationLimits?: IntervalLimits; + + // @IsBoolean() + // @IsOptional() + // isInstantEvent?: boolean; + + // @IsBoolean() + // @IsOptional() + // assignAllTeamMembers?: boolean; + + // @IsBoolean() + // @IsOptional() + // useEventTypeDestinationCalendarEmail?: boolean; + + // @IsInt() + // @IsOptional() + // secondaryEmailId?: number; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts new file mode 100644 index 00000000000000..1a77102d4c5f8c --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts @@ -0,0 +1,20 @@ +import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput) + data!: EventTypeOutput; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts new file mode 100644 index 00000000000000..738ab324995b22 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts @@ -0,0 +1,38 @@ +import { + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/inputs/create-event-type.input"; +import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class DeleteData { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; +} + +export class DeleteEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => DeleteData) + data!: DeleteData; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts new file mode 100644 index 00000000000000..a165dfa685fa7e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts @@ -0,0 +1,227 @@ +import { + CREATE_EVENT_DESCRIPTION_EXAMPLE, + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/inputs/create-event-type.input"; +import { PeriodType } from "@/ee/event-types/inputs/enums/period-type"; +import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type"; +import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input"; +import { + BookingField, + IntervalLimits, + RecurringEvent, +} from "@/ee/event-types/inputs/update-event-type.input"; +import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDate, + IsEnum, + IsInt, + IsJSON, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +export class EventTypeOutput { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE }) + description!: string | null; + + @IsBoolean() + @ApiHideProperty() + hidden!: boolean; + + @ValidateNested({ each: true }) + @Type(() => EventTypeLocation) + @IsArray() + locations!: EventTypeLocation[] | null; + + @IsInt() + @ApiHideProperty() + position!: number; + + @IsInt() + @ApiHideProperty() + offsetStart!: number; + + @IsInt() + @ApiHideProperty() + userId!: number | null; + + @IsInt() + @ApiHideProperty() + profileId!: number | null; + + @IsInt() + @ApiHideProperty() + teamId!: number | null; + + @IsString() + @ApiHideProperty() + eventName!: string | null; + + @IsInt() + @ApiHideProperty() + parentId!: number | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookingField) + @ApiHideProperty() + bookingFields!: BookingField[] | null; + + @IsString() + @ApiHideProperty() + timeZone!: string | null; + + @IsEnum(PeriodType) + @ApiHideProperty() + periodType!: PeriodType | null; + + @IsDate() + @ApiHideProperty() + periodStartDate!: Date | null; + + @IsDate() + @ApiHideProperty() + periodEndDate!: Date | null; + + @IsInt() + @ApiHideProperty() + periodDays!: number | null; + + @IsBoolean() + @ApiHideProperty() + periodCountCalendarDays!: boolean | null; + + @IsBoolean() + @ApiHideProperty() + lockTimeZoneToggleOnBookingPage!: boolean; + + @IsBoolean() + @ApiHideProperty() + requiresConfirmation!: boolean; + + @IsBoolean() + @ApiHideProperty() + requiresBookerEmailVerification!: boolean; + + @ValidateNested() + @Type(() => RecurringEvent) + @IsOptional() + @ApiHideProperty() + recurringEvent!: RecurringEvent | null; + + @IsBoolean() + @ApiHideProperty() + disableGuests!: boolean; + + @IsBoolean() + @ApiHideProperty() + hideCalendarNotes!: boolean; + + @IsInt() + @ApiHideProperty() + minimumBookingNotice!: number; + + @IsInt() + @ApiHideProperty() + beforeEventBuffer!: number; + + @IsInt() + @ApiHideProperty() + afterEventBuffer!: number; + + @IsInt() + @ApiHideProperty() + seatsPerTimeSlot!: number | null; + + @IsBoolean() + @ApiHideProperty() + onlyShowFirstAvailableSlot!: boolean; + + @IsBoolean() + @ApiHideProperty() + seatsShowAttendees!: boolean; + + @IsBoolean() + @ApiHideProperty() + seatsShowAvailabilityCount!: boolean; + + @IsEnum(SchedulingType) + @ApiHideProperty() + schedulingType!: SchedulingType | null; + + @IsInt() + @ApiHideProperty() + scheduleId!: number | null; + + @IsNumber() + @ApiHideProperty() + price!: number; + + @IsString() + @ApiHideProperty() + currency!: string; + + @IsInt() + @ApiHideProperty() + slotInterval!: number | null; + + @IsJSON() + @ApiHideProperty() + metadata!: Record | null; + + @IsString() + @ApiHideProperty() + successRedirectUrl!: string | null; + + @ValidateNested() + @Type(() => IntervalLimits) + @IsOptional() + @ApiHideProperty() + bookingLimits!: IntervalLimits; + + @ValidateNested() + @Type(() => IntervalLimits) + @ApiHideProperty() + durationLimits!: IntervalLimits; + + @IsBoolean() + @ApiHideProperty() + isInstantEvent!: boolean; + + @IsBoolean() + @ApiHideProperty() + assignAllTeamMembers!: boolean; + + @IsBoolean() + @ApiHideProperty() + useEventTypeDestinationCalendarEmail!: boolean; + + @IsInt() + @ApiHideProperty() + secondaryEmailId!: number | null; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-type-public.output.ts b/apps/api/v2/src/ee/event-types/outputs/get-event-type-public.output.ts new file mode 100644 index 00000000000000..887e1f6bef117f --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/get-event-type-public.output.ts @@ -0,0 +1,355 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + IsUrl, + ValidateNested, + IsArray, + IsObject, + IsNumber, + IsEnum, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Location { + @IsString() + type!: string; +} + +class Source { + @IsString() + id!: string; + + @IsString() + type!: string; + + @IsString() + label!: string; +} + +class OptionInput { + @IsString() + type!: string; + + @IsBoolean() + @IsOptional() + required?: boolean; + + @IsString() + @IsOptional() + placeholder?: string; +} + +class BookingField { + @IsString() + name!: string; + + @IsString() + type!: string; + + @IsOptional() + @IsString() + defaultLabel?: string; + + @IsString() + @IsOptional() + label?: string; + + @IsString() + @IsOptional() + placeholder?: string; + + @IsBoolean() + @IsOptional() + required?: boolean; + + @IsOptional() + getOptionsAt?: string; + + @IsObject() + @IsOptional() + optionsInputs?: { [key: string]: OptionInput }; + + @IsBoolean() + @IsOptional() + hideWhenJustOneOption?: boolean; + + @IsString() + @IsOptional() + editable?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Source) + @IsOptional() + sources?: Source[]; +} + +class Organization { + @IsInt() + id!: number; + + @IsString() + @IsOptional() + slug?: string | null; + + @IsString() + name!: string; + + @IsOptional() + metadata!: Record; +} + +class Profile { + @IsString() + username!: string | null; + + @IsInt() + id!: number | null; + + @IsInt() + @IsOptional() + userId?: number; + + @IsString() + @IsOptional() + uid?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsInt() + organizationId!: number | null; + + @ValidateNested() + @Type(() => Organization) + organization?: Organization | null; + + @IsString() + upId!: string; + + @IsString() + @IsOptional() + image?: string; + + @IsString() + @IsOptional() + brandColor?: string; + + @IsString() + @IsOptional() + darkBrandColor?: string; + + @IsString() + @IsOptional() + theme?: string; + + @IsOptional() + bookerLayouts?: any; +} + +class Owner { + @IsInt() + id!: number; + + @IsString() + @IsOptional() + avatarUrl?: string | null; + + @IsString() + username!: string | null; + + @IsString() + name!: string | null; + + @IsString() + weekStart!: string; + + @IsString() + @IsOptional() + brandColor?: string | null; + + @IsString() + @IsOptional() + darkBrandColor?: string | null; + + @IsString() + @IsOptional() + theme?: string | null; + + @IsOptional() + metadata!: any; + + @IsInt() + @IsOptional() + defaultScheduleId?: number | null; + + @IsString() + nonProfileUsername!: string | null; + + @ValidateNested() + @Type(() => Profile) + profile!: Profile; +} + +class User { + @IsString() + username!: string | null; + + @IsString() + name!: string | null; + + @IsString() + weekStart!: string; + + @IsInt() + organizationId?: number; + + @IsString() + @IsOptional() + avatarUrl?: string | null; + + @ValidateNested() + profile!: Profile; + + @IsString() + bookerUrl!: string; +} + +class Schedule { + @IsInt() + id!: number; + + @IsString() + timeZone!: string | null; +} + +class PublicEventTypeOutput { + @IsInt() + id!: number; + + @IsString() + title!: string; + + @IsString() + description!: string; + + @IsString() + @IsOptional() + eventName?: string | null; + + @IsString() + slug!: string; + + @IsBoolean() + isInstantEvent!: boolean; + + @IsOptional() + aiPhoneCallConfig?: any; + + @IsOptional() + schedulingType?: any; + + @IsInt() + length!: number; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Location) + locations!: Location[]; + + @IsArray() + customInputs!: any[]; + + @IsBoolean() + disableGuests!: boolean; + + @IsObject() + metadata!: object | null; + + @IsBoolean() + lockTimeZoneToggleOnBookingPage!: boolean; + + @IsBoolean() + requiresConfirmation!: boolean; + + @IsBoolean() + requiresBookerEmailVerification!: boolean; + + @IsOptional() + recurringEvent?: any; + + @IsNumber() + price!: number; + + @IsString() + currency!: string; + + @IsOptional() + seatsPerTimeSlot?: number | null; + + @IsBoolean() + seatsShowAvailabilityCount!: boolean | null; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookingField) + bookingFields!: BookingField[]; + + @IsOptional() + team?: any; + + @IsOptional() + @IsUrl() + successRedirectUrl?: string | null; + + @IsArray() + workflows!: any[]; + + @IsArray() + hosts!: any[]; + + @ValidateNested() + @Type(() => Owner) + owner!: Owner | null; + + @ValidateNested() + @Type(() => Schedule) + schedule!: Schedule | null; + + @IsBoolean() + hidden!: boolean; + + @IsBoolean() + assignAllTeamMembers!: boolean; + + @IsOptional() + bookerLayouts?: any; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => User) + users!: User[]; + + @IsObject() + entity!: object; + + @IsBoolean() + isDynamic!: boolean; +} + +export class GetEventTypePublicOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => PublicEventTypeOutput) + @IsArray() + data!: PublicEventTypeOutput | null; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts new file mode 100644 index 00000000000000..605be799e97903 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts @@ -0,0 +1,28 @@ +import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class Data { + @ApiProperty({ + type: EventTypeOutput, + }) + @ValidateNested() + @Type(() => EventTypeOutput) + eventType!: EventTypeOutput; +} + +export class GetEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: Data, + }) + @ValidateNested() + @Type(() => Data) + data!: Data; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-types-public.output.ts b/apps/api/v2/src/ee/event-types/outputs/get-event-types-public.output.ts new file mode 100644 index 00000000000000..c3789fce154fc5 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/get-event-types-public.output.ts @@ -0,0 +1,43 @@ +import { + CREATE_EVENT_LENGTH_EXAMPLE, + CREATE_EVENT_SLUG_EXAMPLE, + CREATE_EVENT_TITLE_EXAMPLE, +} from "@/ee/event-types/inputs/create-event-type.input"; +import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty as DocsProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, IsInt, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class PublicEventType { + @IsInt() + @DocsProperty({ example: 1 }) + id!: number; + + @IsInt() + @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) + length!: number; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE }) + slug!: string; + + @IsString() + @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE }) + title!: string; + + @IsString() + description?: string | null; +} + +export class GetEventTypesPublicOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => PublicEventType) + @IsArray() + data!: PublicEventType[]; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts b/apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts new file mode 100644 index 00000000000000..735db07b935713 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts @@ -0,0 +1,31 @@ +import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class EventTypeGroup { + @ValidateNested({ each: true }) + @Type(() => EventTypeOutput) + @IsArray() + eventTypes!: EventTypeOutput[]; +} + +class GetEventTypesData { + @ValidateNested({ each: true }) + @Type(() => EventTypeGroup) + @IsArray() + eventTypeGroups!: EventTypeGroup[]; +} + +export class GetEventTypesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => GetEventTypesData) + @IsArray() + data!: GetEventTypesData; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts new file mode 100644 index 00000000000000..642be558a6d930 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts @@ -0,0 +1,20 @@ +import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateEventTypeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: EventTypeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EventTypeOutput) + data!: EventTypeOutput; +} diff --git a/apps/api/v2/src/ee/event-types/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/services/event-types.service.ts new file mode 100644 index 00000000000000..e55b0b1eba056d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/services/event-types.service.ts @@ -0,0 +1,164 @@ +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants"; +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; + +import { createEventType, updateEventType } from "@calcom/platform-libraries"; +import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries"; +import { EventType } from "@calcom/prisma/client"; + +@Injectable() +export class EventTypesService { + constructor( + private readonly eventTypesRepository: EventTypesRepository, + private readonly membershipsRepository: MembershipsRepository, + private readonly usersRepository: UsersRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly dbWrite: PrismaWriteService + ) {} + + async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) { + await this.checkCanCreateEventType(user.id, body); + const eventTypeUser = await this.getUserToCreateEvent(user); + const { eventType } = await createEventType({ + input: body, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + return eventType; + } + + async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) { + const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug); + if (existsWithSlug) { + throw new BadRequestException("User already has an event type with this slug."); + } + } + + async getUserToCreateEvent(user: UserWithProfile) { + const organizationId = user.movedToProfile?.organizationId || user.organizationId; + const isOrgAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + const profileId = user.movedToProfile?.id || null; + return { + id: user.id, + organizationId: user.organizationId, + organization: { isOrgAdmin }, + profile: { id: profileId }, + metadata: user.metadata, + }; + } + + async getUserEventType(userId: number, eventTypeId: number) { + const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId); + + if (!eventType) { + return null; + } + + this.checkUserOwnsEventType(userId, eventType); + return eventType; + } + + async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) { + const organizationId = user.movedToProfile?.organizationId || user.organizationId; + + const isUserOrganizationAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + + const eventType = await this.eventTypesRepository.getUserEventTypeForAtom( + user, + isUserOrganizationAdmin, + eventTypeId + ); + + if (!eventType) { + return null; + } + + this.checkUserOwnsEventType(user.id, eventType.eventType); + return eventType; + } + + async getEventTypesPublicByUsername(username: string): Promise { + const user = await this.usersRepository.findByUsername(username); + if (!user) { + throw new NotFoundException(`User with username "${username}" not found`); + } + + return await getEventTypesPublic(user.id); + } + + async createUserDefaultEventTypes(userId: number) { + const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES; + + const defaultEventTypes = await Promise.all([ + this.eventTypesRepository.createUserEventType(userId, thirtyMinutes), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutes), + this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo), + ]); + + return defaultEventTypes; + } + + async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) { + this.checkCanUpdateEventType(user.id, eventTypeId); + const eventTypeUser = await this.getUserToUpdateEvent(user); + await updateEventType({ + input: { id: eventTypeId, ...body }, + ctx: { + user: eventTypeUser, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbWrite.prisma, + }, + }); + + const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId); + + return eventType; + } + + async checkCanUpdateEventType(userId: number, eventTypeId: number) { + const existingEventType = await this.getUserEventType(userId, eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + this.checkUserOwnsEventType(userId, existingEventType); + } + + async getUserToUpdateEvent(user: UserWithProfile) { + const profileId = user.movedToProfile?.id || null; + const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id); + return { ...user, profile: { id: profileId }, selectedCalendars }; + } + + async deleteEventType(eventTypeId: number, userId: number) { + const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId); + if (!existingEventType) { + throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`); + } + + this.checkUserOwnsEventType(userId, existingEventType); + + return this.eventTypesRepository.deleteEventType(eventTypeId); + } + + checkUserOwnsEventType(userId: number, eventType: Pick) { + if (userId !== eventType.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`); + } + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts new file mode 100644 index 00000000000000..08bab9f6675dd7 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts @@ -0,0 +1,167 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Gcal Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let user: User; + let gcalCredentials: Credential; + let accessTokenSecret: string; + let refreshTokenSecret: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/ee/gcal/oauth/auth-url`) + .set("Authorization", `Bearer invalid_access_token`) + .expect(401); + }); + + it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/ee/gcal/oauth/auth-url`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/ee/gcal/oauth/save: without access token`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/ee/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/ee/gcal/oauth/save: without origin`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/ee/gcal/check with access token but without origin`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/ee/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .expect(400); + }); + + it(`/GET/ee/gcal/check without access token`, async () => { + await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401); + }); + + it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/ee/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => { + gcalCredentials = await credentialsRepositoryFixture.create( + "google_calendar", + {}, + user.id, + "google-calendar" + ); + await request(app.getHttpServer()) + .get(`/api/v2/ee/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await credentialsRepositoryFixture.delete(gcalCredentials.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts new file mode 100644 index 00000000000000..22d3e5d8f1260e --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -0,0 +1,167 @@ +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output"; +import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output"; +import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output"; +import { GCalService } from "@/modules/apps/services/gcal.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Redirect, + Req, + UnauthorizedException, + UseGuards, + Headers, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { Request } from "express"; +import { google } from "googleapis"; +import { z } from "zod"; + +import { + APPS_READ, + GOOGLE_CALENDAR_ID, + GOOGLE_CALENDAR_TYPE, + SUCCESS_STATUS, +} from "@calcom/platform-constants"; + +const CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +// Controller for the GCalConnect Atom +@Controller({ + path: "ee/gcal", + version: "2", +}) +@DocsTags("Google Calendar") +export class GcalController { + private readonly logger = new Logger("Platform Gcal Provider"); + + constructor( + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly config: ConfigService, + private readonly gcalService: GCalService, + private readonly calendarsService: CalendarsService + ) {} + + private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`; + + @Get("/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async redirect( + @Headers("Authorization") authorization: string, + @Req() req: Request + ): Promise { + const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri); + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: CALENDAR_SCOPES, + prompt: "consent", + state: `accessToken=${accessToken}&origin=${origin}`, + }); + return { status: SUCCESS_STATUS, data: { authUrl } }; + } + + @Get("/oauth/save") + @Redirect(undefined, 301) + @HttpCode(HttpStatus.OK) + async save(@Query("state") state: string, @Query("code") code: string): Promise { + const stateParams = new URLSearchParams(state); + const { accessToken, origin } = z + .object({ accessToken: z.string(), origin: z.string() }) + .parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") }); + + // User chose not to authorize your app or didn't authorize your app + // redirect directly without oauth code + if (!code) { + return { url: origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri); + const token = await oAuth2Client.getToken(parsedCode); + const key = token.res?.data; + const credential = await this.credentialRepository.createAppCredential( + GOOGLE_CALENDAR_TYPE, + key, + ownerId + ); + + oAuth2Client.setCredentials(key); + + const calendar = google.calendar({ + version: "v3", + auth: oAuth2Client, + }); + + const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); + + const primaryCal = cals.data.items?.find((cal) => cal.primary); + + if (primaryCal?.id) { + await this.selectedCalendarsRepository.createSelectedCalendar( + primaryCal.id, + credential.id, + ownerId, + GOOGLE_CALENDAR_ID + ); + } + + return { url: origin }; + } + + @Get("/check") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard, PermissionsGuard) + @Permissions([APPS_READ]) + async check(@GetUser("id") userId: number): Promise { + const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); + + if (!gcalCredentials) { + throw new BadRequestException("Credentials for google_calendar not found."); + } + + if (gcalCredentials.invalid) { + throw new BadRequestException("Invalid google oauth credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const googleCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE + ); + if (!googleCalendar) { + throw new UnauthorizedException("Google Calendar not connected."); + } + if (googleCalendar.error?.message) { + throw new UnauthorizedException(googleCalendar.error?.message); + } + + return { status: SUCCESS_STATUS }; + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.module.ts b/apps/api/v2/src/ee/gcal/gcal.module.ts new file mode 100644 index 00000000000000..e6c40d864ab272 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.module.ts @@ -0,0 +1,27 @@ +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GcalController } from "@/ee/gcal/gcal.controller"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { GCalService } from "@/modules/apps/services/gcal.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [ + AppsRepository, + ConfigService, + CredentialsRepository, + SelectedCalendarsRepository, + GCalService, + CalendarsService, + UsersRepository, + ], + controllers: [GcalController], +}) +export class GcalModule {} diff --git a/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts b/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts new file mode 100644 index 00000000000000..a550faf4e4e171 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class AuthUrlData { + @IsString() + authUrl!: string; +} + +export class GcalAuthUrlOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => AuthUrlData) + @ValidateNested() + data!: AuthUrlData; +} diff --git a/apps/api/v2/src/ee/gcal/outputs/check.output.ts b/apps/api/v2/src/ee/gcal/outputs/check.output.ts new file mode 100644 index 00000000000000..fd533efece2a3b --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/check.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GcalCheckOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts b/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts new file mode 100644 index 00000000000000..42f727169c9f41 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/outputs/save-redirect.output.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class GcalSaveRedirectOutput { + @IsString() + url!: string; +} diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts new file mode 100644 index 00000000000000..7c48064816134e --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -0,0 +1,140 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UserResponse } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Me Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let schedulesRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = "me-controller-e2e@api.com"; + let user: User; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [ + AppModule, + PrismaModule, + AvailabilitiesModule, + UsersModule, + TokensModule, + SchedulesModule, + ], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should get user associated with access token", async () => { + return request(app.getHttpServer()) + .get("/api/v2/ee/me") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(user.timeZone); + }); + }); + + it("should update user associated with access token", async () => { + const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" }; + + return request(app.getHttpServer()) + .patch("/api/v2/ee/me") + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(body.timeZone); + + if (user.defaultScheduleId) { + const defaultSchedule = await schedulesRepositoryFixture.getById(user.defaultScheduleId); + expect(defaultSchedule?.timeZone).toEqual(body.timeZone); + } + }); + }); + + it("should not update user associated with access token given invalid timezone", async () => { + const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" }; + + return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400); + }); + + it("should not update user associated with access token given invalid time format", async () => { + const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 }; + + return request(app.getHttpServer()) + .patch("/api/v2/ee/me") + .send(bodyWithIncorrectTimeFormat) + .expect(400); + }); + + it("should not update user associated with access token given invalid week start", async () => { + const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" }; + + return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts new file mode 100644 index 00000000000000..c32d59906f671b --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -0,0 +1,59 @@ +import { GetMeOutput } from "@/ee/me/outputs/get-me.output"; +import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; + +import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { userSchemaResponse } from "@calcom/platform-types"; + +@Controller({ + path: "ee/me", + version: "2", +}) +@UseGuards(AccessTokenGuard, PermissionsGuard) +@DocsTags("Me") +export class MeController { + constructor( + private readonly usersRepository: UsersRepository, + private readonly schedulesRepository: SchedulesService + ) {} + + @Get("/") + @Permissions([PROFILE_READ]) + async getMe(@GetUser() user: UserWithProfile): Promise { + const me = userSchemaResponse.parse(user); + + return { + status: SUCCESS_STATUS, + data: me, + }; + } + + @Patch("/") + @Permissions([PROFILE_WRITE]) + async updateMe( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateManagedUserInput + ): Promise { + const updatedUser = await this.usersRepository.update(user.id, bodySchedule); + if (bodySchedule.timeZone && user.defaultScheduleId) { + await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, { + timeZone: bodySchedule.timeZone, + }); + } + + const me = userSchemaResponse.parse(updatedUser); + + return { + status: SUCCESS_STATUS, + data: me, + }; + } +} diff --git a/apps/api/v2/src/ee/me/me.module.ts b/apps/api/v2/src/ee/me/me.module.ts new file mode 100644 index 00000000000000..b8802914f6c7d1 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.module.ts @@ -0,0 +1,11 @@ +import { MeController } from "@/ee/me/me.controller"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [UsersModule, SchedulesModule, TokensModule], + controllers: [MeController], +}) +export class MeModule {} diff --git a/apps/api/v2/src/ee/me/outputs/get-me.output.ts b/apps/api/v2/src/ee/me/outputs/get-me.output.ts new file mode 100644 index 00000000000000..9f0bdfd4e65cdf --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/get-me.output.ts @@ -0,0 +1,20 @@ +import { MeOutput } from "@/ee/me/outputs/me.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetMeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: MeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => MeOutput) + data!: MeOutput; +} diff --git a/apps/api/v2/src/ee/me/outputs/me.output.ts b/apps/api/v2/src/ee/me/outputs/me.output.ts new file mode 100644 index 00000000000000..060189d4d91c45 --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/me.output.ts @@ -0,0 +1,25 @@ +import { IsInt, IsEmail, IsOptional, IsString } from "class-validator"; + +export class MeOutput { + @IsInt() + id!: number; + + @IsString() + username!: string; + + @IsEmail() + email!: string; + + @IsInt() + timeFormat!: number; + + @IsInt() + @IsOptional() + defaultScheduleId!: number | null; + + @IsString() + weekStart!: string; + + @IsString() + timeZone!: string; +} diff --git a/apps/api/v2/src/ee/me/outputs/update-me.output.ts b/apps/api/v2/src/ee/me/outputs/update-me.output.ts new file mode 100644 index 00000000000000..53fc44cb26b6e6 --- /dev/null +++ b/apps/api/v2/src/ee/me/outputs/update-me.output.ts @@ -0,0 +1,20 @@ +import { MeOutput } from "@/ee/me/outputs/me.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateMeOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: MeOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => MeOutput) + data!: MeOutput; +} diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts new file mode 100644 index 00000000000000..fdf3ef34bb995d --- /dev/null +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -0,0 +1,29 @@ +import { BookingsModule } from "@/ee/bookings/bookings.module"; +import { CalendarsModule } from "@/ee/calendars/calendars.module"; +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { GcalModule } from "@/ee/gcal/gcal.module"; +import { MeModule } from "@/ee/me/me.module"; +import { ProviderModule } from "@/ee/provider/provider.module"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { SlotsModule } from "@/modules/slots/slots.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + GcalModule, + ProviderModule, + SchedulesModule, + MeModule, + EventTypesModule, + CalendarsModule, + BookingsModule, + SlotsModule, + ], +}) +export class PlatformEndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts b/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts new file mode 100644 index 00000000000000..ff5aa337e0092d --- /dev/null +++ b/apps/api/v2/src/ee/provider/outputs/verify-access-token.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class ProviderVerifyAccessTokenOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts b/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts new file mode 100644 index 00000000000000..24d96283d1bdf8 --- /dev/null +++ b/apps/api/v2/src/ee/provider/outputs/verify-client.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class ProviderVerifyClientOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts new file mode 100644 index 00000000000000..9168c8467c0226 --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -0,0 +1,64 @@ +import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output"; +import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "ee/provider", + version: "2", +}) +@DocsTags("Cal provider") +export class CalProviderController { + constructor(private readonly oauthClientRepository: OAuthClientRepository) {} + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + async verifyClientId(@Param("clientId") clientId: string): Promise { + if (!clientId) { + throw new NotFoundException(); + } + const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId); + + if (!oAuthClient) throw new UnauthorizedException(); + + return { + status: SUCCESS_STATUS, + }; + } + + @Get("/:clientId/access-token") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async verifyAccessToken( + @Param("clientId") clientId: string, + @GetUser() user: UserWithProfile + ): Promise { + if (!clientId) { + throw new BadRequestException(); + } + + if (!user) { + throw new UnauthorizedException(); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/provider/provider.module.ts b/apps/api/v2/src/ee/provider/provider.module.ts new file mode 100644 index 00000000000000..d96be50d3a6fbb --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.module.ts @@ -0,0 +1,13 @@ +import { CalProviderController } from "@/ee/provider/provider.controller"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [CredentialsRepository], + controllers: [CalProviderController], +}) +export class ProviderModule {} diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts new file mode 100644 index 00000000000000..7e6dec77f52b23 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts @@ -0,0 +1,198 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output"; +import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output"; +import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UpdateScheduleInput } from "@calcom/platform-types"; + +describe("Schedules Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = "schedules-controller-e2e@api.com"; + let user: User; + + let createdSchedule: CreateScheduleOutput["data"]; + const defaultAvailabilityDays = [1, 2, 3, 4, 5]; + const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z"; + const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z"; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [ + AppModule, + PrismaModule, + AvailabilitiesModule, + UsersModule, + TokensModule, + SchedulesModule, + ], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a default schedule", async () => { + const scheduleName = "schedule-name"; + const scheduleTimeZone = "Europe/Rome"; + const isDefault = true; + + const body: CreateScheduleInput = { + name: scheduleName, + timeZone: scheduleTimeZone, + isDefault, + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .send(body) + .expect(201) + .then(async (response) => { + const responseData: CreateScheduleOutput = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.isDefault).toEqual(isDefault); + expect(responseData.data.timeZone).toEqual(scheduleTimeZone); + expect(responseData.data.name).toEqual(scheduleName); + + const schedule = responseData.data.schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + const scheduleUser = schedule?.[0].userId + ? await userRepositoryFixture.get(schedule?.[0].userId) + : null; + expect(scheduleUser?.defaultScheduleId).toEqual(responseData.data.id); + createdSchedule = responseData.data; + }); + }); + + it("should get default schedule", async () => { + return request(app.getHttpServer()) + .get("/api/v2/schedules/default") + .expect(200) + .then(async (response) => { + const responseData: CreateScheduleOutput = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.id).toEqual(createdSchedule.id); + expect(responseData.data.schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId); + + const schedule = responseData.data.schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + }); + }); + + it("should get schedules", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules`) + .expect(200) + .then((response) => { + const responseData: GetSchedulesOutput = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data?.[0].id).toEqual(createdSchedule.id); + expect(responseData.data?.[0].schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId); + + const schedule = responseData.data?.[0].schedule; + expect(schedule).toBeDefined(); + expect(schedule.length).toEqual(1); + expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + }); + }); + + it("should update schedule name", async () => { + const newScheduleName = "new-schedule-name"; + + const body: UpdateScheduleInput = { + name: newScheduleName, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .send(body) + .expect(200) + .then((response: any) => { + const responseData: UpdateScheduleOutput = response.body; + expect(responseData.status).toEqual(SUCCESS_STATUS); + expect(responseData.data).toBeDefined(); + expect(responseData.data.schedule.name).toEqual(newScheduleName); + expect(responseData.data.schedule.id).toEqual(createdSchedule.id); + expect(responseData.data.schedule.userId).toEqual(createdSchedule.schedule[0].userId); + + const availability = responseData.data.schedule.availability; + expect(availability).toBeDefined(); + expect(availability?.length).toEqual(1); + expect(availability?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + createdSchedule.name = newScheduleName; + }); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + try { + await scheduleRepositoryFixture.deleteById(createdSchedule.id); + } catch (e) {} + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts new file mode 100644 index 00000000000000..1e953a9f5aec07 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts @@ -0,0 +1,131 @@ +import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output"; +import { DeleteScheduleOutput } from "@/ee/schedules/outputs/delete-schedule.output"; +import { GetDefaultScheduleOutput } from "@/ee/schedules/outputs/get-default-schedule.output"; +import { GetScheduleOutput } from "@/ee/schedules/outputs/get-schedule.output"; +import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output"; +import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Patch, + UseGuards, +} from "@nestjs/common"; +import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UpdateScheduleInput } from "@calcom/platform-types"; + +import { CreateScheduleInput } from "../inputs/create-schedule.input"; + +@Controller({ + path: "schedules", + version: "2", +}) +@UseGuards(AccessTokenGuard, PermissionsGuard) +@DocsTags("Schedules") +export class SchedulesController { + constructor(private readonly schedulesService: SchedulesService) {} + + @Post("/") + @Permissions([SCHEDULE_WRITE]) + async createSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: CreateScheduleInput + ): Promise { + const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule); + const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/default") + @Permissions([SCHEDULE_READ]) + @ApiResponse({ status: 200, description: "Returns the default schedule", type: GetDefaultScheduleOutput }) + async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise { + const schedule = await this.schedulesService.getUserScheduleDefault(user.id); + const scheduleFormatted = schedule + ? await this.schedulesService.formatScheduleForAtom(user, schedule) + : null; + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/:scheduleId") + @Permissions([SCHEDULE_READ]) + async getSchedule( + @GetUser() user: UserWithProfile, + @Param("scheduleId") scheduleId: number + ): Promise { + const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId); + const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/") + @Permissions([SCHEDULE_READ]) + async getSchedules(@GetUser() user: UserWithProfile): Promise { + const schedules = await this.schedulesService.getUserSchedules(user.id); + const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules); + + return { + status: SUCCESS_STATUS, + data: schedulesFormatted, + }; + } + + // note(Lauris): currently this endpoint is atoms only + @Patch("/:scheduleId") + @Permissions([SCHEDULE_WRITE]) + async updateSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateScheduleInput, + @Param("scheduleId") scheduleId: string + ): Promise { + const updatedSchedule = await this.schedulesService.updateUserSchedule( + user, + Number(scheduleId), + bodySchedule + ); + + return { + status: SUCCESS_STATUS, + data: updatedSchedule, + }; + } + + @Delete("/:scheduleId") + @HttpCode(HttpStatus.OK) + @Permissions([SCHEDULE_WRITE]) + async deleteSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts new file mode 100644 index 00000000000000..f90c291adf02a0 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts @@ -0,0 +1,20 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Type } from "class-transformer"; +import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator"; + +export class CreateScheduleInput { + @IsString() + name!: string; + + @IsTimeZone() + timeZone!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput) + @IsOptional() + availabilities?: CreateAvailabilityInput[]; + + @IsBoolean() + isDefault!: boolean; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts new file mode 100644 index 00000000000000..bbcdd1788700d4 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class CreateScheduleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/delete-schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/delete-schedule.output.ts new file mode 100644 index 00000000000000..023c013170ffea --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/delete-schedule.output.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteScheduleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/get-default-schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/get-default-schedule.output.ts new file mode 100644 index 00000000000000..cf8369fa21cbcc --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/get-default-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetDefaultScheduleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput | null; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/get-schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/get-schedule.output.ts new file mode 100644 index 00000000000000..f49e291e23a8c9 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/get-schedule.output.ts @@ -0,0 +1,20 @@ +import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetScheduleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ScheduleOutput) + data!: ScheduleOutput; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/get-schedules.output.ts b/apps/api/v2/src/ee/schedules/outputs/get-schedules.output.ts new file mode 100644 index 00000000000000..9f7f6ce32f918d --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/get-schedules.output.ts @@ -0,0 +1,21 @@ +import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetSchedulesOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: ScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested({ each: true }) + @Type(() => ScheduleOutput) + @IsArray() + data!: ScheduleOutput[]; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/schedule-updated.output.ts b/apps/api/v2/src/ee/schedules/outputs/schedule-updated.output.ts new file mode 100644 index 00000000000000..a16600db982ebe --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/schedule-updated.output.ts @@ -0,0 +1,95 @@ +import { Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator"; + +class EventTypeModel { + @IsInt() + id!: number; + + @IsOptional() + @IsString() + eventName?: string | null; +} + +class AvailabilityModel { + @IsInt() + id!: number; + + @IsOptional() + @IsInt() + userId?: number | null; + + @IsOptional() + @IsInt() + scheduleId?: number | null; + + @IsOptional() + @IsInt() + eventTypeId?: number | null; + + @IsArray() + @IsInt({ each: true }) + days!: number[]; + + @IsOptional() + @Type(() => Date) + @IsString() // Assuming date is serialized/deserialized appropriately + startTime?: Date; + + @IsOptional() + @Type(() => Date) + @IsString() // Assuming date is serialized/deserialized appropriately + endTime?: Date; + + @IsOptional() + @Type(() => Date) + @IsString() // Assuming date is serialized/deserialized appropriately + date?: Date | null; +} + +class ScheduleModel { + @IsInt() + id!: number; + + @IsInt() + userId!: number; + + @IsString() + name!: string; + + @IsOptional() + @IsString() + timeZone?: string | null; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => EventTypeModel) + @IsArray() + eventType?: EventTypeModel[]; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AvailabilityModel) + @IsArray() + availability?: AvailabilityModel[]; +} + +export class UpdatedScheduleOutput { + @ValidateNested() + @Type(() => ScheduleModel) + schedule!: ScheduleModel; + + @IsBoolean() + isDefault!: boolean; + + @IsOptional() + @IsString() + timeZone?: string; + + @IsOptional() + @IsInt() + prevDefaultId?: number | null; + + @IsOptional() + @IsInt() + currentDefaultId?: number | null; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/schedule.output.ts new file mode 100644 index 00000000000000..015bfb77d8d5ef --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/schedule.output.ts @@ -0,0 +1,105 @@ +import { Type } from "class-transformer"; +import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator"; + +class AvailabilityModel { + @IsInt() + id!: number; + + @IsOptional() + @IsInt() + userId?: number | null; + + @IsOptional() + @IsInt() + eventTypeId?: number | null; + + @IsArray() + @IsInt({ each: true }) + days!: number[]; + + @IsDate() + @Type(() => Date) + startTime!: Date; + + @IsDate() + @Type(() => Date) + endTime!: Date; + + @IsOptional() + @IsDate() + @Type(() => Date) + date?: Date | null; + + @IsOptional() + @IsInt() + scheduleId?: number | null; +} + +class WorkingHours { + @IsArray() + @IsInt({ each: true }) + days!: number[]; + + @IsInt() + startTime!: number; + + @IsInt() + endTime!: number; + + @IsOptional() + @IsInt() + userId?: number | null; +} + +class TimeRange { + @IsOptional() + @IsInt() + userId?: number | null; + + @IsDate() + start!: Date; + + @IsDate() + end!: Date; +} + +export class ScheduleOutput { + @IsInt() + id!: number; + + @IsString() + name!: string; + + @IsBoolean() + isManaged!: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkingHours) + workingHours!: WorkingHours[]; + + @ValidateNested({ each: true }) + @Type(() => AvailabilityModel) + @IsArray() + schedule!: AvailabilityModel[]; + + availability!: TimeRange[][]; + + @IsString() + timeZone!: string; + + @ValidateNested({ each: true }) + @IsArray() + // note(Lauris) it should be + // dateOverrides!: { ranges: TimeRange[] }[]; + // but docs aren't generating correctly it results in array of strings + dateOverrides!: unknown[]; + + @IsBoolean() + isDefault!: boolean; + + @IsBoolean() + isLastSchedule!: boolean; + + @IsBoolean() + readOnly!: boolean; +} diff --git a/apps/api/v2/src/ee/schedules/outputs/update-schedule.output.ts b/apps/api/v2/src/ee/schedules/outputs/update-schedule.output.ts new file mode 100644 index 00000000000000..70ebe9ba9b4991 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/outputs/update-schedule.output.ts @@ -0,0 +1,20 @@ +import { UpdatedScheduleOutput } from "@/ee/schedules/outputs/schedule-updated.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateScheduleOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: UpdatedScheduleOutput, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => UpdatedScheduleOutput) + data!: UpdatedScheduleOutput; +} diff --git a/apps/api/v2/src/ee/schedules/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules.module.ts new file mode 100644 index 00000000000000..3fe53634ee2da2 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.module.ts @@ -0,0 +1,16 @@ +import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, AvailabilitiesModule, UsersModule, TokensModule], + providers: [SchedulesRepository, SchedulesService], + controllers: [SchedulesController], + exports: [SchedulesService, SchedulesRepository], +}) +export class SchedulesModule {} diff --git a/apps/api/v2/src/ee/schedules/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules.repository.ts new file mode 100644 index 00000000000000..b447b6b4691413 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.repository.ts @@ -0,0 +1,95 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createScheduleWithAvailabilities( + userId: number, + schedule: CreateScheduleInput, + availabilities: CreateAvailabilityInput[] + ) { + const createScheduleData: Prisma.ScheduleCreateInput = { + user: { + connect: { + id: userId, + }, + }, + name: schedule.name, + timeZone: schedule.timeZone, + }; + + if (availabilities.length > 0) { + createScheduleData.availability = { + createMany: { + data: availabilities.map((availability) => { + return { + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }; + }), + }, + }; + } + + const createdSchedule = await this.dbWrite.prisma.schedule.create({ + data: { + ...createScheduleData, + }, + include: { + availability: true, + }, + }); + + return createdSchedule; + } + + async getScheduleById(scheduleId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + }, + include: { + availability: true, + }, + }); + + return schedule; + } + + async getSchedulesByUserId(userId: number) { + const schedules = await this.dbRead.prisma.schedule.findMany({ + where: { + userId, + }, + include: { + availability: true, + }, + }); + + return schedules; + } + + async deleteScheduleById(scheduleId: number) { + return this.dbWrite.prisma.schedule.delete({ + where: { + id: scheduleId, + }, + }); + } + + async getUserSchedulesCount(userId: number) { + return this.dbRead.prisma.schedule.count({ + where: { + userId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/schedules/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/services/schedules.service.ts new file mode 100644 index 00000000000000..d74abd6cebf0c4 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/services/schedules.service.ts @@ -0,0 +1,151 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Schedule } from "@prisma/client"; +import { User } from "@prisma/client"; + +import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries"; +import { updateScheduleHandler } from "@calcom/platform-libraries"; +import { + transformWorkingHoursForClient, + transformAvailabilityForClient, + transformDateOverridesForClient, +} from "@calcom/platform-libraries"; +import { UpdateScheduleInput } from "@calcom/platform-types"; + +@Injectable() +export class SchedulesService { + constructor( + private readonly schedulesRepository: SchedulesRepository, + private readonly availabilitiesService: AvailabilitiesService, + private readonly usersRepository: UsersRepository + ) {} + + async createUserSchedule(userId: number, schedule: CreateScheduleInput) { + const availabilities = schedule.availabilities?.length + ? schedule.availabilities + : [this.availabilitiesService.getDefaultAvailabilityInput()]; + + const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities( + userId, + schedule, + availabilities + ); + + if (schedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id); + } + + return createdSchedule; + } + + async getUserScheduleDefault(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return this.schedulesRepository.getScheduleById(user.defaultScheduleId); + } + + async getUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return existingSchedule; + } + + async getUserSchedules(userId: number) { + return this.schedulesRepository.getSchedulesByUserId(userId); + } + + async updateUserSchedule(user: UserWithProfile, scheduleId: number, bodySchedule: UpdateScheduleInput) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(user.id, existingSchedule); + + const schedule = await this.getUserSchedule(user.id, Number(scheduleId)); + const scheduleFormatted = await this.formatScheduleForAtom(user, schedule); + + if (!bodySchedule.schedule) { + // note(Lauris): When updating an availability in cal web app, lets say only its name, also + // the schedule is sent and then passed to the update handler. Notably, availability is passed too + // and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect + // schedule that would be passed by the web app. If we don't, then updating schedule name will erase + // schedule. + bodySchedule.schedule = scheduleFormatted.availability; + } + + return updateScheduleHandler({ + input: { scheduleId: Number(scheduleId), ...bodySchedule }, + ctx: { user }, + }); + } + + async deleteUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.schedulesRepository.deleteScheduleById(scheduleId); + } + + async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return this.transformScheduleForAtom(schedule, usersSchedulesCount, user); + } + + async formatSchedulesForAtom( + user: User, + schedules: ScheduleWithAvailabilities[] + ): Promise { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return Promise.all( + schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user)) + ); + } + + async transformScheduleForAtom( + schedule: ScheduleWithAvailabilities, + userSchedulesCount: number, + user: Pick + ): Promise { + const timeZone = schedule.timeZone || user.timeZone; + const defaultSchedule = await this.getUserScheduleDefault(user.id); + + return { + id: schedule.id, + name: schedule.name, + isManaged: schedule.userId !== user.id, + workingHours: transformWorkingHoursForClient(schedule), + schedule: schedule.availability, + availability: transformAvailabilityForClient(schedule), + timeZone, + dateOverrides: transformDateOverridesForClient(schedule, timeZone), + isDefault: defaultSchedule?.id === schedule.id, + isLastSchedule: userSchedulesCount <= 1, + readOnly: schedule.userId !== user.id, + }; + } + + checkUserOwnsSchedule(userId: number, schedule: Pick) { + if (userId !== schedule.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); + } + } +} diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts new file mode 100644 index 00000000000000..d1840bf6a52b7c --- /dev/null +++ b/apps/api/v2/src/env.ts @@ -0,0 +1,28 @@ +import { logLevels } from "@/lib/logger"; + +export type Environment = { + NODE_ENV: "development" | "production"; + API_PORT: string; + API_URL: string; + DATABASE_READ_URL: string; + DATABASE_WRITE_URL: string; + NEXTAUTH_SECRET: string; + DATABASE_URL: string; + JWT_SECRET: string; + SENTRY_DSN: string; + LOG_LEVEL: keyof typeof logLevels; + REDIS_URL: string; +}; + +export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { + const value = process.env[key] as Environment[K] | undefined; + + if (!value) { + if (fallback) { + return fallback; + } + throw new Error(`Missing environment variable: ${key}.`); + } + + return value; +}; diff --git a/apps/api/v2/src/filters/http-exception.filter.ts b/apps/api/v2/src/filters/http-exception.filter.ts new file mode 100644 index 00000000000000..c47e1ff936c9df --- /dev/null +++ b/apps/api/v2/src/filters/http-exception.filter.ts @@ -0,0 +1,30 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common"; +import { Request } from "express"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("HttpExceptionFilter"); + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const statusCode = exception.getStatus(); + this.logger.error(`Http Exception Filter: ${exception?.message}`, { + exception, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + }); + response.status(statusCode).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: exception.name, message: exception.message }, + }); + } +} diff --git a/apps/api/v2/src/filters/prisma-exception.filter.ts b/apps/api/v2/src/filters/prisma-exception.filter.ts new file mode 100644 index 00000000000000..4490a0de8c8ac1 --- /dev/null +++ b/apps/api/v2/src/filters/prisma-exception.filter.ts @@ -0,0 +1,50 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime/library"; +import { Request } from "express"; + +import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +type PrismaError = + | PrismaClientInitializationError + | PrismaClientKnownRequestError + | PrismaClientRustPanicError + | PrismaClientUnknownRequestError + | PrismaClientValidationError; + +@Catch( + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError +) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("PrismaExceptionFilter"); + + catch(error: PrismaError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + this.logger.error(`PrismaError: ${error.message}`, { + error, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + }); + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: INTERNAL_SERVER_ERROR, message: "There was an error, please try again later." }, + }); + } +} diff --git a/apps/api/v2/src/filters/sentry-exception.filter.ts b/apps/api/v2/src/filters/sentry-exception.filter.ts new file mode 100644 index 00000000000000..479525ecf47c20 --- /dev/null +++ b/apps/api/v2/src/filters/sentry-exception.filter.ts @@ -0,0 +1,37 @@ +import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common"; +import { BaseExceptionFilter } from "@nestjs/core"; +import * as Sentry from "@sentry/node"; +import { Request } from "express"; + +import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch() +export class SentryFilter extends BaseExceptionFilter { + private readonly logger = new Logger("SentryExceptionFilter"); + + handleUnknownError(exception: any, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + this.logger.error(`Sentry Exception Filter: ${exception?.message}`, { + exception, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + }); + + // capture if client has been init + if (Boolean(Sentry.getCurrentHub().getClient())) { + Sentry.captureException(exception); + } + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: INTERNAL_SERVER_ERROR, message: "Internal server error." }, + }); + } +} diff --git a/apps/api/v2/src/filters/trpc-exception.filter.ts b/apps/api/v2/src/filters/trpc-exception.filter.ts new file mode 100644 index 00000000000000..e65e94af1628c7 --- /dev/null +++ b/apps/api/v2/src/filters/trpc-exception.filter.ts @@ -0,0 +1,59 @@ +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common"; +import { Request } from "express"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { TRPCError } from "@calcom/platform-libraries"; +import { Response } from "@calcom/platform-types"; + +@Catch(TRPCError) +export class TRPCExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("TRPCExceptionFilter"); + + catch(exception: TRPCError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let statusCode = 500; + switch (exception.code) { + case "UNAUTHORIZED": + statusCode = 401; + break; + case "FORBIDDEN": + statusCode = 403; + break; + case "NOT_FOUND": + statusCode = 404; + break; + case "INTERNAL_SERVER_ERROR": + statusCode = 500; + break; + case "BAD_REQUEST": + statusCode = 400; + break; + case "CONFLICT": + statusCode = 409; + break; + case "TOO_MANY_REQUESTS": + statusCode = 429; + default: + statusCode = 500; + break; + } + + this.logger.error(`TRPC Exception Filter: ${exception?.message}`, { + exception, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + }); + + response.status(statusCode).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: exception.name, message: exception.message }, + }); + } +} diff --git a/apps/api/v2/src/filters/zod-exception.filter.ts b/apps/api/v2/src/filters/zod-exception.filter.ts new file mode 100644 index 00000000000000..eb962ff5fa001a --- /dev/null +++ b/apps/api/v2/src/filters/zod-exception.filter.ts @@ -0,0 +1,39 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { Request } from "express"; +import { ZodError } from "zod"; + +import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(ZodError) +export class ZodExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("ZodExceptionFilter"); + + catch(error: ZodError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + this.logger.error(`ZodError: ${error.message}`, { + error, + body: request.body, + headers: request.headers, + url: request.url, + method: request.method, + }); + + response.status(HttpStatus.BAD_REQUEST).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { + code: BAD_REQUEST, + message: error.issues.reduce( + (acc, issue) => `${issue.path.join(".")} - ${issue.message}, ${acc}`, + "" + ), + }, + }); + } +} diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts new file mode 100644 index 00000000000000..d292bd06e8bdce --- /dev/null +++ b/apps/api/v2/src/lib/api-key/index.ts @@ -0,0 +1,3 @@ +import { createHash } from "crypto"; + +export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); diff --git a/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts new file mode 100644 index 00000000000000..bbcadf9d2a5e71 --- /dev/null +++ b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const ForAtom = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.query.for === "atom"; +}); diff --git a/apps/api/v2/src/lib/logger.ts b/apps/api/v2/src/lib/logger.ts new file mode 100644 index 00000000000000..43378b13bc076e --- /dev/null +++ b/apps/api/v2/src/lib/logger.ts @@ -0,0 +1,50 @@ +import type { LoggerOptions } from "winston"; +import { format, transports } from "winston"; + +const formattedTimestamp = format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss.SSS", +}); + +const colorizer = format.colorize({ + colors: { + fatal: "red", + error: "red", + warn: "yellow", + info: "blue", + debug: "white", + trace: "grey", + }, +}); + +const WINSTON_DEV_FORMAT = format.combine( + format.errors({ stack: true }), + colorizer, + formattedTimestamp, + format.simple() +); +const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json()); + +export const logLevels = { + fatal: 0, + error: 1, + warn: 2, + info: 3, + debug: 4, + trace: 5, +} as const; + +export const loggerConfig = (): LoggerOptions => { + const isProduction = process.env.NODE_ENV === "production"; + + return { + levels: logLevels, + level: process.env.LOG_LEVEL ?? "info", + format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT, + transports: [new transports.Console()], + exceptionHandlers: [new transports.Console()], + rejectionHandlers: [new transports.Console()], + defaultMeta: { + service: "cal-platform-api", + }, + }; +}; diff --git a/apps/api/v2/src/lib/passport/strategies/types.ts b/apps/api/v2/src/lib/passport/strategies/types.ts new file mode 100644 index 00000000000000..34bf58941d69c4 --- /dev/null +++ b/apps/api/v2/src/lib/passport/strategies/types.ts @@ -0,0 +1,11 @@ +import { UserWithProfile } from "@/modules/users/users.repository"; + +export class BaseStrategy { + success!: (user: unknown) => void; + error!: (error: Error) => void; +} + +export class NextAuthPassportStrategy { + success!: (user: UserWithProfile) => void; + error!: (error: Error) => void; +} diff --git a/apps/api/v2/src/lib/response/response.dto.ts b/apps/api/v2/src/lib/response/response.dto.ts new file mode 100644 index 00000000000000..e4ec569e6318f3 --- /dev/null +++ b/apps/api/v2/src/lib/response/response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class BaseApiResponseDto { + @ApiProperty({ example: "success" }) + @IsString() + @IsNotEmpty() + status: string; + + @ApiProperty({ + description: "The payload of the response, which can be any type of data.", + }) + data: T; + + constructor(status: string, data: T) { + this.status = status; + this.data = data; + } +} + +export class OAuthClientDto { + @ApiProperty({ example: "abc123" }) + @IsString() + clientId!: string; + + @ApiProperty({ example: "secretKey123" }) + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts new file mode 100644 index 00000000000000..e149793395326c --- /dev/null +++ b/apps/api/v2/src/main.ts @@ -0,0 +1,61 @@ +import type { AppConfig } from "@/config/type"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import "dotenv/config"; +import * as fs from "fs"; +import { Server } from "http"; +import { WinstonModule } from "nest-winston"; + +import { bootstrap } from "./app"; +import { AppModule } from "./app.module"; +import { loggerConfig } from "./lib/logger"; + +const run = async () => { + const app = await NestFactory.create(AppModule, { + logger: WinstonModule.createLogger(loggerConfig()), + }); + + const logger = new Logger("App"); + + try { + bootstrap(app); + const port = app.get(ConfigService).get("api.port", { infer: true }); + generateSwagger(app); + await app.listen(port); + logger.log(`Application started on port: ${port}`); + } catch (error) { + logger.error("Application crashed", { + error, + }); + } +}; + +async function generateSwagger(app: NestExpressApplication) { + const logger = new Logger("App"); + logger.log(`Generating Swagger documentation...\n`); + + const config = new DocumentBuilder().setTitle("Cal.com v2 API").build(); + + const document = SwaggerModule.createDocument(app, config); + + const outputFile = "./swagger/documentation.json"; + + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + + fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" }); + SwaggerModule.setup("docs", app, document, { + customCss: ".swagger-ui .topbar { display: none }", + }); + + logger.log(`Swagger documentation available in the "/docs" endpoint\n`); +} + +run().catch((error: Error) => { + console.error("Failed to start Cal Platform API", { error: error.stack }); + process.exit(1); +}); diff --git a/apps/api/v2/src/modules/api-key/api-key.module.ts b/apps/api/v2/src/modules/api-key/api-key.module.ts new file mode 100644 index 00000000000000..6c3d86ba058c7c --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key.module.ts @@ -0,0 +1,10 @@ +import { ApiKeyService } from "@/modules/api-key/api-key.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ApiKeyService], + exports: [ApiKeyService], +}) +export class ApiKeyModule {} diff --git a/apps/api/v2/src/modules/api-key/api-key.service.ts b/apps/api/v2/src/modules/api-key/api-key.service.ts new file mode 100644 index 00000000000000..c381fa99be0dc6 --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key.service.ts @@ -0,0 +1,25 @@ +import { hashAPIKey } from "@/lib/api-key"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import type { Request } from "express"; + +@Injectable() +export class ApiKeyService { + constructor(private readonly dbRead: PrismaReadService) {} + + async retrieveApiKey(request: Request) { + const apiKey = request.get("Authorization")?.replace("Bearer ", ""); + + if (!apiKey) { + return null; + } + + const hashedKey = hashAPIKey(apiKey.replace("cal_", "")); + + return this.dbRead.prisma.apiKey.findUniqueOrThrow({ + where: { + hashedKey, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/apps/apps.module.ts b/apps/api/v2/src/modules/apps/apps.module.ts new file mode 100644 index 00000000000000..587b18ce8d91cf --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.module.ts @@ -0,0 +1,14 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule], + providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository], + exports: [], +}) +export class AppsModule {} diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts new file mode 100644 index 00000000000000..c2a74e1421d304 --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -0,0 +1,13 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { App } from "@prisma/client"; + +@Injectable() +export class AppsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getAppBySlug(slug: string): Promise { + return await this.dbRead.prisma.app.findUnique({ where: { slug } }); + } +} diff --git a/apps/api/v2/src/modules/apps/services/gcal.service.ts b/apps/api/v2/src/modules/apps/services/gcal.service.ts new file mode 100644 index 00000000000000..fa835f8ea6c816 --- /dev/null +++ b/apps/api/v2/src/modules/apps/services/gcal.service.ts @@ -0,0 +1,27 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { google } from "googleapis"; +import { z } from "zod"; + +@Injectable() +export class GCalService { + private logger = new Logger("GcalService"); + + private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + + constructor(private readonly appsRepository: AppsRepository) {} + + async getOAuthClient(redirectUri: string) { + this.logger.log("Getting Google Calendar OAuth Client"); + const app = await this.appsRepository.getAppBySlug("google-calendar"); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys); + + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri); + return oAuth2Client; + } +} diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts new file mode 100644 index 00000000000000..aae58abb100e8a --- /dev/null +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -0,0 +1,26 @@ +import { ApiKeyModule } from "@/modules/api-key/api-key.module"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; +import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-key-auth.strategy"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; +import { PassportModule } from "@nestjs/passport"; + +@Module({ + imports: [PassportModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule], + providers: [ + ApiKeyAuthStrategy, + NextAuthGuard, + NextAuthStrategy, + AccessTokenGuard, + AccessTokenStrategy, + OAuthFlowService, + ], + exports: [NextAuthGuard, AccessTokenGuard], +}) +export class AuthModule {} diff --git a/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts new file mode 100644 index 00000000000000..28ec9d5e3a34ed --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts @@ -0,0 +1,27 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; +import { User } from "@prisma/client"; + +export const GetUser = createParamDecorator((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as User; + + if (!user) { + throw new Error("GetUser decorator : User not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: request.user[curr], + }; + }, {}); + } + + if (data) { + return request.user[data]; + } + + return user; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts new file mode 100644 index 00000000000000..425a3006daa6ea --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { Reflector } from "@nestjs/core"; + +import { PERMISSIONS } from "@calcom/platform-constants"; + +export const Permissions = Reflector.createDecorator<(typeof PERMISSIONS)[number][]>(); diff --git a/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts new file mode 100644 index 00000000000000..d1a4511770d1f0 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts @@ -0,0 +1,4 @@ +import { Reflector } from "@nestjs/core"; +import { MembershipRole } from "@prisma/client"; + +export const Roles = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts b/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts new file mode 100644 index 00000000000000..2543c644549ae1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class AccessTokenGuard extends AuthGuard("access-token") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts b/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts new file mode 100644 index 00000000000000..1e3f4eea837ec1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts @@ -0,0 +1,9 @@ +import { HttpException } from "@nestjs/common"; + +import { ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED } from "@calcom/platform-constants"; + +export class TokenExpiredException extends HttpException { + constructor() { + super(ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts new file mode 100644 index 00000000000000..a2597709da7e8d --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class NextAuthGuard extends AuthGuard("next-auth") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts new file mode 100644 index 00000000000000..c55702be8816e1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts @@ -0,0 +1,61 @@ +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { MembershipRole } from "@calcom/prisma/enums"; + +@Injectable() +export class OrganizationRolesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private organizationsService: OrganizationsService, + private membershipRepository: MembershipsRepository + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user: UserWithProfile = request.user; + const organizationId = user?.movedToProfile?.organizationId || user?.organizationId; + + if (!user || !organizationId) { + throw new ForbiddenException("No organization associated with the user."); + } + + await this.isPlatform(organizationId); + + const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id); + const allowedRoles = this.reflector.get(Roles, context.getHandler()); + + this.isMembershipAccepted(membership.accepted); + this.isRoleAllowed(membership.role, allowedRoles); + + return true; + } + + async isPlatform(organizationId: number) { + const isPlatform = await this.organizationsService.isPlatform(organizationId); + if (!isPlatform) { + throw new ForbiddenException("Organization is not a platform (SHP)."); + } + } + + isMembershipAccepted(accepted: boolean) { + if (!accepted) { + throw new ForbiddenException(`User has not accepted membership in the organization.`); + } + } + + isRoleAllowed(membershipRole: MembershipRole, allowedRoles: MembershipRole[]) { + if (!allowedRoles?.length || !Object.keys(allowedRoles)?.length) { + return true; + } + + const hasRequiredRole = allowedRoles.includes(membershipRole); + if (!hasRequiredRole) { + throw new ForbiddenException(`User must have one of the roles: ${allowedRoles.join(", ")}.`); + } + } +} diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts new file mode 100644 index 00000000000000..0bd07651deeb6f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -0,0 +1,99 @@ +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; + +import { PermissionsGuard } from "./permissions.guard"; + +describe("PermissionsGuard", () => { + let guard: PermissionsGuard; + let reflector: Reflector; + + beforeEach(async () => { + reflector = new Reflector(); + guard = new PermissionsGuard(reflector, createMock()); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + describe("when access token is missing", () => { + it("should return false", async () => { + const mockContext = createMockExecutionContext({}); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + describe("when access token is provided", () => { + it("should return true for valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for multiple valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + oAuthClientPermissions |= SCHEDULE_READ; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for empty Permissions decorator", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false for invalid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= APPS_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false for a missing permission", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + function createMockExecutionContext(headers: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts new file mode 100644 index 00000000000000..4fec7f87009d03 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -0,0 +1,39 @@ +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { hasPermissions } from "@calcom/platform-utils"; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector, private tokensRepository: TokensRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermissions = this.reflector.get(Permissions, context.getHandler()); + + if (!requiredPermissions?.length || !Object.keys(requiredPermissions)?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const accessToken = request.get("Authorization")?.replace("Bearer ", ""); + + if (!accessToken) { + return false; + } + + const oAuthClientPermissions = await this.getOAuthClientPermissions(accessToken); + + if (!oAuthClientPermissions) { + return false; + } + + return hasPermissions(oAuthClientPermissions, [...requiredPermissions]); + } + + async getOAuthClientPermissions(accessToken: string) { + const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken); + return oAuthClient?.permissions; + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts new file mode 100644 index 00000000000000..53e055d2d29fc3 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts @@ -0,0 +1,58 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-token") { + constructor( + private readonly oauthFlowService: OAuthFlowService, + private readonly tokensRepository: TokensRepository, + private readonly userRepository: UsersRepository + ) { + super(); + } + + async authenticate(request: Request) { + try { + const accessToken = request.get("Authorization")?.replace("Bearer ", ""); + const requestOrigin = request.get("Origin"); + + if (!accessToken) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + await this.oauthFlowService.validateAccessToken(accessToken); + + const client = await this.tokensRepository.getAccessTokenClient(accessToken); + if (!client) { + throw new UnauthorizedException("OAuth client not found given the access token"); + } + + if (requestOrigin && !client.redirectUris.some((uri) => uri.startsWith(requestOrigin))) { + throw new UnauthorizedException("Invalid request origin"); + } + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); + + if (!user) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + return this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts new file mode 100644 index 00000000000000..40c7f1c970ca00 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts @@ -0,0 +1,39 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { ApiKeyService } from "@/modules/api-key/api-key.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; + +@Injectable() +export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key") { + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly userRepository: UsersRepository + ) { + super(); + } + + async authenticate(req: Request) { + try { + const apiKey = await this.apiKeyService.retrieveApiKey(req); + + if (!apiKey) { + throw new UnauthorizedException("Authorization token is missing."); + } + + if (apiKey.expiresAt && new Date() > apiKey.expiresAt) { + throw new UnauthorizedException("The API key is expired."); + } + + const user = await this.userRepository.findById(apiKey.userId); + if (!user) { + throw new NotFoundException("User not found."); + } + + this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts new file mode 100644 index 00000000000000..e6f7b572656865 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts @@ -0,0 +1,38 @@ +import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; +import { getToken } from "next-auth/jwt"; + +@Injectable() +export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") { + constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) { + super(); + } + + async authenticate(req: Request) { + try { + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const payload = await getToken({ req, secret: nextAuthSecret }); + + if (!payload) { + throw new UnauthorizedException("Authentication token is missing or invalid."); + } + + if (!payload.email) { + throw new UnauthorizedException("Email not found in the authentication token."); + } + + const user = await this.userRepository.findByEmailWithProfile(payload.email); + if (!user) { + throw new UnauthorizedException("User associated with the authentication token email not found."); + } + + return this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/availabilities/availabilities.module.ts b/apps/api/v2/src/modules/availabilities/availabilities.module.ts new file mode 100644 index 00000000000000..f3fe35bf6a7314 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.module.ts @@ -0,0 +1,10 @@ +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [AvailabilitiesService], + exports: [AvailabilitiesService], +}) +export class AvailabilitiesModule {} diff --git a/apps/api/v2/src/modules/availabilities/availabilities.service.ts b/apps/api/v2/src/modules/availabilities/availabilities.service.ts new file mode 100644 index 00000000000000..fc7dc14ec494c3 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.service.ts @@ -0,0 +1,16 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class AvailabilitiesService { + getDefaultAvailabilityInput(): CreateAvailabilityInput { + const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0)); + const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0)); + + return { + days: [1, 2, 3, 4, 5], + startTime, + endTime, + }; + } +} diff --git a/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts new file mode 100644 index 00000000000000..f03698705b2afc --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts @@ -0,0 +1,48 @@ +import { BadRequestException } from "@nestjs/common"; +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, TransformFnParams } from "class-transformer"; +import { IsArray, IsDate, IsNumber } from "class-validator"; + +export class CreateAvailabilityInput { + @IsArray() + @IsNumber({}, { each: true }) + @ApiProperty({ example: [1, 2] }) + days!: number[]; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + startTime!: Date; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + endTime!: Date; +} + +function transformStringToDate(value: string, key: string): Date { + // note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z + const dateTimeParts = value.split("T"); + + const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds + const parts = timePart.split(":"); + + if (parts.length !== 3) { + throw new BadRequestException( + `Invalid time format. Expected format(ISO8061): 2025-0412T13:17:56.324Z. Received: ${value}` + ); + } + const [hours, minutes, seconds] = parts.map(Number); + + if (hours < 0 || hours > 23) { + throw new BadRequestException(`Invalid ${key} hours. Expected value between 0 and 23`); + } + + if (minutes < 0 || minutes > 59) { + throw new BadRequestException(`Invalid ${key} minutes. Expected value between 0 and 59`); + } + + if (seconds < 0 || seconds > 59) { + throw new BadRequestException(`Invalid ${key} seconds. Expected value between 0 and 59`); + } + + return new Date(new Date().setUTCHours(hours, minutes, seconds, 0)); +} diff --git a/apps/api/v2/src/modules/credentials/credential.module.ts b/apps/api/v2/src/modules/credentials/credential.module.ts new file mode 100644 index 00000000000000..c837e49328880d --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credential.module.ts @@ -0,0 +1,9 @@ +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [], + providers: [CredentialsRepository], + exports: [CredentialsRepository], +}) +export class CredentialsModule {} diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts new file mode 100644 index 00000000000000..5a496cc85fa4de --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -0,0 +1,67 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; + +@Injectable() +export class CredentialsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createAppCredential( + type: keyof typeof APPS_TYPE_ID_MAPPING, + key: Prisma.InputJsonValue, + userId: number + ) { + const credential = await this.getByTypeAndUserId(type, userId); + return this.dbWrite.prisma.credential.upsert({ + create: { + type, + key, + userId, + appId: APPS_TYPE_ID_MAPPING[type], + }, + update: { + key, + invalid: false, + }, + where: { + id: credential?.id ?? 0, + }, + }); + } + + getByTypeAndUserId(type: string, userId: number) { + return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } }); + } + + getUserCredentialsByIds(userId: number, credentialIds: number[]) { + return this.dbRead.prisma.credential.findMany({ + where: { + id: { + in: credentialIds, + }, + userId: userId, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + } +} + +export type CredentialsWithUserEmail = Awaited< + ReturnType +>; diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts new file mode 100644 index 00000000000000..de9be4b261ee4b --- /dev/null +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -0,0 +1,15 @@ +import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { TimezoneModule } from "@/modules/timezones/timezones.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [OAuthClientModule, PlatformEndpointsModule, TimezoneModule], +}) +export class EndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/modules/jwt/jwt.module.ts b/apps/api/v2/src/modules/jwt/jwt.module.ts new file mode 100644 index 00000000000000..ef12816e2fdf73 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.module.ts @@ -0,0 +1,12 @@ +import { getEnv } from "@/env"; +import { JwtService } from "@/modules/jwt/jwt.service"; +import { Global, Module } from "@nestjs/common"; +import { JwtModule as NestJwtModule } from "@nestjs/jwt"; + +@Global() +@Module({ + imports: [NestJwtModule.register({ secret: getEnv("JWT_SECRET") })], + providers: [JwtService], + exports: [JwtService], +}) +export class JwtModule {} diff --git a/apps/api/v2/src/modules/jwt/jwt.service.ts b/apps/api/v2/src/modules/jwt/jwt.service.ts new file mode 100644 index 00000000000000..c50e53ddb4d9e9 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; +import { JwtService as NestJwtService } from "@nestjs/jwt"; + +@Injectable() +export class JwtService { + constructor(private readonly nestJwtService: NestJwtService) {} + + signAccessToken(payload: Payload) { + const accessToken = this.sign({ type: "access_token", ...payload }); + return accessToken; + } + + signRefreshToken(payload: Payload) { + const refreshToken = this.sign({ type: "refresh_token", ...payload }); + return refreshToken; + } + + sign(payload: Payload) { + const issuedAtTime = this.getIssuedAtTime(); + + const token = this.nestJwtService.sign({ ...payload, iat: issuedAtTime }); + return token; + } + + getIssuedAtTime() { + // divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT speficication + return Math.floor(Date.now() / 1000); + } +} + +type Payload = Record; diff --git a/apps/api/v2/src/modules/memberships/memberships.module.ts b/apps/api/v2/src/modules/memberships/memberships.module.ts new file mode 100644 index 00000000000000..418e1f6d1605d7 --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.module.ts @@ -0,0 +1,10 @@ +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [MembershipsRepository], + exports: [MembershipsRepository], +}) +export class MembershipsModule {} diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts new file mode 100644 index 00000000000000..2a8e7d1a47598e --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class MembershipsRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async findOrgUserMembership(organizationId: number, userId: number) { + const membership = await this.dbRead.prisma.membership.findUniqueOrThrow({ + where: { + userId_teamId: { + userId: userId, + teamId: organizationId, + }, + }, + }); + + return membership; + } + + async isUserOrganizationAdmin(userId: number, organizationId: number) { + const adminMembership = await this.dbRead.prisma.membership.findFirst({ + where: { + userId, + teamId: organizationId, + accepted: true, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + }); + + return !!adminMembership; + } +} 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 new file mode 100644 index 00000000000000..877765a9cf8893 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -0,0 +1,257 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { + CreateUserResponse, + UserReturned, +} from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +const CLIENT_REDIRECT_URI = "http://localhost:4321"; + +describe("OAuth Client Users Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("secret header not set", () => { + it(`/POST`, () => { + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients/100/users") + .send({ email: "bob@gmail.com" }) + .expect(401); + }); + }); + + describe("Bearer access token not set", () => { + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/100/users/200").expect(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + let postResponseData: CreateUserResponse; + + const userEmail = "oauth-client-user@gmail.com"; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + }); + + it(`should fail /POST with incorrect timeZone`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + timeZone: "incorrect-time-zone", + }; + + await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(400); + }); + + it(`/POST`, async () => { + const requestBody: CreateManagedUserInput = { + email: userEmail, + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: ApiSuccessResponse<{ + user: Omit; + accessToken: string; + refreshToken: string; + }> = response.body; + + postResponseData = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email)); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + await userConnectedToOAuth(responseBody.data.user.email); + await userHasDefaultEventTypes(responseBody.data.user.id); + }); + + async function userConnectedToOAuth(userEmail: string) { + const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClient.id); + const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail); + + expect(oAuthUsers?.length).toEqual(1); + expect(newOAuthUser?.email).toEqual(userEmail); + } + + async function userHasDefaultEventTypes(userId: number) { + const defaultEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + + // note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes + expect(defaultEventTypes?.length).toEqual(4); + expect( + defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length) + ).toBeTruthy(); + expect( + defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length) + ).toBeTruthy(); + } + + 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`) + .set("x-cal-secret-key", oAuthClient.secret) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data?.length).toBeGreaterThan(0); + expect(responseBody.data[0].email).toEqual(postResponseData.user.email); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userEmail)); + }); + + it(`/PUT/:id`, async () => { + const userUpdatedEmail = "pineapple-pizza@gmail.com"; + const body: UpdateManagedUserInput = { email: userUpdatedEmail }; + + const response = await request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .send(body) + .expect(200); + + const responseBody: ApiSuccessResponse> = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)); + }); + + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()) + .delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + }); + + function getOAuthUserEmail(oAuthClientId: string, userEmail: string) { + const [username, emailDomain] = userEmail.split("@"); + const email = `${username}+${oAuthClientId}@${emailDomain}`; + + return email; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + try { + await userRepositoryFixture.delete(postResponseData.user.id); + } catch (e) { + // User might have been deleted by the test + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts new file mode 100644 index 00000000000000..9a24f04ffcf713 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -0,0 +1,203 @@ +import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output"; +import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output"; +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.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"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Post, + Logger, + UseGuards, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + BadRequestException, + Delete, + Query, + NotFoundException, +} from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { User } from "@prisma/client"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Pagination } from "@calcom/platform-types"; + +@Controller({ + path: "oauth-clients/:clientId/users", + version: "2", +}) +@UseGuards(OAuthClientCredentialsGuard) +@DocsTags("Managed users") +export class OAuthClientUsersController { + private readonly logger = new Logger("UserController"); + + constructor( + private readonly userRepository: UsersRepository, + private readonly oAuthClientUsersService: OAuthClientUsersService, + private readonly oauthRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository + ) {} + + @Get("/") + async getManagedUsers( + @Param("clientId") oAuthClientId: string, + @Query() queryParams: Pagination + ): Promise { + this.logger.log(`getting managed users with data for OAuth Client with ID ${oAuthClientId}`); + const { offset, limit } = queryParams; + + const existingUsers = await this.userRepository.findManagedUsersByOAuthClientId( + oAuthClientId, + offset ?? 0, + limit ?? 50 + ); + + return { + status: SUCCESS_STATUS, + data: existingUsers.map((user) => this.getResponseUser(user)), + }; + } + + @Post("/") + async createUser( + @Param("clientId") oAuthClientId: string, + @Body() body: CreateManagedUserInput + ): Promise { + this.logger.log( + `Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}` + ); + const existingUser = await this.userRepository.findByEmail(body.email); + + if (existingUser) { + throw new BadRequestException("A user with the provided email already exists."); + } + const client = await this.oauthRepository.getOAuthClient(oAuthClientId); + + const isPlatformManaged = true; + const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser( + oAuthClientId, + body, + isPlatformManaged, + client?.organizationId + ); + + return { + status: SUCCESS_STATUS, + data: { + user: this.getResponseUser(user), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + }; + } + + @Get("/:userId") + @HttpCode(HttpStatus.OK) + async getUserById( + @Param("clientId") clientId: string, + @Param("userId") userId: number + ): Promise { + const user = await this.validateManagedUserOwnership(clientId, userId); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Patch("/:userId") + @HttpCode(HttpStatus.OK) + async updateUser( + @Param("clientId") clientId: string, + @Param("userId") userId: number, + @Body() body: UpdateManagedUserInput + ): Promise { + await this.validateManagedUserOwnership(clientId, userId); + this.logger.log(`Updating user with ID ${userId}: ${JSON.stringify(body, null, 2)}`); + + const user = await this.oAuthClientUsersService.updateOAuthClientUser(clientId, userId, body); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Delete("/:userId") + @HttpCode(HttpStatus.OK) + async deleteUser( + @Param("clientId") clientId: string, + @Param("userId") userId: number + ): Promise { + const user = await this.validateManagedUserOwnership(clientId, userId); + await this.userRepository.delete(userId); + + this.logger.warn(`Deleting user with ID: ${userId}`); + + return { + status: SUCCESS_STATUS, + data: this.getResponseUser(user), + }; + } + + @Post("/:userId/force-refresh") + @HttpCode(HttpStatus.OK) + async forceRefresh( + @Param("userId") userId: number, + @Param("clientId") oAuthClientId: string + ): Promise { + this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`); + + const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId); + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + id, + true + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + refreshToken, + }, + }; + } + + private async validateManagedUserOwnership(clientId: string, userId: number): Promise { + const user = await this.userRepository.findByIdWithinPlatformScope(userId, clientId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} is not part of this OAuth client.`); + } + + return user; + } + + private getResponseUser(user: User): ManagedUserOutput { + return { + id: user.id, + email: user.email, + username: user.username, + timeZone: user.timeZone, + weekStart: user.weekStart, + createdDate: user.createdDate, + timeFormat: user.timeFormat, + defaultScheduleId: user.defaultScheduleId, + }; + } +} + +export type UserReturned = Pick; + +export type CreateUserResponse = { user: UserReturned; accessToken: string; refreshToken: string }; diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts new file mode 100644 index 00000000000000..4528068fc31d7a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output.ts @@ -0,0 +1,34 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class CreateManagedUserData { + @ApiProperty({ + type: ManagedUserOutput, + }) + @ValidateNested() + @Type(() => ManagedUserOutput) + user!: ManagedUserOutput; + + @IsString() + accessToken!: string; + + @IsString() + refreshToken!: string; +} + +export class CreateManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: CreateManagedUserData, + }) + @ValidateNested() + @Type(() => CreateManagedUserData) + data!: CreateManagedUserData; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts new file mode 100644 index 00000000000000..0de2d8e2a7df57 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/delete-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class DeleteManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts new file mode 100644 index 00000000000000..cc341ebc6b0b3d --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts new file mode 100644 index 00000000000000..16b8b41c51da6c --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output.ts @@ -0,0 +1,17 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetManagedUsersOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested({ each: true }) + @Type(() => ManagedUserOutput) + @IsArray() + data!: ManagedUserOutput[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts new file mode 100644 index 00000000000000..3f7ee80738273d --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; + +export class ManagedUserOutput { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" }) + email!: string; + + @ApiProperty({ example: "alice" }) + username!: string | null; + + @ApiProperty({ example: "America/New_York" }) + timeZone!: string; + + @ApiProperty({ example: "Sunday" }) + weekStart!: string; + + @ApiProperty({ example: "2024-04-01T00:00:00.000Z", type: "string" }) + @Transform(({ value }) => value.toISOString()) + createdDate!: Date; + + @ApiProperty({ example: 12, nullable: true }) + timeFormat!: number | null; + + @ApiProperty({ example: null }) + defaultScheduleId!: number | null; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts new file mode 100644 index 00000000000000..2d660a47def0f4 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/update-managed-user.output.ts @@ -0,0 +1,16 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UpdateManagedUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Type(() => ManagedUserOutput) + @ValidateNested() + data!: ManagedUserOutput; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts new file mode 100644 index 00000000000000..c94e0328fba4e1 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts @@ -0,0 +1,364 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { CreateOAuthClientInput } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("OAuth Clients Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`/GET`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients").expect(401); + }); + it(`/GET/:id`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/POST`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth-clients").expect(401); + }); + it(`/PUT/:id`, () => { + return request(appWithoutAuth.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(appWithoutAuth.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("Organization is not platform", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = "oauth-clients-test-e2e@api.com"; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: "apiOrg", + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: false, + }); + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + + afterAll(async () => { + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); + await app.close(); + }); + }); + + describe("User Is Authenticated", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = "test-e2e@api.com"; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: "apiOrg", + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: true, + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("User is not in an organization", () => { + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + }); + + describe("User is part of an organization as Member", () => { + let membership: Membership; + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(200); + }); + it(`/GET/:id - oAuth client does not exist`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(404); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Admin", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = "test-oauth-client-admin"; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = "test-oauth-client-updated"; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Owner", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = "test-oauth-client-owner"; + const oAuthClientPermissions = 32; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "OWNER", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + expect(responseBody.data[0].permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + expect(responseBody.data.permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = "test-oauth-client-updated"; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + afterAll(async () => { + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts new file mode 100644 index 00000000000000..a0dd99e1227f59 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -0,0 +1,123 @@ +import { getEnv } from "@/env"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; +import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto"; +import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; +import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Get, + Post, + Patch, + Delete, + Param, + HttpCode, + HttpStatus, + Logger, + UseGuards, + NotFoundException, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiCreatedResponse as DocsCreatedResponse, +} from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard. +Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`; + +@Controller({ + path: "oauth-clients", + version: "2", +}) +@UseGuards(NextAuthGuard, OrganizationRolesGuard) +@DocsExcludeController(getEnv("NODE_ENV") === "production") +@DocsTags("OAuth - development only") +export class OAuthClientsController { + private readonly logger = new Logger("OAuthClientController"); + + constructor(private readonly oauthClientRepository: OAuthClientRepository) {} + + @Post("/") + @HttpCode(HttpStatus.CREATED) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @DocsCreatedResponse({ + description: "Create an OAuth client", + type: CreateOAuthClientResponseDto, + }) + async createOAuthClient( + @GetUser() user: UserWithProfile, + @Body() body: CreateOAuthClientInput + ): Promise { + const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number; + this.logger.log( + `For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` + ); + const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body); + return { + status: SUCCESS_STATUS, + data: { + clientId: id, + clientSecret: secret, + }, + }; + } + + @Get("/") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async getOAuthClients(@GetUser() user: UserWithProfile): Promise { + const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number; + + const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId); + return { status: SUCCESS_STATUS, data: clients }; + } + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async getOAuthClientById(@Param("clientId") clientId: string): Promise { + const client = await this.oauthClientRepository.getOAuthClient(clientId); + if (!client) { + throw new NotFoundException(`OAuth client with ID ${clientId} not found`); + } + return { status: SUCCESS_STATUS, data: client }; + } + + @Patch("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async updateOAuthClient( + @Param("clientId") clientId: string, + @Body() body: UpdateOAuthClientInput + ): Promise { + this.logger.log(`For client ${clientId} updating OAuth Client with data: ${JSON.stringify(body)}`); + const client = await this.oauthClientRepository.updateOAuthClient(clientId, body); + return { status: SUCCESS_STATUS, data: client }; + } + + @Delete("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async deleteOAuthClient(@Param("clientId") clientId: string): Promise { + this.logger.log(`Deleting OAuth Client with ID: ${clientId}`); + const client = await this.oauthClientRepository.deleteOAuthClient(clientId); + return { status: SUCCESS_STATUS, data: client }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..fcee72c195ceea --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class DataDto { + @ApiProperty({ + example: "clsx38nbl0001vkhlwin9fmt0", + }) + @IsString() + clientId!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }) + @IsString() + clientSecret!: string; +} + +export class CreateOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + example: { + clientId: "clsx38nbl0001vkhlwin9fmt0", + clientSecret: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DataDto) + data!: DataDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..e95fd60155f7a2 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + ValidateNested, + IsEnum, + IsString, + IsNumber, + IsOptional, + IsDate, + IsNotEmptyObject, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class PlatformOAuthClientDto { + @ApiProperty({ example: "clsx38nbl0001vkhlwin9fmt0" }) + @IsString() + id!: string; + + @ApiProperty({ example: "MyClient" }) + @IsString() + name!: string; + + @ApiProperty({ example: "secretValue" }) + @IsString() + secret!: string; + + @ApiProperty({ example: 3 }) + @IsNumber() + permissions!: number; + + @ApiProperty({ example: "https://example.com/logo.png", required: false }) + @IsOptional() + @IsString() + logo!: string | null; + + @ApiProperty({ example: ["https://example.com/callback"] }) + @IsArray() + @IsString({ each: true }) + redirectUris!: string[]; + + @ApiProperty({ example: 1 }) + @IsNumber() + organizationId!: number; + + @ApiProperty({ example: "2024-03-23T08:33:21.851Z", type: Date }) + @IsDate() + createdAt!: Date; +} + +export class GetOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: PlatformOAuthClientDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => PlatformOAuthClientDto) + data!: PlatformOAuthClientDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts new file mode 100644 index 00000000000000..584729b9f06d3b --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts @@ -0,0 +1,21 @@ +import { PlatformOAuthClientDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, ValidateNested, IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetOAuthClientsResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: PlatformOAuthClientDto, + isArray: true, + }) + @ValidateNested({ each: true }) + @Type(() => PlatformOAuthClientDto) + @IsArray() + data!: PlatformOAuthClientDto[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts new file mode 100644 index 00000000000000..2081240bbe0c5e --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -0,0 +1,175 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +describe("OAuthFlow Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`POST /oauth/:clientId/authorize missing Cookie with user`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/authorize").expect(401); + }); + + it(`POST /oauth/:clientId/exchange missing Authorization Bearer token`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/exchange").expect(400); + }); + + it(`POST /oauth/:clientId/refresh missing ${X_CAL_SECRET_KEY} header with secret`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/refresh").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let usersRepositoryFixtures: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; + + let user: User; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + + let authorizationCode: string | null; + let refreshToken: string; + + beforeAll(async () => { + const userEmail = "developer@platform.com"; + + const moduleRef: TestingModule = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); + + user = await usersRepositoryFixtures.create({ + email: userEmail, + }); + organization = await organizationsRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri.com"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oAuthClientsRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("Authorize Endpoint", () => { + it("POST /oauth/:clientId/authorize", async () => { + const body: OAuthAuthorizeInput = { + redirectUri: oAuthClient.redirectUris[0], + }; + + const REDIRECT_STATUS = 302; + + const response = await request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/authorize`) + .send(body) + .expect(REDIRECT_STATUS); + + const baseUrl = "http://www.localhost/"; + const redirectUri = new URL(response.header.location, baseUrl); + authorizationCode = redirectUri.searchParams.get("code"); + + expect(authorizationCode).toBeDefined(); + }); + }); + + describe("Exchange Endpoint", () => { + it("POST /oauth/:clientId/exchange", async () => { + const authorizationToken = `Bearer ${authorizationCode}`; + const body: ExchangeAuthorizationCodeInput = { + clientSecret: oAuthClient.secret, + }; + + const response = await request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/exchange`) + .set("Authorization", authorizationToken) + .send(body) + .expect(200); + + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + + refreshToken = response.body.data.refreshToken; + }); + }); + + describe("Refresh Token Endpoint", () => { + it("POST /oauth/:clientId/refresh", () => { + const secretKey = oAuthClient.secret; + const body = { + refreshToken, + }; + + return request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/refresh`) + .set("x-cal-secret-key", secretKey) + .send(body) + .expect(200) + .then((response) => { + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + }); + }); + }); + + afterAll(async () => { + await oAuthClientsRepositoryFixture.delete(oAuthClient.id); + await organizationsRepositoryFixture.delete(organization.id); + await usersRepositoryFixtures.delete(user.id); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts new file mode 100644 index 00000000000000..c7d82b69be6d61 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -0,0 +1,157 @@ +import { getEnv } from "@/env"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Param, + Post, + Response, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiOkResponse as DocsOkResponse, + ApiBadRequestResponse as DocsBadRequestResponse, +} from "@nestjs/swagger"; +import { Response as ExpressResponse } from "express"; + +import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +@Controller({ + path: "oauth/:clientId", + version: "2", +}) +@DocsExcludeController(getEnv("NODE_ENV") === "production") +@DocsTags("OAuth - development only") +export class OAuthFlowController { + constructor( + private readonly oauthClientRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository, + private readonly oAuthFlowService: OAuthFlowService + ) {} + + @Post("/authorize") + @HttpCode(HttpStatus.OK) + @UseGuards(NextAuthGuard) + @DocsOperation({ + summary: "Authorize an OAuth client", + description: + "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.", + }) + @DocsOkResponse({ + description: + "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`", + }) + @DocsBadRequestResponse({ + description: + "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.", + }) + async authorize( + @Param("clientId") clientId: string, + @Body() body: OAuthAuthorizeInput, + @GetUser("id") userId: number, + @Response() res: ExpressResponse + ): Promise { + const oauthClient = await this.oauthClientRepository.getOAuthClient(clientId); + if (!oauthClient) { + throw new BadRequestException(`OAuth client with ID '${clientId}' not found`); + } + + if (!oauthClient?.redirectUris.includes(body.redirectUri)) { + throw new BadRequestException("Invalid 'redirect_uri' value."); + } + + const alreadyAuthorized = await this.tokensRepository.getAuthorizationTokenByClientUserIds( + clientId, + userId + ); + + if (alreadyAuthorized) { + throw new BadRequestException( + `User with id=${userId} has already authorized client with id=${clientId}.` + ); + } + + const { id } = await this.tokensRepository.createAuthorizationToken(clientId, userId); + + return res.redirect(`${body.redirectUri}?code=${id}`); + } + + @Post("/exchange") + @HttpCode(HttpStatus.OK) + @DocsOperation({ + summary: "Exchange authorization code for access tokens", + description: + "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.", + }) + @DocsOkResponse({ + type: KeysResponseDto, + description: "Successfully exchanged authorization code for access and refresh tokens.", + }) + @DocsBadRequestResponse({ + description: + "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.", + }) + async exchange( + @Headers("Authorization") authorization: string, + @Param("clientId") clientId: string, + @Body() body: ExchangeAuthorizationCodeInput + ): Promise { + const authorizeEndpointCode = authorization.replace("Bearer ", "").trim(); + if (!authorizeEndpointCode) { + throw new BadRequestException("Missing 'Bearer' Authorization header."); + } + + const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken( + authorizeEndpointCode, + clientId, + body.clientSecret + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + refreshToken, + }, + }; + } + + @Post("/refresh") + @HttpCode(HttpStatus.OK) + @UseGuards(OAuthClientCredentialsGuard) + async refreshAccessToken( + @Param("clientId") clientId: string, + @Headers(X_CAL_SECRET_KEY) secretKey: string, + @Body() body: RefreshTokenInput + ): Promise { + const { accessToken, refreshToken } = await this.oAuthFlowService.refreshToken( + clientId, + secretKey, + body.refreshToken + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts new file mode 100644 index 00000000000000..ba5c85b51e5a9b --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ValidateNested, IsEnum, IsString, IsNotEmptyObject } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class KeysDto { + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + accessToken!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + refreshToken!: string; +} + +export class KeysResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: KeysDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => KeysDto) + data!: KeysDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts new file mode 100644 index 00000000000000..285f8661258050 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts @@ -0,0 +1,94 @@ +import { AppModule } from "@/app.module"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team } from "@prisma/client"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard"; + +describe("OAuthClientCredentialsGuard", () => { + let guard: OAuthClientCredentialsGuard; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let oauthClient: PlatformOAuthClient; + let organization: Team; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule, OAuthClientModule], + }).compile(); + + guard = module.get(OAuthClientCredentialsGuard); + teamRepositoryFixture = new TeamRepositoryFixture(module); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); + + organization = await teamRepositoryFixture.create({ name: "organization" }); + + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + expect(oauthClient).toBeDefined(); + }); + + it("should return true if client ID and secret are valid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: oauthClient.secret }, + { clientId: oauthClient.id } + ); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if client ID is invalid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: oauthClient.secret }, + { clientId: "invalid id" } + ); + + await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); + }); + + it("should return false if secret key is invalid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: "invalid secret" }, + { clientId: oauthClient.id } + ); + + await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oauthClient.id); + await teamRepositoryFixture.delete(organization.id); + }); + + function createMockExecutionContext( + headers: Record, + params: Record + ): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + params, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts new file mode 100644 index 00000000000000..e58cdcba5c7fb5 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts @@ -0,0 +1,33 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import { Request } from "express"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +@Injectable() +export class OAuthClientCredentialsGuard implements CanActivate { + constructor(private readonly oauthRepository: OAuthClientRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const { params } = request; + + const oauthClientId = params.clientId; + const oauthClientSecret = request.get(X_CAL_SECRET_KEY); + + if (!oauthClientId) { + throw new UnauthorizedException("Missing client ID"); + } + if (!oauthClientSecret) { + throw new UnauthorizedException("Missing client secret"); + } + + const client = await this.oauthRepository.getOAuthClient(oauthClientId); + + if (!client || client.secret !== oauthClientSecret) { + throw new UnauthorizedException("Invalid client credentials"); + } + + return true; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts new file mode 100644 index 00000000000000..62b7987310320a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class OAuthAuthorizeInput { + @IsString() + redirectUri!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts new file mode 100644 index 00000000000000..938f8db08fe382 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class ExchangeAuthorizationCodeInput { + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts new file mode 100644 index 00000000000000..1eb25d3950a25c --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class RefreshTokenInput { + @IsString() + refreshToken!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts new file mode 100644 index 00000000000000..1b2a12957602fe --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts @@ -0,0 +1,32 @@ +import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator"; + +export class UpdateOAuthClientInput { + @IsOptional() + @IsString() + logo?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + redirectUris?: string[] = []; + + @IsOptional() + @IsString() + bookingRedirectUri?: string; + + @IsOptional() + @IsString() + bookingCancelRedirectUri?: string; + + @IsOptional() + @IsString() + bookingRescheduleRedirectUri?: string; + + @IsOptional() + @IsBoolean() + areEmailsEnabled?: boolean; +} 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 new file mode 100644 index 00000000000000..74a1b554ae48ce --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -0,0 +1,39 @@ +import { EventTypesModule } from "@/ee/event-types/event-types.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"; +import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; +import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Global, Module } from "@nestjs/common"; + +@Global() +@Module({ + imports: [ + PrismaModule, + AuthModule, + UsersModule, + TokensModule, + MembershipsModule, + EventTypesModule, + OrganizationsModule, + ], + providers: [ + OAuthClientRepository, + OAuthClientCredentialsGuard, + TokensRepository, + OAuthFlowService, + OAuthClientUsersService, + ], + controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], + exports: [OAuthClientRepository, OAuthClientCredentialsGuard], +}) +export class OAuthClientModule {} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts new file mode 100644 index 00000000000000..6dc399e2e4aead --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts @@ -0,0 +1,102 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import type { PlatformOAuthClient } from "@prisma/client"; + +import type { CreateOAuthClientInput } from "@calcom/platform-types"; + +@Injectable() +export class OAuthClientRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private jwtService: JwtService + ) {} + + async createOAuthClient(organizationId: number, data: CreateOAuthClientInput) { + return this.dbWrite.prisma.platformOAuthClient.create({ + data: { + ...data, + secret: this.jwtService.sign(data), + organizationId, + }, + }); + } + + async getOAuthClient(clientId: string): Promise { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { id: clientId }, + }); + } + + async getOAuthClientWithAuthTokens(tokenId: string, clientId: string, clientSecret: string) { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { + id: clientId, + secret: clientSecret, + authorizationTokens: { + some: { + id: tokenId, + }, + }, + }, + include: { + authorizationTokens: { + where: { + id: tokenId, + }, + include: { + owner: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + } + + async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) { + return await this.dbRead.prisma.platformOAuthClient.findFirst({ + where: { + id: clientId, + secret: clientSecret, + }, + include: { + refreshToken: { + where: { + secret: refreshToken, + }, + }, + }, + }); + } + + async getOrganizationOAuthClients(organizationId: number): Promise { + return this.dbRead.prisma.platformOAuthClient.findMany({ + where: { + organization: { + id: organizationId, + }, + }, + }); + } + + async updateOAuthClient( + clientId: string, + updateData: Partial + ): Promise { + return this.dbWrite.prisma.platformOAuthClient.update({ + where: { id: clientId }, + data: updateData, + }); + } + + async deleteOAuthClient(clientId: string): Promise { + return this.dbWrite.prisma.platformOAuthClient.delete({ + where: { id: clientId }, + }); + } +} 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 new file mode 100644 index 00000000000000..ec2753a95d8679 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -0,0 +1,99 @@ +import { EventTypesService } from "@/ee/event-types/services/event-types.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"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; + +import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries"; +import { slugify } from "@calcom/platform-libraries"; + +@Injectable() +export class OAuthClientUsersService { + constructor( + private readonly userRepository: UsersRepository, + private readonly tokensRepository: TokensRepository, + private readonly eventTypesService: EventTypesService + ) {} + + async createOauthClientUser( + oAuthClientId: string, + body: CreateManagedUserInput, + isPlatformManaged: boolean, + organizationId?: number + ) { + const existsWithEmail = await this.userExistsWithEmail(oAuthClientId, body.email); + if (existsWithEmail) { + throw new BadRequestException("User with the provided e-mail already exists."); + } + + let user: User; + if (!organizationId) { + throw new BadRequestException("You cannot create a managed user outside of an organization"); + } else { + const email = this.getOAuthUserEmail(oAuthClientId, body.email); + user = ( + await createNewUsersConnectToOrgIfExists({ + usernamesOrEmails: [email], + input: { + teamId: organizationId, + role: "MEMBER", + usernameOrEmail: [email], + isOrg: true, + language: "en", + }, + parentId: null, + autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users", + connectionInfoMap: { + [email]: { + orgId: organizationId, + autoAccept: true, + }, + }, + isPlatformManaged, + }) + )[0]; + await this.userRepository.addToOAuthClient(user.id, oAuthClientId); + await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined }); + } + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + user.id + ); + await this.eventTypesService.createUserDefaultEventTypes(user.id); + + return { + user, + tokens: { + accessToken, + refreshToken, + }, + }; + } + + async userExistsWithEmail(oAuthClientId: string, email: string) { + const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email); + const user = await this.userRepository.findByEmail(oAuthEmail); + return !!user; + } + + async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) { + if (body.email) { + const emailWithOAuthId = this.getOAuthUserEmail(oAuthClientId, body.email); + body.email = emailWithOAuthId; + const newUsername = slugify(emailWithOAuthId); + await this.userRepository.updateUsername(userId, newUsername); + } + + return this.userRepository.update(userId, body); + } + + getOAuthUserEmail(oAuthClientId: string, userEmail: string) { + const [username, emailDomain] = userEmail.split("@"); + const email = `${username}+${oAuthClientId}@${emailDomain}`; + + return email; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts new file mode 100644 index 00000000000000..24490dbfb7b207 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -0,0 +1,116 @@ +import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class OAuthFlowService { + constructor( + private readonly tokensRepository: TokensRepository, + private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService + ) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async propagateAccessToken(accessToken: string) { + // this.logger.log("Propagating access token to redis", accessToken); + // TODO propagate + //this.redisService.redis.hset("access_tokens", accessToken,) + return void 0; + } + + async getOwnerId(accessToken: string) { + return this.tokensRepository.getAccessTokenOwnerId(accessToken); + } + + async validateAccessToken(secret: string) { + // status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired + // but we need to check the SQL db for it anyways. + const { status } = await this.readFromCache(secret); + + if (status === "CACHE_HIT") { + return true; + } + + const tokenExpiresAt = await this.tokensRepository.getAccessTokenExpiryDate(secret); + + if (!tokenExpiresAt) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + if (new Date() > tokenExpiresAt) { + throw new TokenExpiredException(); + } + + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private async readFromCache(secret: string) { + return { status: "CACHE_MISS" }; + } + + async exchangeAuthorizationToken( + tokenId: string, + clientId: string, + clientSecret: string + ): Promise<{ accessToken: string; refreshToken: string }> { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens( + tokenId, + clientId, + clientSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuth Client."); + } + + const authorizationToken = oauthClient.authorizationTokens[0]; + + if (!authorizationToken || !authorizationToken.owner.id) { + throw new BadRequestException("Invalid Authorization Token."); + } + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + clientId, + authorizationToken.owner.id + ); + await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id); + void this.propagateAccessToken(accessToken); // voided as we don't need to await + + return { + accessToken, + refreshToken, + }; + } + + async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret( + clientId, + clientSecret, + tokenSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuthClient credentials."); + } + + const currentRefreshToken = oauthClient.refreshToken[0]; + + if (!currentRefreshToken) { + throw new BadRequestException("Invalid refresh token"); + } + + const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens( + clientId, + currentRefreshToken.secret, + currentRefreshToken.userId + ); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts new file mode 100644 index 00000000000000..0ec5c5d73fc36c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -0,0 +1,11 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [OrganizationsRepository, OrganizationsService], + exports: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts new file mode 100644 index 00000000000000..98dfa20159a708 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -0,0 +1,15 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async findById(organizationId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: organizationId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations.service.ts b/apps/api/v2/src/modules/organizations/services/organizations.service.ts new file mode 100644 index 00000000000000..a32ed9e38b503a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations.service.ts @@ -0,0 +1,12 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsService { + constructor(private readonly organizationsRepository: OrganizationsRepository) {} + + async isPlatform(organizationId: number) { + const organization = await this.organizationsRepository.findById(organizationId); + return organization?.isPlatform; + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-read.service.ts b/apps/api/v2/src/modules/prisma/prisma-read.service.ts new file mode 100644 index 00000000000000..7913e805388ba7 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-read.service.ts @@ -0,0 +1,25 @@ +import type { OnModuleInit } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaReadService implements OnModuleInit { + public prisma: PrismaClient; + + constructor(readonly configService: ConfigService) { + const dbUrl = configService.get("db.readUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-write.service.ts b/apps/api/v2/src/modules/prisma/prisma-write.service.ts new file mode 100644 index 00000000000000..0b8fc2198c6d5d --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-write.service.ts @@ -0,0 +1,25 @@ +import { OnModuleInit } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaWriteService implements OnModuleInit { + public prisma: PrismaClient; + + constructor(readonly configService: ConfigService) { + const dbUrl = configService.get("db.writeUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma.module.ts b/apps/api/v2/src/modules/prisma/prisma.module.ts new file mode 100644 index 00000000000000..e9a66a5fc41da9 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [PrismaReadService, PrismaWriteService], + exports: [PrismaReadService, PrismaWriteService], +}) +export class PrismaModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts new file mode 100644 index 00000000000000..8153f2ac4f1df4 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [SelectedCalendarsRepository], + exports: [SelectedCalendarsRepository], +}) +export class SelectedCalendarsModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts new file mode 100644 index 00000000000000..b56ae971c33e79 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -0,0 +1,40 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class SelectedCalendarsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) { + return this.dbWrite.prisma.selectedCalendar.upsert({ + create: { + userId, + externalId, + credentialId, + integration, + }, + update: { + userId, + externalId, + credentialId, + integration, + }, + where: { + userId_integration_externalId: { + userId, + integration, + externalId, + }, + }, + }); + } + + getUserSelectedCalendars(userId: number) { + return this.dbRead.prisma.selectedCalendar.findMany({ + where: { + userId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts new file mode 100644 index 00000000000000..b8fcf941cdafba --- /dev/null +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -0,0 +1,71 @@ +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { Response as ExpressResponse, Request as ExpressRequest } from "express"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getAvailableSlots } from "@calcom/platform-libraries"; +import type { AvailableSlotsType } from "@calcom/platform-libraries"; +import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types"; +import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types"; + +@Controller({ + path: "slots", + version: "2", +}) +@DocsTags("Slots") +export class SlotsController { + constructor(private readonly slotsService: SlotsService) {} + + @Post("/reserve") + async reserveSlot( + @Body() body: ReserveSlotInput, + @Res({ passthrough: true }) res: ExpressResponse, + @Req() req: ExpressRequest + ): Promise> { + const uid = await this.slotsService.reserveSlot(body, req.cookies?.uid); + + res.cookie("uid", uid); + return { + status: SUCCESS_STATUS, + data: uid, + }; + } + + @Delete("/selected-slot") + async deleteSelectedSlot( + @Query() params: RemoveSelectedSlotInput, + @Req() req: ExpressRequest + ): Promise { + const uid = req.cookies?.uid || params.uid; + + await this.slotsService.deleteSelectedslot(uid); + + return { + status: SUCCESS_STATUS, + }; + } + + @Get("/available") + async getAvailableSlots( + @Query() query: GetAvailableSlotsInput, + @Req() req: ExpressRequest + ): Promise> { + const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId); + const availableSlots = await getAvailableSlots({ + input: { + ...query, + isTeamEvent, + }, + ctx: { + req, + }, + }); + + return { + data: availableSlots, + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/modules/slots/services/slots.service.ts b/apps/api/v2/src/modules/slots/services/slots.service.ts new file mode 100644 index 00000000000000..1d17340ec8df52 --- /dev/null +++ b/apps/api/v2/src/modules/slots/services/slots.service.ts @@ -0,0 +1,57 @@ +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { v4 as uuid } from "uuid"; + +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsService { + constructor( + private readonly eventTypeRepo: EventTypesRepository, + private readonly slotsRepo: SlotsRepository + ) {} + + async reserveSlot(input: ReserveSlotInput, headerUid?: string) { + const uid = headerUid || uuid(); + const eventType = await this.eventTypeRepo.getEventTypeWithSeats(input.eventTypeId); + if (!eventType) { + throw new NotFoundException("Event Type not found"); + } + + let shouldReserveSlot = true; + if (eventType.seatsPerTimeSlot) { + const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid); + const bookingAttendeesLength = bookingWithAttendees?.attendees?.length; + if (bookingAttendeesLength) { + const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength; + if (seatsLeft < 1) shouldReserveSlot = false; + } else { + shouldReserveSlot = false; + } + } + + if (eventType && shouldReserveSlot) { + await Promise.all( + eventType.users.map((user) => + this.slotsRepo.upsertSelectedSlot(user.id, input, uid, eventType.seatsPerTimeSlot !== null) + ) + ); + } + + return uid; + } + + async deleteSelectedslot(uid?: string) { + if (!uid) return; + + return this.slotsRepo.deleteSelectedSlots(uid); + } + + async checkIfIsTeamEvent(eventTypeId?: number) { + if (!eventTypeId) return false; + + const event = await this.eventTypeRepo.getEventTypeById(eventTypeId); + return !!event?.teamId; + } +} diff --git a/apps/api/v2/src/modules/slots/slots.module.ts b/apps/api/v2/src/modules/slots/slots.module.ts new file mode 100644 index 00000000000000..94e7c0d27fe2a9 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.module.ts @@ -0,0 +1,14 @@ +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SlotsController } from "@/modules/slots/controllers/slots.controller"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, EventTypesModule], + providers: [SlotsRepository, SlotsService], + controllers: [SlotsController], + exports: [SlotsService], +}) +export class SlotsModule {} diff --git a/apps/api/v2/src/modules/slots/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts new file mode 100644 index 00000000000000..8ef589f9f87515 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.repository.ts @@ -0,0 +1,53 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { MINUTES_TO_BOOK } from "@calcom/platform-libraries"; +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getBookingWithAttendees(bookingUid?: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { attendees: true }, + }); + } + + async upsertSelectedSlot(userId: number, input: ReserveSlotInput, uid: string, isSeat: boolean) { + const { slotUtcEndDate, slotUtcStartDate, eventTypeId } = input; + + const releaseAt = DateTime.utc() + .plus({ minutes: parseInt(MINUTES_TO_BOOK) }) + .toISO(); + return this.dbWrite.prisma.selectedSlots.upsert({ + where: { + selectedSlotUnique: { userId, slotUtcStartDate, slotUtcEndDate, uid }, + }, + update: { + slotUtcEndDate, + slotUtcStartDate, + releaseAt, + eventTypeId, + }, + create: { + userId, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat, + }, + }); + } + + async deleteSelectedSlots(uid: string) { + return this.dbWrite.prisma.selectedSlots.deleteMany({ + where: { uid: { equals: uid } }, + }); + } +} diff --git a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts new file mode 100644 index 00000000000000..4882e154f390e3 --- /dev/null +++ b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts @@ -0,0 +1,26 @@ +import { TimezonesService } from "@/modules/timezones/services/timezones.service"; +import { Controller, Get } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { CityTimezones } from "@calcom/platform-libraries"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "timezones", + version: "2", +}) +@DocsTags("Timezones") +export class TimezonesController { + constructor(private readonly timezonesService: TimezonesService) {} + + @Get("/") + async getTimeZones(): Promise> { + const timeZones = await this.timezonesService.getCityTimeZones(); + + return { + status: SUCCESS_STATUS, + data: timeZones, + }; + } +} diff --git a/apps/api/v2/src/modules/timezones/services/timezones.service.ts b/apps/api/v2/src/modules/timezones/services/timezones.service.ts new file mode 100644 index 00000000000000..b13c00f6bf4c5a --- /dev/null +++ b/apps/api/v2/src/modules/timezones/services/timezones.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@nestjs/common"; + +import { cityTimezonesHandler } from "@calcom/platform-libraries"; + +@Injectable() +export class TimezonesService { + async getCityTimeZones() { + return cityTimezonesHandler(); + } +} diff --git a/apps/api/v2/src/modules/timezones/timezones.module.ts b/apps/api/v2/src/modules/timezones/timezones.module.ts new file mode 100644 index 00000000000000..4447da80c1481f --- /dev/null +++ b/apps/api/v2/src/modules/timezones/timezones.module.ts @@ -0,0 +1,11 @@ +import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller"; +import { TimezonesService } from "@/modules/timezones/services/timezones.service"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [], + providers: [TimezonesService], + controllers: [TimezonesController], + exports: [TimezonesService], +}) +export class TimezoneModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.module.ts b/apps/api/v2/src/modules/tokens/tokens.module.ts new file mode 100644 index 00000000000000..d700cc77541949 --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [TokensRepository], + exports: [TokensRepository], +}) +export class TokensModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts new file mode 100644 index 00000000000000..46b90a2ddc6370 --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -0,0 +1,162 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { PlatformAuthorizationToken } from "@prisma/client"; +import { DateTime } from "luxon"; + +@Injectable() +export class TokensRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly jwtService: JwtService + ) {} + + async createAuthorizationToken(clientId: string, userId: number): Promise { + return this.dbWrite.prisma.platformAuthorizationToken.create({ + data: { + client: { + connect: { + id: clientId, + }, + }, + owner: { + connect: { + id: userId, + }, + }, + }, + }); + } + + async invalidateAuthorizationToken(tokenId: string) { + return this.dbWrite.prisma.platformAuthorizationToken.delete({ + where: { + id: tokenId, + }, + }); + } + + async getAuthorizationTokenByClientUserIds(clientId: string, userId: number) { + return this.dbRead.prisma.platformAuthorizationToken.findFirst({ + where: { + platformOAuthClientId: clientId, + userId: userId, + }, + }); + } + + async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) { + if (deleteOld) { + try { + await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, userId: ownerId, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.deleteMany({ + where: { + client: { id: clientId }, + userId: ownerId, + }, + }), + ]); + } catch (err) { + // discard. + } + } + const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, ownerId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, ownerId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } + + async getAccessTokenExpiryDate(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + expiresAt: true, + }, + }); + return accessToken?.expiresAt; + } + + async getAccessTokenOwnerId(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + userId: true, + }, + }); + + return accessToken?.userId; + } + + async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) { + const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }), + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, userId: tokenUserId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, userId: tokenUserId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + ]); + return { accessToken, refreshToken }; + } + + async getAccessTokenClient(accessToken: string) { + const token = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessToken, + }, + select: { + client: true, + }, + }); + + return token?.client; + } +} diff --git a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts new file mode 100644 index 00000000000000..7e66b9a749d46b --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts @@ -0,0 +1,31 @@ +import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format"; +import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator"; + +export class CreateManagedUserInput { + @IsString() + @ApiProperty({ example: "alice@example.com" }) + email!: string; + + @IsString() + @IsOptional() + name?: string; + + @IsNumber() + @IsOptional() + @Validate(IsTimeFormat) + @ApiProperty({ example: 12 }) + timeFormat?: number; + + @IsString() + @IsOptional() + @Validate(IsWeekStart) + @ApiProperty({ example: "Sunday" }) + weekStart?: string; + + @IsTimeZone() + @IsOptional() + @ApiProperty({ example: "America/New_York" }) + timeZone?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts new file mode 100644 index 00000000000000..4f3df48960a731 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts @@ -0,0 +1,31 @@ +import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format"; +import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start"; +import { IsNumber, IsOptional, IsString, IsTimeZone, Validate } from "class-validator"; + +export class UpdateManagedUserInput { + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsNumber() + @IsOptional() + @Validate(IsTimeFormat) + timeFormat?: number; + + @IsNumber() + @IsOptional() + defaultScheduleId?: number; + + @IsString() + @IsOptional() + @Validate(IsWeekStart) + weekStart?: string; + + @IsTimeZone() + @IsOptional() + timeZone?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts b/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts new file mode 100644 index 00000000000000..2271a7e4f74420 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts @@ -0,0 +1,12 @@ +import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "isTimeFormat", async: false }) +export class IsTimeFormat implements ValidatorConstraintInterface { + validate(timeFormat: number) { + return timeFormat === 12 || timeFormat === 24; + } + + defaultMessage() { + return "timeFormat must be a number either 12 or 24"; + } +} diff --git a/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts b/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts new file mode 100644 index 00000000000000..d21cf1271910ec --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts @@ -0,0 +1,23 @@ +import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "isWeekStart", async: false }) +export class IsWeekStart implements ValidatorConstraintInterface { + validate(weekStart: string) { + if (!weekStart) return false; + + const lowerCaseWeekStart = weekStart.toLowerCase(); + return ( + lowerCaseWeekStart === "monday" || + lowerCaseWeekStart === "tuesday" || + lowerCaseWeekStart === "wednesday" || + lowerCaseWeekStart === "thursday" || + lowerCaseWeekStart === "friday" || + lowerCaseWeekStart === "saturday" || + lowerCaseWeekStart === "sunday" + ); + } + + defaultMessage() { + return "weekStart must be a string either Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, or Sunday"; + } +} diff --git a/apps/api/v2/src/modules/users/users.module.ts b/apps/api/v2/src/modules/users/users.module.ts new file mode 100644 index 00000000000000..932f4e0ea9c606 --- /dev/null +++ b/apps/api/v2/src/modules/users/users.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [UsersRepository], + exports: [UsersRepository], +}) +export class UsersModule {} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts new file mode 100644 index 00000000000000..e0239ed07b9689 --- /dev/null +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -0,0 +1,190 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { Injectable } from "@nestjs/common"; +import type { Profile, User } from "@prisma/client"; + +export type UserWithProfile = User & { + movedToProfile?: Profile | null; +}; + +@Injectable() +export class UsersRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async create( + user: CreateManagedUserInput, + username: string, + oAuthClientId: string, + isPlatformManaged: boolean + ) { + this.formatInput(user); + + return this.dbWrite.prisma.user.create({ + data: { + ...user, + username, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + isPlatformManaged, + }, + }); + } + + async addToOAuthClient(userId: number, oAuthClientId: string) { + return this.dbWrite.prisma.user.update({ + data: { + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + where: { id: userId }, + }); + } + + async findById(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + }); + } + + async findByIdWithinPlatformScope(userId: number, clientId: string) { + return this.dbRead.prisma.user.findFirst({ + where: { + id: userId, + isPlatformManaged: true, + platformOAuthClients: { + some: { + id: clientId, + }, + }, + }, + }); + } + + async findByIdWithProfile(userId: number): Promise { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + movedToProfile: true, + }, + }); + } + + async findByIdWithCalendars(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + selectedCalendars: true, + destinationCalendar: true, + }, + }); + } + + async findByEmail(email: string) { + return this.dbRead.prisma.user.findUnique({ + where: { + email, + }, + }); + } + + async findByEmailWithProfile(email: string) { + return this.dbRead.prisma.user.findUnique({ + where: { + email, + }, + include: { + movedToProfile: true, + }, + }); + } + + async findByUsername(username: string) { + return this.dbRead.prisma.user.findFirst({ + where: { + username, + }, + }); + } + + async findManagedUsersByOAuthClientId(oauthClientId: string, cursor: number, limit: number) { + return this.dbRead.prisma.user.findMany({ + where: { + platformOAuthClients: { + some: { + id: oauthClientId, + }, + }, + isPlatformManaged: true, + }, + take: limit, + skip: cursor, + }); + } + + async update(userId: number, updateData: UpdateManagedUserInput) { + this.formatInput(updateData); + + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + } + + async updateUsername(userId: number, newUsername: string) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + username: newUsername, + }, + }); + } + + async delete(userId: number): Promise { + return this.dbWrite.prisma.user.delete({ + where: { id: userId }, + }); + } + + formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) { + if (userInput.weekStart) { + userInput.weekStart = capitalize(userInput.weekStart); + } + + if (userInput.timeZone) { + userInput.timeZone = capitalizeTimezone(userInput.timeZone); + } + } + + setDefaultSchedule(userId: number, scheduleId: number) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + defaultScheduleId: scheduleId, + }, + }); + } +} + +function capitalizeTimezone(timezone: string) { + const segments = timezone.split("/"); + + const capitalizedSegments = segments.map((segment) => { + return capitalize(segment); + }); + + return capitalizedSegments.join("/"); +} + +function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} diff --git a/apps/api/v2/swagger/copy-swagger-module.ts b/apps/api/v2/swagger/copy-swagger-module.ts new file mode 100644 index 00000000000000..8f6f23d3f382ff --- /dev/null +++ b/apps/api/v2/swagger/copy-swagger-module.ts @@ -0,0 +1,28 @@ +import * as fs from "fs-extra"; +import * as path from "path"; + +// First, copyNestSwagger is required to enable "@nestjs/swagger" in the "nest-cli.json", +// because nest-cli.json is resolving "@nestjs/swagger" plugin from +// project's node_modules, but due to dependency hoisting, the "@nestjs/swagger" is located in the root node_modules. +// Second, we need to run this before starting the application using "nest start", because "nest start" is ran by +// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger" +// should reside in the project's node_modules already before the "nest start" command is executed. +async function copyNestSwagger() { + const monorepoRoot = path.resolve(__dirname, "../../../../"); + const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs"); + const swaggerModulePath = "@nestjs/swagger"; + + const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath); + const targetDir = path.join(nodeModulesNestjs, "swagger"); + + if (!(await fs.pathExists(targetDir))) { + try { + await fs.ensureDir(nodeModulesNestjs); + await fs.copy(sourceDir, targetDir); + } catch (error) { + console.error("Failed to copy @nestjs/swagger:", error); + } + } +} + +copyNestSwagger(); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json new file mode 100644 index 00000000000000..62e5924af74e34 --- /dev/null +++ b/apps/api/v2/swagger/documentation.json @@ -0,0 +1,4238 @@ +{ + "openapi": "3.0.0", + "paths": { + "/health": { + "get": { + "operationId": "AppController_getHealth", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + }, + "tags": [ + "Health - development only" + ] + } + }, + "/api/v2/oauth-clients/{clientId}/users": { + "get": { + "operationId": "OAuthClientUsersController_getManagedUsers", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUsersOutput" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + }, + "post": { + "operationId": "OAuthClientUsersController_createUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + } + }, + "/api/v2/oauth-clients/{clientId}/users/{userId}": { + "get": { + "operationId": "OAuthClientUsersController_getUserById", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + }, + "patch": { + "operationId": "OAuthClientUsersController_updateUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateManagedUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + }, + "delete": { + "operationId": "OAuthClientUsersController_deleteUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetManagedUserOutput" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + } + }, + "/api/v2/oauth-clients/{clientId}/users/{userId}/force-refresh": { + "post": { + "operationId": "OAuthClientUsersController_forceRefresh", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + } + }, + "tags": [ + "Managed users" + ] + } + }, + "/api/v2/oauth-clients": { + "post": { + "operationId": "OAuthClientsController_createOAuthClient", + "summary": "", + "description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthClientInput" + } + } + } + }, + "responses": { + "201": { + "description": "Create an OAuth client", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthClientResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + }, + "get": { + "operationId": "OAuthClientsController_getOAuthClients", + "summary": "", + "description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientsResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + } + }, + "/api/v2/oauth-clients/{clientId}": { + "get": { + "operationId": "OAuthClientsController_getOAuthClientById", + "summary": "", + "description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + }, + "patch": { + "operationId": "OAuthClientsController_updateOAuthClient", + "summary": "", + "description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOAuthClientInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + }, + "delete": { + "operationId": "OAuthClientsController_deleteOAuthClient", + "summary": "", + "description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + } + }, + "/api/v2/oauth/{clientId}/authorize": { + "post": { + "operationId": "OAuthFlowController_authorize", + "summary": "Authorize an OAuth client", + "description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthAuthorizeInput" + } + } + } + }, + "responses": { + "200": { + "description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`" + }, + "400": { + "description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client." + } + }, + "tags": [ + "OAuth - development only" + ] + } + }, + "/api/v2/oauth/{clientId}/exchange": { + "post": { + "operationId": "OAuthFlowController_exchange", + "summary": "Exchange authorization code for access tokens", + "description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExchangeAuthorizationCodeInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully exchanged authorization code for access and refresh tokens.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + }, + "400": { + "description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match." + } + }, + "tags": [ + "OAuth - development only" + ] + } + }, + "/api/v2/oauth/{clientId}/refresh": { + "post": { + "operationId": "OAuthFlowController_refreshAccessToken", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + } + }, + "tags": [ + "OAuth - development only" + ] + } + }, + "/api/v2/event-types": { + "post": { + "operationId": "EventTypesController_createEventType", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + }, + "get": { + "operationId": "EventTypesController_getEventTypes", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypesOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + } + }, + "/api/v2/event-types/{eventTypeId}": { + "get": { + "operationId": "EventTypesController_getEventType", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + }, + "patch": { + "operationId": "EventTypesController_updateEventType", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + }, + "delete": { + "operationId": "EventTypesController_deleteEventType", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEventTypeOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + } + }, + "/api/v2/event-types/{username}/{eventSlug}/public": { + "get": { + "operationId": "EventTypesController_getPublicEventType", + "parameters": [ + { + "name": "username", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "eventSlug", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "isTeamEvent", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "org", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypePublicOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + } + }, + "/api/v2/event-types/{username}/public": { + "get": { + "operationId": "EventTypesController_getPublicEventTypes", + "parameters": [ + { + "name": "username", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEventTypesPublicOutput" + } + } + } + } + }, + "tags": [ + "Event types" + ] + } + }, + "/api/v2/ee/gcal/oauth/auth-url": { + "get": { + "operationId": "GcalController_redirect", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalAuthUrlOutput" + } + } + } + } + }, + "tags": [ + "Google Calendar" + ] + } + }, + "/api/v2/ee/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" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalSaveRedirectOutput" + } + } + } + } + }, + "tags": [ + "Google Calendar" + ] + } + }, + "/api/v2/ee/gcal/check": { + "get": { + "operationId": "GcalController_check", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GcalCheckOutput" + } + } + } + } + }, + "tags": [ + "Google Calendar" + ] + } + }, + "/api/v2/ee/provider/{clientId}": { + "get": { + "operationId": "CalProviderController_verifyClientId", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderVerifyClientOutput" + } + } + } + } + }, + "tags": [ + "Cal provider" + ] + } + }, + "/api/v2/ee/provider/{clientId}/access-token": { + "get": { + "operationId": "CalProviderController_verifyAccessToken", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderVerifyAccessTokenOutput" + } + } + } + } + }, + "tags": [ + "Cal provider" + ] + } + }, + "/api/v2/schedules": { + "post": { + "operationId": "SchedulesController_createSchedule", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "get": { + "operationId": "SchedulesController_getSchedules", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/api/v2/schedules/default": { + "get": { + "operationId": "SchedulesController_getDefaultSchedule", + "parameters": [], + "responses": { + "200": { + "description": "Returns the default schedule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDefaultScheduleOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/api/v2/schedules/{scheduleId}": { + "get": { + "operationId": "SchedulesController_getSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetScheduleOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "patch": { + "operationId": "SchedulesController_updateSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + }, + "delete": { + "operationId": "SchedulesController_deleteSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteScheduleOutput" + } + } + } + } + }, + "tags": [ + "Schedules" + ] + } + }, + "/api/v2/ee/me": { + "get": { + "operationId": "MeController_getMe", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMeOutput" + } + } + } + } + }, + "tags": [ + "Me" + ] + }, + "patch": { + "operationId": "MeController_updateMe", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateManagedUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMeOutput" + } + } + } + } + }, + "tags": [ + "Me" + ] + } + }, + "/api/v2/ee/calendars/busy-times": { + "get": { + "operationId": "CalendarsController_getBusyTimes", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBusyTimesOutput" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/api/v2/ee/calendars": { + "get": { + "operationId": "CalendarsController_getCalendars", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectedCalendarsOutput" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/api/v2/ee/bookings": { + "get": { + "operationId": "BookingsController_getBookings", + "parameters": [ + { + "name": "cursor", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "filters[status]", + "required": true, + "in": "query", + "schema": { + "enum": [ + "upcoming", + "recurring", + "past", + "cancelled", + "unconfirmed" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + }, + "post": { + "operationId": "BookingsController_createBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/ee/bookings/{bookingUid}": { + "get": { + "operationId": "BookingsController_getBooking", + "parameters": [ + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingOutput" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/ee/bookings/{bookingUid}/reschedule": { + "get": { + "operationId": "BookingsController_getBookingForReschedule", + "parameters": [ + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/ee/bookings/{bookingId}/cancel": { + "post": { + "operationId": "BookingsController_cancelBooking", + "parameters": [ + { + "name": "bookingId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelBookingInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/ee/bookings/reccuring": { + "post": { + "operationId": "BookingsController_createReccuringBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/ee/bookings/instant": { + "post": { + "operationId": "BookingsController_createInstantBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Bookings" + ] + } + }, + "/api/v2/slots/reserve": { + "post": { + "operationId": "SlotsController_reserveSlot", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReserveSlotInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/api/v2/slots/selected-slot": { + "delete": { + "operationId": "SlotsController_deleteSelectedSlot", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/api/v2/slots/available": { + "get": { + "operationId": "SlotsController_getAvailableSlots", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Slots" + ] + } + }, + "/api/v2/timezones": { + "get": { + "operationId": "TimezonesController_getTimeZones", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Timezones" + ] + } + } + }, + "info": { + "title": "Cal.com v2 API", + "description": "", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "ManagedUserOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "email": { + "type": "string", + "example": "alice+cluo37fwd0001khkzqqynkpj3@example.com" + }, + "username": { + "type": "string", + "nullable": true, + "example": "alice" + }, + "timeZone": { + "type": "string", + "example": "America/New_York" + }, + "weekStart": { + "type": "string", + "example": "Sunday" + }, + "createdDate": { + "type": "string", + "example": "2024-04-01T00:00:00.000Z" + }, + "timeFormat": { + "type": "number", + "nullable": true, + "example": 12 + }, + "defaultScheduleId": { + "type": "number", + "nullable": true, + "example": null + } + }, + "required": [ + "id", + "email", + "username", + "timeZone", + "weekStart", + "createdDate", + "timeFormat", + "defaultScheduleId" + ] + }, + "GetManagedUsersOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagedUserOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateManagedUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "alice@example.com" + }, + "timeFormat": { + "type": "number", + "example": 12 + }, + "weekStart": { + "type": "string", + "example": "Sunday" + }, + "timeZone": { + "type": "string", + "example": "America/New_York" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email" + ] + }, + "CreateManagedUserData": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/ManagedUserOutput" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + } + }, + "required": [ + "user", + "accessToken", + "refreshToken" + ] + }, + "CreateManagedUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/CreateManagedUserData" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetManagedUserOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ManagedUserOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateManagedUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number" + }, + "weekStart": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + } + }, + "KeysDto": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "refreshToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + } + }, + "required": [ + "accessToken", + "refreshToken" + ] + }, + "KeysResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/KeysDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "CreateOAuthClientInput": { + "type": "object", + "properties": {} + }, + "DataDto": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "clientSecret": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + } + }, + "required": [ + "clientId", + "clientSecret" + ] + }, + "CreateOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "example": "success" + }, + "data": { + "example": { + "clientId": "clsx38nbl0001vkhlwin9fmt0", + "clientSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataDto" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "PlatformOAuthClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "name": { + "type": "string", + "example": "MyClient" + }, + "secret": { + "type": "string", + "example": "secretValue" + }, + "permissions": { + "type": "number", + "example": 3 + }, + "logo": { + "type": "string", + "nullable": true, + "example": "https://example.com/logo.png" + }, + "redirectUris": { + "example": [ + "https://example.com/callback" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "organizationId": { + "type": "number", + "example": 1 + }, + "createdAt": { + "format": "date-time", + "type": "string", + "example": "2024-03-23T08:33:21.851Z" + } + }, + "required": [ + "id", + "name", + "secret", + "permissions", + "redirectUris", + "organizationId", + "createdAt" + ] + }, + "GetOAuthClientsResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "GetOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateOAuthClientInput": { + "type": "object", + "properties": { + "logo": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirectUris": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "bookingRedirectUri": { + "type": "string" + }, + "bookingCancelRedirectUri": { + "type": "string" + }, + "bookingRescheduleRedirectUri": { + "type": "string" + }, + "areEmailsEnabled": { + "type": "boolean" + } + } + }, + "OAuthAuthorizeInput": { + "type": "object", + "properties": { + "redirectUri": { + "type": "string" + } + }, + "required": [ + "redirectUri" + ] + }, + "ExchangeAuthorizationCodeInput": { + "type": "object", + "properties": { + "clientSecret": { + "type": "string" + } + }, + "required": [ + "clientSecret" + ] + }, + "RefreshTokenInput": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + }, + "required": [ + "refreshToken" + ] + }, + "EventTypeLocation": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "link" + }, + "link": { + "type": "string", + "example": "https://masterchief.com/argentina/flan/video/9129412" + } + }, + "required": [ + "type" + ] + }, + "CreateEventTypeInput": { + "type": "object", + "properties": { + "length": { + "type": "number", + "minimum": 1, + "example": 60 + }, + "slug": { + "type": "string", + "example": "cooking-class" + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "description": { + "type": "string", + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeLocation" + } + } + }, + "required": [ + "length", + "slug", + "title" + ] + }, + "EventTypeOutput": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "length": { + "type": "number", + "example": 60 + }, + "slug": { + "type": "string", + "example": "cooking-class" + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "description": { + "type": "string", + "nullable": true, + "example": "Discover the culinary wonders of the Argentina by making the best flan ever!" + }, + "locations": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeLocation" + } + } + }, + "required": [ + "id", + "length", + "slug", + "title", + "description", + "locations" + ] + }, + "CreateEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/EventTypeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "Data": { + "type": "object", + "properties": { + "eventType": { + "$ref": "#/components/schemas/EventTypeOutput" + } + }, + "required": [ + "eventType" + ] + }, + "GetEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/Data" + } + }, + "required": [ + "status", + "data" + ] + }, + "EventTypeGroup": { + "type": "object", + "properties": { + "eventTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeOutput" + } + } + }, + "required": [ + "eventTypes" + ] + }, + "GetEventTypesData": { + "type": "object", + "properties": { + "eventTypeGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeGroup" + } + } + }, + "required": [ + "eventTypeGroups" + ] + }, + "GetEventTypesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/GetEventTypesData" + } + }, + "required": [ + "status", + "data" + ] + }, + "Location": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + }, + "Source": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "label" + ] + }, + "BookingField": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "defaultLabel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "getOptionsAt": { + "type": "string" + }, + "hideWhenJustOneOption": { + "type": "boolean" + }, + "editable": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Source" + } + } + }, + "required": [ + "name", + "type" + ] + }, + "Organization": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "slug": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "metadata": { + "type": "object" + } + }, + "required": [ + "id", + "name", + "metadata" + ] + }, + "Profile": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + }, + "id": { + "type": "number", + "nullable": true + }, + "userId": { + "type": "number" + }, + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organizationId": { + "type": "number", + "nullable": true + }, + "organization": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Organization" + } + ] + }, + "upId": { + "type": "string" + }, + "image": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "bookerLayouts": { + "type": "object" + } + }, + "required": [ + "username", + "id", + "organizationId", + "upId" + ] + }, + "Owner": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "username": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "weekStart": { + "type": "string" + }, + "brandColor": { + "type": "string", + "nullable": true + }, + "darkBrandColor": { + "type": "string", + "nullable": true + }, + "theme": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "object" + }, + "defaultScheduleId": { + "type": "number", + "nullable": true + }, + "nonProfileUsername": { + "type": "string", + "nullable": true + }, + "profile": { + "$ref": "#/components/schemas/Profile" + } + }, + "required": [ + "id", + "username", + "name", + "weekStart", + "metadata", + "nonProfileUsername", + "profile" + ] + }, + "Schedule": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "timeZone": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "timeZone" + ] + }, + "User": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "weekStart": { + "type": "string" + }, + "organizationId": { + "type": "number" + }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "profile": { + "$ref": "#/components/schemas/Profile" + }, + "bookerUrl": { + "type": "string" + } + }, + "required": [ + "username", + "name", + "weekStart", + "profile", + "bookerUrl" + ] + }, + "PublicEventTypeOutput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "eventName": { + "type": "string", + "nullable": true + }, + "slug": { + "type": "string" + }, + "isInstantEvent": { + "type": "boolean" + }, + "aiPhoneCallConfig": { + "type": "object" + }, + "schedulingType": { + "type": "object" + }, + "length": { + "type": "number" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location" + } + }, + "customInputs": { + "type": "array", + "items": { + "type": "object" + } + }, + "disableGuests": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "nullable": true + }, + "lockTimeZoneToggleOnBookingPage": { + "type": "boolean" + }, + "requiresConfirmation": { + "type": "boolean" + }, + "requiresBookerEmailVerification": { + "type": "boolean" + }, + "recurringEvent": { + "type": "object" + }, + "price": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "seatsPerTimeSlot": { + "type": "number", + "nullable": true + }, + "seatsShowAvailabilityCount": { + "type": "boolean", + "nullable": true + }, + "bookingFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingField" + } + }, + "team": { + "type": "object" + }, + "successRedirectUrl": { + "type": "string", + "nullable": true + }, + "workflows": { + "type": "array", + "items": { + "type": "object" + } + }, + "hosts": { + "type": "array", + "items": { + "type": "object" + } + }, + "owner": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Owner" + } + ] + }, + "schedule": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Schedule" + } + ] + }, + "hidden": { + "type": "boolean" + }, + "assignAllTeamMembers": { + "type": "boolean" + }, + "bookerLayouts": { + "type": "object" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "entity": { + "type": "object" + }, + "isDynamic": { + "type": "boolean" + } + }, + "required": [ + "id", + "title", + "description", + "slug", + "isInstantEvent", + "length", + "locations", + "customInputs", + "disableGuests", + "metadata", + "lockTimeZoneToggleOnBookingPage", + "requiresConfirmation", + "requiresBookerEmailVerification", + "price", + "currency", + "seatsShowAvailabilityCount", + "bookingFields", + "workflows", + "hosts", + "owner", + "schedule", + "hidden", + "assignAllTeamMembers", + "users", + "entity", + "isDynamic" + ] + }, + "GetEventTypePublicOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/PublicEventTypeOutput" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "PublicEventType": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "length": { + "type": "number", + "example": 60 + }, + "slug": { + "type": "string", + "example": "cooking-class" + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "length", + "slug", + "title" + ] + }, + "GetEventTypesPublicOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicEventType" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateEventTypeInput": { + "type": "object", + "properties": { + "length": { + "type": "number", + "minimum": 1 + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeLocation" + } + } + } + }, + "UpdateEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/EventTypeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteData": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "length": { + "type": "number", + "example": 60 + }, + "slug": { + "type": "string", + "example": "cooking-class" + }, + "title": { + "type": "string", + "example": "Learn the secrets of masterchief!" + } + }, + "required": [ + "id", + "length", + "slug", + "title" + ] + }, + "DeleteEventTypeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/DeleteData" + } + }, + "required": [ + "status", + "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": { + "days": { + "example": [ + 1, + 2 + ], + "type": "array", + "items": { + "type": "number" + } + }, + "startTime": { + "format": "date-time", + "type": "string" + }, + "endTime": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "days", + "startTime", + "endTime" + ] + }, + "CreateScheduleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "availabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateAvailabilityInput" + } + }, + "isDefault": { + "type": "boolean" + } + }, + "required": [ + "name", + "timeZone", + "isDefault" + ] + }, + "WorkingHours": { + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "number" + } + }, + "startTime": { + "type": "number" + }, + "endTime": { + "type": "number" + }, + "userId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "days", + "startTime", + "endTime" + ] + }, + "AvailabilityModel": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number", + "nullable": true + }, + "eventTypeId": { + "type": "number", + "nullable": true + }, + "days": { + "type": "array", + "items": { + "type": "number" + } + }, + "startTime": { + "format": "date-time", + "type": "string" + }, + "endTime": { + "format": "date-time", + "type": "string" + }, + "date": { + "format": "date-time", + "type": "string", + "nullable": true + }, + "scheduleId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "id", + "days", + "startTime", + "endTime" + ] + }, + "TimeRange": { + "type": "object", + "properties": { + "userId": { + "type": "number", + "nullable": true + }, + "start": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "start", + "end" + ] + }, + "ScheduleOutput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "isManaged": { + "type": "boolean" + }, + "workingHours": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkingHours" + } + }, + "schedule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AvailabilityModel" + } + }, + "availability": { + "type": "array", + "items": { + "required": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeRange" + } + } + }, + "timeZone": { + "type": "string" + }, + "dateOverrides": { + "type": "array", + "items": { + "type": "object" + } + }, + "isDefault": { + "type": "boolean" + }, + "isLastSchedule": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "isManaged", + "workingHours", + "schedule", + "availability", + "timeZone", + "dateOverrides", + "isDefault", + "isLastSchedule", + "readOnly" + ] + }, + "CreateScheduleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetDefaultScheduleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ScheduleOutput" + } + ] + } + }, + "required": [ + "status", + "data" + ] + }, + "GetScheduleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetSchedulesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateScheduleInput": { + "type": "object", + "properties": { + "timeZone": { + "type": "string" + }, + "name": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "schedule": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + }, + "dateOverrides": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + } + }, + "required": [ + "timeZone", + "name", + "isDefault", + "schedule" + ] + }, + "EventTypeModel": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "eventName": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id" + ] + }, + "ScheduleModel": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "name": { + "type": "string" + }, + "timeZone": { + "type": "string", + "nullable": true + }, + "eventType": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeModel" + } + }, + "availability": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AvailabilityModel" + } + } + }, + "required": [ + "id", + "userId", + "name" + ] + }, + "UpdatedScheduleOutput": { + "type": "object", + "properties": { + "schedule": { + "$ref": "#/components/schemas/ScheduleModel" + }, + "isDefault": { + "type": "boolean" + }, + "timeZone": { + "type": "string" + }, + "prevDefaultId": { + "type": "number", + "nullable": true + }, + "currentDefaultId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "schedule", + "isDefault" + ] + }, + "UpdateScheduleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/UpdatedScheduleOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteScheduleOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, + "MeOutput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number", + "nullable": true + }, + "weekStart": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "email", + "timeFormat", + "defaultScheduleId", + "weekStart", + "timeZone" + ] + }, + "GetMeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/MeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateMeOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/MeOutput" + } + }, + "required": [ + "status", + "data" + ] + }, + "BusyTimesOutput": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + }, + "source": { + "type": "string", + "nullable": true + } + }, + "required": [ + "start", + "end" + ] + }, + "GetBusyTimesOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BusyTimesOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "Integration": { + "type": "object", + "properties": { + "appData": { + "type": "object", + "nullable": true + }, + "dirName": { + "type": "string" + }, + "__template": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "logo": { + "type": "string" + }, + "publisher": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "email": { + "type": "string" + }, + "locationOption": { + "type": "object", + "nullable": true + } + }, + "required": [ + "name", + "description", + "type", + "variant", + "categories", + "logo", + "publisher", + "slug", + "url", + "email", + "locationOption" + ] + }, + "Primary": { + "type": "object", + "properties": { + "externalId": { + "type": "string" + }, + "integration": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primary": { + "type": "boolean", + "nullable": true + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "isSelected": { + "type": "boolean" + }, + "credentialId": { + "type": "number" + } + }, + "required": [ + "externalId", + "primary", + "readOnly", + "isSelected", + "credentialId" + ] + }, + "Calendar": { + "type": "object", + "properties": { + "externalId": { + "type": "string" + }, + "integration": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primary": { + "type": "boolean", + "nullable": true + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "isSelected": { + "type": "boolean" + }, + "credentialId": { + "type": "number" + } + }, + "required": [ + "externalId", + "readOnly", + "isSelected", + "credentialId" + ] + }, + "ConnectedCalendar": { + "type": "object", + "properties": { + "integration": { + "$ref": "#/components/schemas/Integration" + }, + "credentialId": { + "type": "number" + }, + "primary": { + "$ref": "#/components/schemas/Primary" + }, + "calendars": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Calendar" + } + } + }, + "required": [ + "integration", + "credentialId" + ] + }, + "DestinationCalendar": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "integration": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "primaryEmail": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "number", + "nullable": true + }, + "eventTypeId": { + "type": "number", + "nullable": true + }, + "credentialId": { + "type": "number", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "primary": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "integrationTitle": { + "type": "string" + } + }, + "required": [ + "id", + "integration", + "externalId", + "primaryEmail", + "userId", + "eventTypeId", + "credentialId" + ] + }, + "ConnectedCalendarsData": { + "type": "object", + "properties": { + "connectedCalendars": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectedCalendar" + } + }, + "destinationCalendar": { + "$ref": "#/components/schemas/DestinationCalendar" + } + }, + "required": [ + "connectedCalendars", + "destinationCalendar" + ] + }, + "ConnectedCalendarsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ConnectedCalendarsData" + } + }, + "required": [ + "status", + "data" + ] + }, + "Attendee": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "locale": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "id", + "email", + "name", + "timeZone", + "locale", + "bookingId" + ] + }, + "EventType": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "id": { + "type": "number" + }, + "eventName": { + "type": "string", + "nullable": true + }, + "price": { + "type": "number" + }, + "recurringEvent": { + "type": "object" + }, + "currency": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "seatsShowAttendees": { + "type": "object" + }, + "seatsShowAvailabilityCount": { + "type": "object" + }, + "team": { + "type": "object", + "nullable": true + } + }, + "required": [ + "price", + "currency", + "metadata" + ] + }, + "Reference": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "type": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "meetingId": { + "type": "string", + "nullable": true + }, + "thirdPartyRecurringEventId": { + "type": "string", + "nullable": true + }, + "meetingPassword": { + "type": "string", + "nullable": true + }, + "meetingUrl": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "number", + "nullable": true + }, + "externalCalendarId": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "object" + }, + "credentialId": { + "type": "number", + "nullable": true + } + }, + "required": [ + "id", + "type", + "uid", + "meetingPassword", + "bookingId", + "externalCalendarId", + "credentialId" + ] + }, + "GetBookingsDataEntry": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "userPrimaryEmail": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "customInputs": { + "type": "object" + }, + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attendee" + } + }, + "metadata": { + "type": "object" + }, + "uid": { + "type": "string" + }, + "recurringEventId": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "eventType": { + "$ref": "#/components/schemas/EventType" + }, + "status": { + "type": "object" + }, + "paid": { + "type": "boolean" + }, + "payment": { + "type": "array", + "items": { + "type": "object" + } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Reference" + } + }, + "isRecorded": { + "type": "boolean" + }, + "seatsReferences": { + "type": "array", + "items": { + "type": "object" + } + }, + "user": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/User" + } + ] + }, + "rescheduled": { + "type": "object" + } + }, + "required": [ + "id", + "title", + "description", + "customInputs", + "startTime", + "endTime", + "attendees", + "metadata", + "uid", + "recurringEventId", + "location", + "eventType", + "status", + "paid", + "payment", + "references", + "isRecorded", + "seatsReferences", + "user" + ] + }, + "GetBookingsData": { + "type": "object", + "properties": { + "bookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetBookingsDataEntry" + } + }, + "recurringInfo": { + "type": "array", + "items": { + "type": "object" + } + }, + "nextCursor": { + "type": "number", + "nullable": true + } + }, + "required": [ + "bookings", + "recurringInfo", + "nextCursor" + ] + }, + "GetBookingsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/GetBookingsData" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetBookingData": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "number" + }, + "uid": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "customInputs": { + "type": "object" + }, + "smsReminderNumber": { + "type": "string", + "nullable": true + }, + "recurringEventId": { + "type": "string", + "nullable": true + }, + "startTime": { + "format": "date-time", + "type": "string" + }, + "endTime": { + "format": "date-time", + "type": "string" + }, + "location": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "cancellationReason": { + "type": "string", + "nullable": true + }, + "responses": { + "type": "object" + }, + "rejectionReason": { + "type": "string", + "nullable": true + }, + "userPrimaryEmail": { + "type": "string", + "nullable": true + }, + "user": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/User" + } + ] + }, + "attendees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attendee" + } + }, + "eventTypeId": { + "type": "number", + "nullable": true + }, + "eventType": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/EventType" + } + ] + } + }, + "required": [ + "title", + "id", + "uid", + "description", + "customInputs", + "smsReminderNumber", + "recurringEventId", + "startTime", + "endTime", + "location", + "status", + "metadata", + "cancellationReason", + "responses", + "rejectionReason", + "userPrimaryEmail", + "user", + "attendees", + "eventTypeId", + "eventType" + ] + }, + "GetBookingOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/GetBookingData" + } + }, + "required": [ + "status", + "data" + ] + }, + "Response": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "guests": { + "type": "array", + "items": { + "type": "string" + } + }, + "location": { + "$ref": "#/components/schemas/Location" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "name", + "email", + "guests" + ] + }, + "CreateBookingInput": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + }, + "eventTypeId": { + "type": "number" + }, + "eventTypeSlug": { + "type": "string" + }, + "rescheduleUid": { + "type": "string" + }, + "recurringEventId": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "user": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "string" + }, + "bookingUid": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "hasHashedBookingLink": { + "type": "boolean" + }, + "hashedLink": { + "type": "string", + "nullable": true + }, + "seatReferenceUid": { + "type": "string" + }, + "responses": { + "$ref": "#/components/schemas/Response" + } + }, + "required": [ + "start", + "eventTypeId", + "timeZone", + "language", + "metadata", + "hashedLink", + "responses" + ] + }, + "CancelBookingInput": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "uid": { + "type": "string" + }, + "allRemainingBookings": { + "type": "boolean" + }, + "cancellationReason": { + "type": "string" + }, + "seatReferenceUid": { + "type": "string" + } + }, + "required": [ + "id", + "uid", + "allRemainingBookings", + "cancellationReason", + "seatReferenceUid" + ] + }, + "ReserveSlotInput": { + "type": "object", + "properties": {} + } + } + } +} \ No newline at end of file diff --git a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts new file mode 100644 index 00000000000000..fc27078bf26041 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Booking, User } from "@prisma/client"; + +export class BookingsRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(bookingId: Booking["id"]) { + return this.primaReadClient.booking.findFirst({ where: { id: bookingId } }); + } + + async deleteById(bookingId: Booking["id"]) { + return this.prismaWriteClient.booking.delete({ where: { id: bookingId } }); + } + + async deleteAllBookings(userId: User["id"], userEmail: User["email"]) { + return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts new file mode 100644 index 00000000000000..449d1124ec2bd6 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class CredentialsRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + create(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) { + return this.prismaWriteClient.credential.create({ + data: { + type, + key, + userId, + appId, + }, + }); + } + + delete(id: number) { + return this.prismaWriteClient.credential.delete({ + where: { + id, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts new file mode 100644 index 00000000000000..46e3cc0c032970 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -0,0 +1,36 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { EventType } from "@prisma/client"; + +export class EventTypesRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getAllUserEventTypes(userId: number) { + return this.prismaWriteClient.eventType.findMany({ + where: { + userId, + }, + }); + } + + async create(data: Pick, userId: number) { + return this.prismaWriteClient.eventType.create({ + data: { + ...data, + userId, + }, + }); + } + + async delete(eventTypeId: EventType["id"]) { + return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts new file mode 100644 index 00000000000000..21ee461df0f66e --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts @@ -0,0 +1,34 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Membership, MembershipRole, Prisma, Team, User } from "@prisma/client"; + +export class MembershipRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.MembershipCreateInput) { + return this.prismaWriteClient.membership.create({ data }); + } + + async delete(membershipId: Membership["id"]) { + return this.prismaWriteClient.membership.delete({ where: { id: membershipId } }); + } + + async get(membershipId: Membership["id"]) { + return this.primaReadClient.membership.findFirst({ where: { id: membershipId } }); + } + + async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) { + const membership = await this.prismaWriteClient.membership.create({ + data: { teamId: org.id, userId: user.id, role, accepted }, + }); + await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } }); + return membership; + } +} diff --git a/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts new file mode 100644 index 00000000000000..4aeb2868b041dc --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts @@ -0,0 +1,49 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient } from "@prisma/client"; + +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +export class OAuthClientRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(clientId: PlatformOAuthClient["id"]) { + return this.prismaReadClient.platformOAuthClient.findFirst({ where: { id: clientId } }); + } + + async getUsers(clientId: PlatformOAuthClient["id"]) { + const response = await this.prismaReadClient.platformOAuthClient.findFirst({ + where: { id: clientId }, + include: { + users: true, + }, + }); + + return response?.users; + } + + async create(organizationId: number, data: CreateOAuthClientInput, secret: string) { + return this.prismaWriteClient.platformOAuthClient.create({ + data: { + ...data, + secret, + organizationId, + }, + }); + } + + async delete(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } + + async deleteByClientId(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts new file mode 100644 index 00000000000000..f34be13c9f236b --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Schedule } from "@prisma/client"; + +export class SchedulesRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(scheduleId: Schedule["id"]) { + return this.primaReadClient.schedule.findFirst({ where: { id: scheduleId } }); + } + + async deleteById(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.schedule.delete({ where: { id: scheduleId } }); + } + + async deleteAvailabilities(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.availability.deleteMany({ where: { scheduleId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts new file mode 100644 index 00000000000000..1d059087318298 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, Team } from "@prisma/client"; + +export class TeamRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(teamId: Team["id"]) { + return this.primaReadClient.team.findFirst({ where: { id: teamId } }); + } + + async create(data: Prisma.TeamCreateInput) { + return this.prismaWriteClient.team.create({ data }); + } + + async delete(teamId: Team["id"]) { + return this.prismaWriteClient.team.delete({ where: { id: teamId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts new file mode 100644 index 00000000000000..6716f17948190a --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts @@ -0,0 +1,47 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import * as crypto from "crypto"; +import { DateTime } from "luxon"; + +export class TokensRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async createTokens(userId: number, clientId: string) { + const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const accessTokenBuffer = crypto.randomBytes(48); + const accessTokenSecret = accessTokenBuffer.toString("hex"); + const refreshTokenBuffer = crypto.randomBytes(48); + const refreshTokenSecret = refreshTokenBuffer.toString("hex"); + const [accessToken, refreshToken] = await this.prismaWriteClient.$transaction([ + this.prismaWriteClient.accessToken.create({ + data: { + secret: accessTokenSecret, + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + this.prismaWriteClient.refreshToken.create({ + data: { + secret: refreshTokenSecret, + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } +} diff --git a/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts new file mode 100644 index 00000000000000..ce0035ab4cc30f --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts @@ -0,0 +1,51 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, User } from "@prisma/client"; + +export class UserRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(userId: User["id"]) { + return this.primaReadClient.user.findFirst({ where: { id: userId } }); + } + + async create(data: Prisma.UserCreateInput) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(data.email); + } catch {} + + return this.prismaWriteClient.user.create({ data }); + } + + async createOAuthManagedUser(email: Prisma.UserCreateInput["email"], oAuthClientId: string) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(email); + } catch {} + + return this.prismaWriteClient.user.create({ + data: { + email, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + }); + } + + async delete(userId: User["id"]) { + return this.prismaWriteClient.user.delete({ where: { id: userId } }); + } + + async deleteByEmail(email: User["email"]) { + return this.prismaWriteClient.user.delete({ where: { email } }); + } +} diff --git a/apps/api/v2/test/mocks/access-token-mock.strategy.ts b/apps/api/v2/test/mocks/access-token-mock.strategy.ts new file mode 100644 index 00000000000000..f7c89a2beb8ee0 --- /dev/null +++ b/apps/api/v2/test/mocks/access-token-mock.strategy.ts @@ -0,0 +1,25 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class AccessTokenMockStrategy extends PassportStrategy(BaseStrategy, "access-token") { + constructor(private readonly email: string, private readonly usersRepository: UsersRepository) { + super(); + } + + async authenticate() { + try { + const user = await this.usersRepository.findByEmail(this.email); + if (!user) { + throw new Error("User with the provided ID not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/mocks/next-auth-mock.strategy.ts b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts new file mode 100644 index 00000000000000..236748220f0e24 --- /dev/null +++ b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts @@ -0,0 +1,24 @@ +import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class NextAuthMockStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") { + constructor(private readonly email: string, private readonly userRepository: UsersRepository) { + super(); + } + async authenticate() { + try { + const user = await this.userRepository.findByEmailWithProfile(this.email); + if (!user) { + throw new Error("User with the provided email not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts new file mode 100644 index 00000000000000..a0e9bce9055a47 --- /dev/null +++ b/apps/api/v2/test/setEnvVars.ts @@ -0,0 +1,17 @@ +import type { Environment } from "@/env"; + +const env: Partial> = { + API_PORT: "5555", + DATABASE_READ_URL: "postgresql://postgres:@localhost:5450/calendso", + DATABASE_WRITE_URL: "postgresql://postgres:@localhost:5450/calendso", + NEXTAUTH_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + LOG_LEVEL: "trace", + REDIS_URL: "redis://localhost:9199", +}; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +process.env = { + ...env, + ...process.env, +}; diff --git a/apps/api/v2/test/utils/withAccessTokenAuth.ts b/apps/api/v2/test/utils/withAccessTokenAuth.ts new file mode 100644 index 00000000000000..809de3c683b873 --- /dev/null +++ b/apps/api/v2/test/utils/withAccessTokenAuth.ts @@ -0,0 +1,10 @@ +import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { AccessTokenMockStrategy } from "test/mocks/access-token-mock.strategy"; + +export const withAccessTokenAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(AccessTokenStrategy).useFactory({ + factory: (usersRepository: UsersRepository) => new AccessTokenMockStrategy(email, usersRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/test/utils/withNextAuth.ts b/apps/api/v2/test/utils/withNextAuth.ts new file mode 100644 index 00000000000000..4a96bf7edfd93a --- /dev/null +++ b/apps/api/v2/test/utils/withNextAuth.ts @@ -0,0 +1,10 @@ +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; + +export const withNextAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(NextAuthStrategy).useFactory({ + factory: (userRepository: UsersRepository) => new NextAuthMockStrategy(email, userRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/tsconfig.build.json b/apps/api/v2/tsconfig.build.json new file mode 100644 index 00000000000000..64f86c6bd2bb30 --- /dev/null +++ b/apps/api/v2/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json new file mode 100644 index 00000000000000..3e738f626136f8 --- /dev/null +++ b/apps/api/v2/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "resolveJsonModule": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": ".", + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + "@prisma/client/*": ["@calcom/prisma/client/*"] + }, + "incremental": true, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + }, + "exclude": ["./dist", "next-i18next.config.js"], + "include": ["./**/*.ts", "../../../packages/types/*.d.ts"] +} diff --git a/apps/platform/example/README.md b/apps/platform/example/README.md new file mode 100644 index 00000000000000..d1a0c4955837be --- /dev/null +++ b/apps/platform/example/README.md @@ -0,0 +1 @@ +## Cal.com Platform Example (https://platform.cal.com) diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index ef0070cf2098fe..4fc3c8a4f6c571 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -1,5 +1,14 @@ # @calcom/web +## 3.9.9 + +### Patch Changes + +- Updated dependencies + - @calcom/embed-core@1.4.0 + - @calcom/embed-react@1.4.0 + - @calcom/embed-snippet@1.2.0 + ## 3.1.3 ### Patch Changes diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 00000000000000..0760f8a241f733 Binary files /dev/null and b/apps/web/app/favicon.ico differ diff --git a/apps/web/app/future/[user]/[type]/page.tsx b/apps/web/app/future/[user]/[type]/page.tsx index 1ac9364f4f1115..2c5ebe455904d1 100644 --- a/apps/web/app/future/[user]/[type]/page.tsx +++ b/apps/web/app/future/[user]/[type]/page.tsx @@ -24,7 +24,13 @@ export const generateMetadata = async ({ const rescheduleUid = booking?.uid; const { trpc } = await import("@calcom/trpc"); const { data: event } = trpc.viewer.public.event.useQuery( - { username: user, eventSlug: slug, isTeamEvent: false, org: eventData.entity.orgSlug ?? null }, + { + username: user, + eventSlug: slug, + isTeamEvent: false, + org: eventData.entity.orgSlug ?? null, + fromRedirectOfNonOrgLink: eventData.entity.fromRedirectOfNonOrgLink, + }, { refetchOnWindowFocus: false } ); diff --git a/apps/web/app/future/apps/categories/[category]/page.tsx b/apps/web/app/future/apps/categories/[category]/page.tsx index 9105acc89be6da..cae4ba87a7d2a9 100644 --- a/apps/web/app/future/apps/categories/[category]/page.tsx +++ b/apps/web/app/future/apps/categories/[category]/page.tsx @@ -1,12 +1,11 @@ import CategoryPage, { type PageProps } from "@pages/apps/categories/[category]"; -import { Prisma } from "@prisma/client"; import { withAppDirSsg } from "app/WithAppDirSsg"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; import { APP_NAME } from "@calcom/lib/constants"; -import prisma from "@calcom/prisma"; import { AppCategories } from "@calcom/prisma/enums"; +import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check"; import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps"; @@ -19,16 +18,11 @@ export const generateMetadata = async () => { export const generateStaticParams = async () => { const paths = Object.keys(AppCategories); + const isPrismaAvailable = await isPrismaAvailableCheck(); - try { - await prisma.$queryRaw`SELECT 1`; - } catch (e: unknown) { - if (e instanceof Prisma.PrismaClientInitializationError) { - // Database is not available at build time. Make sure we fall back to building these pages on demand - return []; - } else { - throw e; - } + if (!isPrismaAvailable) { + // Database is not available at build time. Make sure we fall back to building these pages on demand + return []; } return paths.map((category) => ({ category })); diff --git a/apps/web/app/future/d/[link]/[slug]/page.tsx b/apps/web/app/future/d/[link]/[slug]/page.tsx index 4e380ce756cea1..5ac2b4886e39df 100644 --- a/apps/web/app/future/d/[link]/[slug]/page.tsx +++ b/apps/web/app/future/d/[link]/[slug]/page.tsx @@ -21,7 +21,13 @@ export const generateMetadata = async ({ const rescheduleUid = booking?.uid; const { trpc } = await import("@calcom/trpc"); const { data: event } = trpc.viewer.public.event.useQuery( - { username: user ?? "", eventSlug: slug ?? "", isTeamEvent, org: entity.orgSlug ?? null }, + { + username: user ?? "", + eventSlug: slug ?? "", + isTeamEvent, + org: entity.orgSlug ?? null, + fromRedirectOfNonOrgLink: entity.fromRedirectOfNonOrgLink, + }, { refetchOnWindowFocus: false } ); const profileName = event?.profile?.name ?? ""; diff --git a/apps/web/app/future/org/[orgSlug]/team/[slug]/[type]/page.tsx b/apps/web/app/future/org/[orgSlug]/team/[slug]/[type]/page.tsx index 72713f9d310ab5..af76fb00167f11 100644 --- a/apps/web/app/future/org/[orgSlug]/team/[slug]/[type]/page.tsx +++ b/apps/web/app/future/org/[orgSlug]/team/[slug]/[type]/page.tsx @@ -22,7 +22,13 @@ export const generateMetadata = async ({ const entity = eventData.entity; const { trpc } = await import("@calcom/trpc"); const { data: event } = trpc.viewer.public.event.useQuery( - { username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null }, + { + username: user, + eventSlug: slug, + isTeamEvent: false, + org: entity.orgSlug ?? null, + fromRedirectOfNonOrgLink: entity.fromRedirectOfNonOrgLink, + }, { refetchOnWindowFocus: false } ); diff --git a/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx b/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx deleted file mode 100644 index 3ffb2a3a5023a5..00000000000000 --- a/apps/web/app/future/settings/organizations/[id]/set-password/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import LegacyPage, { WrappedSetPasswordPage } from "@pages/settings/organizations/[id]/set-password"; -import { _generateMetadata } from "app/_utils"; -import { WithLayout } from "app/layoutHOC"; - -export const generateMetadata = async () => - await _generateMetadata( - (t) => t("set_a_password"), - (t) => t("set_a_password_description") - ); - -export default WithLayout({ Page: LegacyPage, getLayout: WrappedSetPasswordPage }); diff --git a/apps/web/app/future/team/[slug]/[type]/page.tsx b/apps/web/app/future/team/[slug]/[type]/page.tsx index e46cc9f14a8861..043af611b56d8f 100644 --- a/apps/web/app/future/team/[slug]/[type]/page.tsx +++ b/apps/web/app/future/team/[slug]/[type]/page.tsx @@ -20,7 +20,13 @@ export const generateMetadata = async ({ const entity = eventData.entity; const { trpc } = await import("@calcom/trpc"); const { data: event } = trpc.viewer.public.event.useQuery( - { username: user, eventSlug: slug, isTeamEvent: false, org: entity.orgSlug ?? null }, + { + username: user, + eventSlug: slug, + isTeamEvent: false, + org: entity.orgSlug ?? null, + fromRedirectOfNonOrgLink: entity.fromRedirectOfNonOrgLink, + }, { refetchOnWindowFocus: false } ); diff --git a/apps/web/components/AddToHomescreen.tsx b/apps/web/components/AddToHomescreen.tsx index dca5e67a0336fb..7fad3766776c4a 100644 --- a/apps/web/components/AddToHomescreen.tsx +++ b/apps/web/components/AddToHomescreen.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { X } from "@calcom/ui/components/icon"; +import { Icon } from "@calcom/ui"; export default function AddToHomescreen() { const { t } = useLocale(); @@ -40,7 +40,7 @@ export default function AddToHomescreen() { type="button" className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white"> {t("dismiss")} -