diff --git a/.env.example b/.env.example
index 644c8b24621635..08c44c5c3b46b1 100644
--- a/.env.example
+++ b/.env.example
@@ -180,6 +180,7 @@ API_KEY_PREFIX=cal_
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
# Configures the global From: header whilst sending emails.
EMAIL_FROM='notifications@yourselfhostedcal.com'
+EMAIL_FROM_NAME='Cal.com'
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
# Configuration to receive emails locally (mailhog)
@@ -299,6 +300,7 @@ E2E_TEST_CALCOM_GCAL_KEYS=
CALCOM_CREDENTIAL_SYNC_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"
+# This the endpoint from which the token is fetched
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 24 bytes for AES256 encryption algorithm
@@ -358,3 +360,6 @@ UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
+
+# Used to disallow emails as being added as guests on bookings
+BLACKLISTED_GUEST_EMAILS=
diff --git a/.eslintignore b/.eslintignore
index 219d7c2615901c..069222c27c0cb1 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,4 +1,6 @@
node_modules
+apps/api/v2/dist
+packages/platform/**/dist/*
**/**/node_modules
**/**/.next
**/**/public
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index c51c8c7c52f93a..0788fabc58dbd7 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,29 +2,18 @@
-Fixes # (issue)
+- Fixes #XXXX (GitHub issue number)
+- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)
-## Requirement/Documentation
+## Mandatory Tasks (DO NOT REMOVE)
-
-
-- If there is a requirement document, please, share it here.
-- If there is a UI/UX design document, please, share it here.
-
-## Type of change
-
-
-
-- Bug fix (non-breaking change which fixes an issue)
-- Chore (refactoring code, technical debt, workflow improvements)
-- New feature (non-breaking change which adds functionality)
-- Breaking change (fix or feature that would cause existing functionality to not work as expected)
-- Tests (Unit/Integration/E2E or any other test)
-- This change requires a documentation update
+- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
+- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
+- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
@@ -35,10 +24,6 @@ Fixes # (issue)
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR
-## Mandatory Tasks
-
-- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
-
## Checklist
@@ -46,7 +31,4 @@ Fixes # (issue)
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
-- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings
-- I haven't added tests that prove my fix is effective or that my feature works
-- I haven't checked if new and existing unit tests pass locally with my changes
diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml
index 62bf25dd5914f0..72baa43cfc56cd 100644
--- a/.github/actions/cache-build/action.yml
+++ b/.github/actions/cache-build/action.yml
@@ -8,22 +8,26 @@ runs:
using: "composite"
steps:
- name: Cache production build
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
id: cache-build
env:
+ # WARN: Don't touch this cache key. Currently github.sha refers to the latest commit in main
+ # and not the branch that's attempting to merge to main, which causes CI errors when merged
+ # to main.
+ # TODO: Fix this problem if intending to modify this cache key.
cache-name: prod-build
key-1: ${{ inputs.node_version }}-${{ hashFiles('yarn.lock') }}
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
key-3: ${{ github.event.pull_request.number || github.ref }}
- # Ensures production-build.yml will always be fresh
key-4: ${{ github.sha }}
+ key-5: ${{ github.event.pull_request.head.sha }}
with:
path: |
${{ github.workspace }}/apps/web/.next
${{ github.workspace }}/apps/web/public/embed
**/.turbo/**
**/dist/**
- key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
+ key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}-${{ env.key-5 }}
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn build
diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml
index 6931d193e2ae31..f1b519f23f1104 100644
--- a/.github/actions/cache-db/action.yml
+++ b/.github/actions/cache-db/action.yml
@@ -12,11 +12,12 @@ runs:
steps:
- name: Cache database
id: cache-db
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
env:
cache-name: cache-db
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
key-2: ${{ github.event.pull_request.number || github.ref }}
+ key-3: ${{ github.event.pull_request.head.sha }}
DATABASE_URL: ${{ inputs.DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
@@ -24,7 +25,7 @@ runs:
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
with:
path: ${{ inputs.path }}
- key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
+ key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
if: steps.cache-db.outputs.cache-hit != 'true'
shell: bash
diff --git a/.github/actions/dangerous-git-checkout/action.yml b/.github/actions/dangerous-git-checkout/action.yml
index 48dca84cbebb92..c2dccc4e290e27 100644
--- a/.github/actions/dangerous-git-checkout/action.yml
+++ b/.github/actions/dangerous-git-checkout/action.yml
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
diff --git a/.github/actions/yarn-install/action.yml b/.github/actions/yarn-install/action.yml
index 0ce62d38717f30..80cd97339500c9 100644
--- a/.github/actions/yarn-install/action.yml
+++ b/.github/actions/yarn-install/action.yml
@@ -20,7 +20,7 @@ runs:
using: "composite"
steps:
- name: Use Node ${{ inputs.node_version }}
- uses: buildjet/setup-node@v3
+ uses: buildjet/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Expose yarn config as "$GITHUB_OUTPUT"
@@ -32,7 +32,7 @@ runs:
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
# Yarn cache is also reusable between arch and os.
- name: Restore yarn cache
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
id: yarn-download-cache
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
@@ -43,7 +43,7 @@ runs:
# Invalidated on yarn.lock changes
- name: Restore node_modules
id: yarn-nm-cache
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
with:
path: "**/node_modules/"
key: ${{ runner.os }}-yarn-nm-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
@@ -51,7 +51,7 @@ runs:
# Invalidated on yarn.lock changes
- name: Restore yarn install state
id: yarn-install-state-cache
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
with:
path: .yarn/ci-cache/
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
diff --git a/.github/actions/yarn-playwright-install/action.yml b/.github/actions/yarn-playwright-install/action.yml
index 601513ee765735..b5d9681be5ba09 100644
--- a/.github/actions/yarn-playwright-install/action.yml
+++ b/.github/actions/yarn-playwright-install/action.yml
@@ -5,7 +5,7 @@ runs:
steps:
- name: Cache playwright binaries
id: playwright-cache
- uses: buildjet/cache@v3
+ uses: buildjet/cache@v4
with:
path: |
~/Library/Caches/ms-playwright
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 208260cad2b1df..6e1a2b605cecf5 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,6 +1,10 @@
"❗️ migrations":
- - packages/prisma/migrations/**/migration.sql
+- changed-files:
+ - any-glob-to-any-file:
+ - packages/prisma/migrations/**/migration.sql
"❗️ .env changes":
- - .env.example
- - .env.appStore.example
+- changed-files:
+ - any-glob-to-any-file:
+ - .env.example
+ - .env.appStore.example
diff --git a/.github/workflows/all-checks.yml b/.github/workflows/all-checks.yml
new file mode 100644
index 00000000000000..8c290f5ec55840
--- /dev/null
+++ b/.github/workflows/all-checks.yml
@@ -0,0 +1,85 @@
+name: All checks
+
+on:
+ merge_group:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ type-check:
+ name: Type check
+ uses: ./.github/workflows/check-types.yml
+ secrets: inherit
+
+ lint:
+ name: Linters
+ uses: ./.github/workflows/lint.yml
+ secrets: inherit
+
+ unit-test:
+ name: Tests
+ uses: ./.github/workflows/unit-tests.yml
+ secrets: inherit
+
+ build-api-v1:
+ name: Production builds
+ uses: ./.github/workflows/api-v1-production-build.yml
+ secrets: inherit
+
+ build-api-v2:
+ name: Production builds
+ uses: ./.github/workflows/api-v2-production-build.yml
+ secrets: inherit
+
+ build:
+ name: Production builds
+ uses: ./.github/workflows/production-build-without-database.yml
+ secrets: inherit
+
+ integration-test:
+ name: Tests
+ needs: [lint, build, build-api-v1, build-api-v2]
+ uses: ./.github/workflows/integration-tests.yml
+ secrets: inherit
+
+ e2e:
+ name: Tests
+ needs: [lint, build, build-api-v1, build-api-v2]
+ uses: ./.github/workflows/e2e.yml
+ secrets: inherit
+
+ e2e-app-store:
+ name: Tests
+ needs: [lint, build, build-api-v1, build-api-v2]
+ uses: ./.github/workflows/e2e-app-store.yml
+ secrets: inherit
+
+ e2e-embed:
+ name: Tests
+ needs: [lint, build, build-api-v1, build-api-v2]
+ uses: ./.github/workflows/e2e-embed.yml
+ secrets: inherit
+
+ e2e-embed-react:
+ name: Tests
+ needs: [lint, build, build-api-v1, build-api-v2]
+ uses: ./.github/workflows/e2e-embed-react.yml
+ secrets: inherit
+
+ analyze:
+ name: Analyze Build
+ needs: [build]
+ uses: ./.github/workflows/nextjs-bundle-analysis.yml
+ secrets: inherit
+
+ required:
+ needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
+ if: always()
+ runs-on: buildjet-2vcpu-ubuntu-2204
+ steps:
+ - name: fail if conditional jobs failed
+ if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
+ run: exit 1
diff --git a/.github/workflows/api-v1-production-build.yml b/.github/workflows/api-v1-production-build.yml
index fc4a1405784e19..80a24ef801b83b 100644
--- a/.github/workflows/api-v1-production-build.yml
+++ b/.github/workflows/api-v1-production-build.yml
@@ -1,4 +1,4 @@
-name: Production Build
+name: Production Builds
on:
workflow_call:
@@ -18,7 +18,10 @@ env:
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
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 }}
@@ -33,13 +36,10 @@ 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 }}
- NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
build:
- name: Build API V1
+ name: Build API v1
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
@@ -60,11 +60,31 @@ jobs:
ports:
- 5432:5432
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
+ - name: Cache API v1 production build
+ uses: buildjet/cache@v4
+ id: cache-api-v1-build
+ env:
+ cache-name: api-v1-build
+ key-1: ${{ hashFiles('yarn.lock') }}
+ key-2: ${{ hashFiles('apps/api/v1/**.[jt]s', 'apps/api/v1/**.[jt]sx', '!**/node_modules') }}
+ key-3: ${{ github.event.pull_request.number || github.ref }}
+ # Ensures production-build.yml will always be fresh
+ key-4: ${{ github.sha }}
+ with:
+ path: |
+ ${{ github.workspace }}/apps/api/v1/.next
+ **/.turbo/**
+ **/dist/**
+ key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
- yarn turbo run build --filter=@calcom/api...
+ if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
+ echo "Cache hit for API v1 build. Skipping build."
+ else
+ yarn turbo run build --filter=@calcom/api...
+ fi
shell: bash
diff --git a/.github/workflows/api-v2-production-build.yml b/.github/workflows/api-v2-production-build.yml
new file mode 100644
index 00000000000000..28fe054a93ed48
--- /dev/null
+++ b/.github/workflows/api-v2-production-build.yml
@@ -0,0 +1,60 @@
+name: Production Build
+
+on:
+ workflow_call:
+
+env:
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+
+jobs:
+ build:
+ name: Build API v2
+ 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@v4
+ - uses: ./.github/actions/dangerous-git-checkout
+ - uses: ./.github/actions/yarn-install
+ - name: Cache API v2 production build
+ uses: buildjet/cache@v4
+ id: cache-api-v2-build
+ env:
+ cache-name: api-v2-build
+ key-1: ${{ hashFiles('yarn.lock') }}
+ key-2: ${{ hashFiles('apps/api/v2/**.[jt]s', 'apps/api/v2/**.[jt]sx', '!**/node_modules') }}
+ key-3: ${{ github.event.pull_request.number || github.ref }}
+ # Ensures production-build.yml will always be fresh
+ key-4: ${{ github.sha }}
+ with:
+ path: |
+ **/dist/**
+ key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
+ - run: |
+ export NODE_OPTIONS="--max_old_space_size=8192"
+ if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
+ echo "Cache hit for API v2 build. Skipping build."
+ else
+ yarn workspace @calcom/api-v2 run generate-schemas
+ rm -rf apps/api/v2/node_modules
+ yarn install
+ yarn workspace @calcom/api-v2 run build
+ fi
+ shell: bash
diff --git a/.github/workflows/cache-clean.yml b/.github/workflows/cache-clean.yml
index dd0be43ec50b41..3b0f51ed1fbfa8 100644
--- a/.github/workflows/cache-clean.yml
+++ b/.github/workflows/cache-clean.yml
@@ -6,10 +6,10 @@ on:
jobs:
cleanup:
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: Check out code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Cleanup
run: |
@@ -21,7 +21,7 @@ jobs:
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
- ## Setting this to not fail the workflow while deleting cache keys.
+ ## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
diff --git a/.github/workflows/check-if-ui-has-changed.yml b/.github/workflows/check-if-ui-has-changed.yml
index 0aa91d974d7733..1a0bd8b9b8fde3 100644
--- a/.github/workflows/check-if-ui-has-changed.yml
+++ b/.github/workflows/check-if-ui-has-changed.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
fetch-depth: 0
diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml
index 85f730a3aed883..3ed84d98b77715 100644
--- a/.github/workflows/check-types.yml
+++ b/.github/workflows/check-types.yml
@@ -7,7 +7,7 @@ jobs:
check-types:
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Show info
diff --git a/.github/workflows/cron-monthlyDigestEmail.yml b/.github/workflows/cron-monthlyDigestEmail.yml
index ed2ec648255334..d8ff0716069aca 100644
--- a/.github/workflows/cron-monthlyDigestEmail.yml
+++ b/.github/workflows/cron-monthlyDigestEmail.yml
@@ -18,9 +18,9 @@ jobs:
run: |
LAST_DAY=$(date -d tomorrow +%d)
if [ "$LAST_DAY" == "01" ]; then
- echo "::set-output name=is_last_day::true"
+ echo "is_last_day=true" >> "$GITHUB_OUTPUT"
else
- echo "::set-output name=is_last_day::false"
+ echo "is_last_day=false" >> "$GITHUB_OUTPUT"
fi
- name: cURL request
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index 50df67ba143fb1..b37240eb12766e 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
token: ${{ secrets.GH_ACCESS_TOKEN }}
diff --git a/.github/workflows/delete-buildjet-cache.yml b/.github/workflows/delete-buildjet-cache.yml
index 8f34eb4d23ff3b..d63487afcbb697 100644
--- a/.github/workflows/delete-buildjet-cache.yml
+++ b/.github/workflows/delete-buildjet-cache.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- uses: buildjet/cache-delete@v1
with:
cache_key: ${{ inputs.cache_key }}
diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml
index a7d8006d369361..2838a454f97f50 100644
--- a/.github/workflows/e2e-app-store.yml
+++ b/.github/workflows/e2e-app-store.yml
@@ -1,12 +1,47 @@
-name: E2E App-Store Apps Tests
+name: E2E App Store Tests
on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
+ E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
+ E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
+ GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
+ NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ 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 }}
jobs:
e2e-app-store:
timeout-minutes: 20
- name: E2E App Store (${{ matrix.shard }}/${{ strategy.job-total }})
+ name: E2E App Store
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -35,16 +70,13 @@ jobs:
- 1025:1025
strategy:
fail-fast: false
- matrix:
- ## There aren't many tests for AppStore. So, just start with 2 shards. Increase if needed.
- shard: [1, 2]
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
@@ -58,44 +90,10 @@ jobs:
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
- uses: ./.github/actions/cache-build
- name: Run Tests
- run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- env:
- ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
- CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
- DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
- DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
- DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
- DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- 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 }}
- E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
- 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_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ run: yarn e2e:app-store
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
- name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
+ name: app-store-results
path: test-results
diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml
index cdc060a4600e4b..efd58e9f96a4b1 100644
--- a/.github/workflows/e2e-embed-react.yml
+++ b/.github/workflows/e2e-embed-react.yml
@@ -3,10 +3,45 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
+ E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
+ E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
+ GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
+ NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ 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 }}
jobs:
e2e-embed:
timeout-minutes: 20
- name: E2E Embed React (${{ matrix.shard }}/${{ strategy.job-total }})
+ name: E2E Embed React
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -27,16 +62,13 @@ jobs:
- 5432:5432
strategy:
fail-fast: false
- matrix:
- ## There aren't many tests for embed-react. So, just start with 2 shards. Increase if needed.
- shard: [1, 2]
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
@@ -44,40 +76,11 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: |
- yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
+ yarn e2e:embed-react
yarn workspace @calcom/embed-react packaged:tests
- env:
- ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
- CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
- DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
- DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
- DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
- DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
- E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
- 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_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
- name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
+ name: embed-react-results
path: test-results
diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml
index 6bc3eeb8c9351b..149f774968fe65 100644
--- a/.github/workflows/e2e-embed.yml
+++ b/.github/workflows/e2e-embed.yml
@@ -3,10 +3,45 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
+ E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
+ E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
+ GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
+ NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ 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 }}
jobs:
e2e-embed:
timeout-minutes: 20
- name: E2E Embed Core (${{ matrix.shard }}/${{ strategy.job-total }})
+ name: E2E Embed Core
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -35,56 +70,23 @@ jobs:
- 1025:1025
strategy:
fail-fast: false
- matrix:
- ## There aren't many tests for embed-core. So, just start with 2 shards. Increase if needed.
- shard: [1, 2]
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- uses: ./.github/actions/cache-build
- name: Run Tests
- run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- env:
- ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
- CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
- DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
- DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
- DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
- DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
- E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
- E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
- 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_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ run: yarn e2e:embed
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
- name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
+ name: embed-core-results
path: test-results
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index d62bb29b0b3ff5..b0084e82fdd7a6 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -1,13 +1,48 @@
-name: E2E tests
+name: E2E
on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
+ E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
+ E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
+ GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
+ NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ 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 }}
jobs:
e2e:
timeout-minutes: 20
- name: E2E tests (${{ matrix.shard }}/${{ strategy.job-total }})
- runs-on: buildjet-4vcpu-ubuntu-2204
+ name: E2E (${{ matrix.shard }}/${{ strategy.job-total }})
+ runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
@@ -36,13 +71,13 @@ jobs:
strategy:
fail-fast: false
matrix:
- shard: [1, 2, 3, 4, 5]
+ shard: [1, 2, 3, 4]
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
@@ -50,40 +85,6 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- env:
- ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
- CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
- DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
- DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
- DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
- DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
- E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
- E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
- E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
- GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
- EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
- EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
- EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
- EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
- 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_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/production-build.yml b/.github/workflows/integration-tests.yml
similarity index 64%
rename from .github/workflows/production-build.yml
rename to .github/workflows/integration-tests.yml
index ddd85c81a8a274..d2a7aefdb86f9c 100644
--- a/.github/workflows/production-build.yml
+++ b/.github/workflows/integration-tests.yml
@@ -1,25 +1,29 @@
-name: Production Build
-
+name: Integration
on:
workflow_call:
-
env:
+ NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
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 }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
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 }}
@@ -34,16 +38,11 @@ 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 }}
- NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
-
-
jobs:
- build:
- name: Build
- runs-on: buildjet-4vcpu-ubuntu-2204
- timeout-minutes: 30
+ integration:
+ timeout-minutes: 20
+ name: Integration
+ runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
@@ -61,9 +60,31 @@ jobs:
--health-retries 5
ports:
- 5432:5432
+ mailhog:
+ image: mailhog/mailhog:v1.0.1
+ credentials:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ ports:
+ - 8025:8025
+ - 1025:1025
+ strategy:
+ fail-fast: false
steps:
- - uses: actions/checkout@v3
+ - uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- - uses: ./.github/actions/cache-build
+ - name: Run Tests
+ run: yarn test -- --integrationTestsOnly
+ # TODO: Generate test results so we can upload them
+ # - name: Upload Test Results
+ # if: ${{ always() }}
+ # uses: actions/upload-artifact@v4
+ # with:
+ # name: test-results
+ # path: test-results
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 202898fcfccf4c..9b39ab67ef71dd 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -12,18 +12,16 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- - uses: actions/labeler@v4
+ - uses: actions/labeler@v5
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
- # https://github.com/actions/labeler/issues/442#issuecomment-1297359481
- sync-labels: ""
team-labels:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- uses: equitybee/team-label-action@main
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
@@ -39,7 +37,7 @@ jobs:
steps:
- name: Apply labels from linked issue to PR
- uses: actions/github-script@v5
+ uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 9bb6f3bdf740cd..1244202e8f430c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -6,7 +6,7 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Run Linting with Reports
diff --git a/.github/workflows/nextjs-bundle-analysis.yml b/.github/workflows/nextjs-bundle-analysis.yml
index bab3e68bc984cd..dc293e4501f42b 100644
--- a/.github/workflows/nextjs-bundle-analysis.yml
+++ b/.github/workflows/nextjs-bundle-analysis.yml
@@ -7,18 +7,49 @@ on:
branches:
- main
+env:
+ ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+ CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+ DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
+ DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
+ DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
+ DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
+ E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
+ E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
+ E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
+ GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
+ EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
+ EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
+ EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
+ EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
+ GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
+ NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
+ NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
+ 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 }}
+ NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
+ NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
+ 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 }}
+
jobs:
- build:
- name: Production build
- if: ${{ github.event_name == 'push' }}
- uses: ./.github/workflows/production-build.yml
- secrets: inherit
analyze:
- needs: build
if: always()
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml
new file mode 100644
index 00000000000000..d18a871ef8d417
--- /dev/null
+++ b/.github/workflows/pr-review.yml
@@ -0,0 +1,17 @@
+name: PR Reviewed
+
+on:
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ label-pr:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Label PR as ready for E2E
+ if: github.event.review.state == 'approved'
+ uses: actions-ecosystem/action-add-labels@v1
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ labels: 'ready-for-e2e'
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index f4de65e6fe1eb3..e55727cc3801ad 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -2,9 +2,10 @@ name: PR Update
on:
pull_request_target:
+ types: [opened, synchronize, reopened, labeled]
branches:
- main
- merge_group:
+ - gh-actions-test-branch
workflow_dispatch:
concurrency:
@@ -14,101 +15,163 @@ concurrency:
jobs:
changes:
name: Detect changes
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
pull-requests: read
outputs:
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
+ commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS)"
+ - name: Get Latest Commit SHA
+ id: get_sha
+ run: |
+ echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
+
+ check-label:
+ needs: [changes]
+ runs-on: buildjet-2vcpu-ubuntu-2204
+ name: Check for E2E label
+ outputs:
+ run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
+ steps:
+ - name: Check if PR exists with ready-for-e2e label for this SHA
+ id: check-if-pr-has-label
+ uses: actions/github-script@v7
+ with:
+ script: |
+ let labels = [];
+
+ if (context.payload.pull_request) {
+ labels = context.payload.pull_request.labels;
+ } else {
+ try {
+ const sha = '${{ needs.changes.outputs.commit-sha }}';
+ console.log('sha', sha);
+ const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ commit_sha: sha
+ });
+
+ if (prs.length === 0) {
+ core.setOutput('run-e2e', false);
+ console.log(`No pull requests found for commit SHA ${sha}`);
+ return;
+ }
+
+ const pr = prs[0];
+ console.log(`PR number: ${pr.number}`);
+ console.log(`PR title: ${pr.title}`);
+ console.log(`PR state: ${pr.state}`);
+ console.log(`PR URL: ${pr.html_url}`);
+
+ labels = pr.labels;
+ }
+ catch (e) {
+ core.setOutput('run-e2e', false);
+ console.log(e);
+ }
+ }
+
+ const labelFound = labels.map(l => l.name).includes('ready-for-e2e');
+ console.log('Found the label?', labelFound);
+ core.setOutput('run-e2e', labelFound);
+
type-check:
name: Type check
- needs: [changes]
+ needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/check-types.yml
secrets: inherit
- test:
- name: Unit tests
- needs: [changes]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
- uses: ./.github/workflows/unit-tests.yml
- secrets: inherit
-
lint:
name: Linters
- needs: [changes]
+ needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/lint.yml
secrets: inherit
- build:
- name: Production build
- needs: [changes]
+ unit-test:
+ name: Tests
+ needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
- uses: ./.github/workflows/production-build.yml
+ uses: ./.github/workflows/unit-tests.yml
secrets: inherit
build-api-v1:
- name: Production build
- needs: [changes]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ name: Production builds
+ needs: [changes, check-label]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && 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]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ build-api-v2:
+ name: Production builds
+ needs: [changes, check-label]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ uses: ./.github/workflows/api-v2-production-build.yml
+ secrets: inherit
+
+ build:
+ name: Production builds
+ needs: [changes, check-label]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
- e2e:
- name: E2E tests
- needs: [changes, lint, build]
+ integration-test:
+ name: Tests
+ needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ uses: ./.github/workflows/integration-tests.yml
+ secrets: inherit
+
+ e2e:
+ name: Tests
+ needs: [changes, check-label, build, build-api-v1, build-api-v2]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
- name: E2E App Store tests
- needs: [changes, lint, build]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ name: Tests
+ needs: [changes, check-label, build, build-api-v1, build-api-v2]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
- name: E2E embeds tests
- needs: [changes, lint, build]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ name: Tests
+ needs: [changes, check-label, build, build-api-v1, build-api-v2]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
- name: E2E React embeds tests
- needs: [changes, lint, build]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ name: Tests
+ needs: [changes, check-label, build, build-api-v1, build-api-v2]
+ if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
- needs: [changes, build]
- if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
+ needs: [build]
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
- needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
+ needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml
index 1dc28a549a84b9..586bb47d2a8aa5 100644
--- a/.github/workflows/pre-release.yml
+++ b/.github/workflows/pre-release.yml
@@ -14,9 +14,9 @@ jobs:
embed: ${{ steps.filter.outputs.embed }}
embed-react: ${{ steps.filter.outputs.embed-react }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- - uses: dorny/paths-filter@v2
+ - uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -39,8 +39,8 @@ jobs:
secrets: inherit
build:
- name: Production build
- uses: ./.github/workflows/production-build.yml
+ name: Production builds
+ uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
e2e:
@@ -67,15 +67,10 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
- build-without-database:
- name: Production build (without database)
- uses: ./.github/workflows/production-build-without-database.yml
- secrets: inherit
-
required:
- needs: [e2e, e2e-app-store, e2e-embed, e2e-embed-react, build-without-database]
+ needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
if: always()
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml
index 8d88b2f4939cff..51949d8fd3a23b 100644
--- a/.github/workflows/production-build-without-database.yml
+++ b/.github/workflows/production-build-without-database.yml
@@ -1,5 +1,4 @@
-name: Production Build (without database)
-
+name: Production Builds
on:
workflow_call:
@@ -37,11 +36,11 @@ env:
jobs:
build:
- name: Build
- runs-on: ubuntu-latest
+ name: Web App
+ runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml
index d0689419a0354e..b9aa6c9961e13f 100644
--- a/.github/workflows/release-docker.yaml
+++ b/.github/workflows/release-docker.yaml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: "Determine tag"
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 547a5e99216403..600404b92c8cc9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
ref: main # Always checkout main even for tagged releases
fetch-depth: 0
diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml
index 6a06056cbc6a28..b9a80a0ec97804 100644
--- a/.github/workflows/semantic-pull-requests.yml
+++ b/.github/workflows/semantic-pull-requests.yml
@@ -37,12 +37,3 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
-
- # Delete a previous comment when the issue has been resolved
- - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
- uses: marocchino/sticky-pull-request-comment@v2
- with:
- header: pr-title-lint-error
- message: |
- Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link.
-
diff --git a/.github/workflows/submodule-sync.yml b/.github/workflows/submodule-sync.yml
index 71e5a32ff2c869..9a92204974e3e9 100644
--- a/.github/workflows/submodule-sync.yml
+++ b/.github/workflows/submodule-sync.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_ACCESS_TOKEN }}
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 14027bf444b868..d4abd9433c5226 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -1,4 +1,4 @@
-name: Unit tests
+name: Unit
on:
workflow_call:
workflow_run:
@@ -6,10 +6,11 @@ on:
types: [completed]
jobs:
test:
+ name: Unit
timeout-minutes: 20
- runs-on: buildjet-4vcpu-ubuntu-2204
+ runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
diff --git a/.github/workflows/yarn-install.yml b/.github/workflows/yarn-install.yml
index 5fbb5c0f1b3411..d5a8a00c3db81a 100644
--- a/.github/workflows/yarn-install.yml
+++ b/.github/workflows/yarn-install.yml
@@ -9,6 +9,6 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 10
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7eac25c49301ed..71978c09301c61 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
To develop locally:
-1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
+1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
own GitHub account and then
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch:
@@ -99,7 +99,7 @@ To develop locally:
- Duplicate `.env.example` to `.env`.
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- - Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
+ - Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
diff --git a/README.md b/README.md
index d4e85ca9e7df2f..84b14bbd51508d 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
-
+
@@ -33,7 +33,7 @@
-
+
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
-
+
+
+## License
+
+Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
+
+> [!NOTE]
+> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
+
+| | AGPLv3 | EE |
+|---|---|---|
+| Self-host for commercial purposes | ✅ | ✅ |
+| Clone privately | ✅ | ✅ |
+| Fork publicly | ✅ | ✅ |
+| Requires CLA | ✅ | ✅ |
+| Official Support| ❌ | ✅ |
+| Derivative work privately | ❌ | ✅ |
+| SSO | ❌ | ✅ |
+| Admin Panel | ❌ | ✅ |
+| Impersonation | ❌ | ✅ |
+| Managed Event Types | ❌ | ✅ |
+| Organizations | ❌ | ✅ |
+| Payments | ❌ | ✅ |
+| Platform | ❌ | ✅ |
+| Teams | ❌ | ✅ |
+| Users | ❌ | ✅ |
+| Video | ❌ | ✅ |
+| Workflows | ❌ | ✅ |
+
+> [!TIP]
+> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
## Repo Activity
diff --git a/apps/api/v1/instrumentation.ts b/apps/api/v1/instrumentation.ts
new file mode 100644
index 00000000000000..79040c9dbb19cb
--- /dev/null
+++ b/apps/api/v1/instrumentation.ts
@@ -0,0 +1,15 @@
+import * as Sentry from "@sentry/nextjs";
+
+export function register() {
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
+ });
+ }
+
+ if (process.env.NEXT_RUNTIME === "edge") {
+ Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
+ });
+ }
+}
diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
new file mode 100644
index 00000000000000..f07e30587194c9
--- /dev/null
+++ b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
@@ -0,0 +1,90 @@
+import type { Request, Response } from "express";
+import type { NextApiResponse, NextApiRequest } from "next";
+import { createMocks } from "node-mocks-http";
+import { describe, it, expect, vi } from "vitest";
+
+import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
+
+import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
+ checkRateLimitAndThrowError: vi.fn(),
+}));
+
+describe("rateLimitApiKey middleware", () => {
+ it("should return 401 if no apiKey is provided", async () => {
+ const { req, res } = createMocks({
+ method: "GET",
+ query: {},
+ });
+
+ await rateLimitApiKey(req, res, vi.fn() as any);
+
+ expect(res._getStatusCode()).toBe(401);
+ expect(res._getJSONData()).toEqual({ message: "No apiKey provided" });
+ });
+
+ it("should call checkRateLimitAndThrowError with correct parameters", async () => {
+ const { req, res } = createMocks({
+ method: "GET",
+ query: { apiKey: "test-key" },
+ });
+
+ (checkRateLimitAndThrowError as any).mockResolvedValueOnce({
+ limit: 100,
+ remaining: 99,
+ reset: Date.now(),
+ });
+
+ // @ts-expect-error weird typing between middleware and createMocks
+ await rateLimitApiKey(req, res, vi.fn() as any);
+
+ expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
+ identifier: "test-key",
+ rateLimitingType: "api",
+ onRateLimiterResponse: expect.any(Function),
+ });
+ });
+
+ it("should set rate limit headers correctly", async () => {
+ const { req, res } = createMocks({
+ method: "GET",
+ query: { apiKey: "test-key" },
+ });
+
+ const rateLimiterResponse = {
+ limit: 100,
+ remaining: 99,
+ reset: Date.now(),
+ };
+
+ (checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
+ onRateLimiterResponse(rateLimiterResponse);
+ });
+
+ // @ts-expect-error weird typing between middleware and createMocks
+ await rateLimitApiKey(req, res, vi.fn() as any);
+
+ expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
+ expect(res.getHeader("X-RateLimit-Remaining")).toBe(rateLimiterResponse.remaining);
+ expect(res.getHeader("X-RateLimit-Reset")).toBe(rateLimiterResponse.reset);
+ });
+
+ it("should return 429 if rate limit is exceeded", async () => {
+ const { req, res } = createMocks({
+ method: "GET",
+ query: { apiKey: "test-key" },
+ });
+
+ (checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
+
+ // @ts-expect-error weird typing between middleware and createMocks
+ await rateLimitApiKey(req, res, vi.fn() as any);
+
+ expect(res._getStatusCode()).toBe(429);
+ expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
+ });
+});
diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.ts
index 8a2f6b6e91d149..7a4ad03d9c4c8a 100644
--- a/apps/api/v1/lib/helpers/rateLimitApiKey.ts
+++ b/apps/api/v1/lib/helpers/rateLimitApiKey.ts
@@ -6,15 +6,19 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// TODO: Add a way to add trusted api keys
- await checkRateLimitAndThrowError({
- identifier: req.query.apiKey as string,
- rateLimitingType: "api",
- onRateLimiterResponse: (response) => {
- res.setHeader("X-RateLimit-Limit", response.limit);
- res.setHeader("X-RateLimit-Remaining", response.remaining);
- res.setHeader("X-RateLimit-Reset", response.reset);
- },
- });
+ try {
+ await checkRateLimitAndThrowError({
+ identifier: req.query.apiKey as string,
+ rateLimitingType: "api",
+ onRateLimiterResponse: (response) => {
+ res.setHeader("X-RateLimit-Limit", response.limit);
+ res.setHeader("X-RateLimit-Remaining", response.remaining);
+ res.setHeader("X-RateLimit-Reset", response.reset);
+ },
+ });
+ } catch (error) {
+ res.status(429).json({ message: "Rate limit exceeded" });
+ }
await next();
};
diff --git a/apps/api/v1/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts
index 8b8114888ed4e6..23cbe74d433cb4 100644
--- a/apps/api/v1/lib/helpers/verifyApiKey.ts
+++ b/apps/api/v1/lib/helpers/verifyApiKey.ts
@@ -6,6 +6,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
+import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
@@ -36,7 +37,10 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
// save the user id in the request for later use
req.userId = apiKey.userId;
- // save the isAdmin boolean here for later use
- req.isAdmin = await isAdminGuard(req);
+ const { isAdmin, scope } = await isAdminGuard(req);
+
+ req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
+ req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
+
await next();
};
diff --git a/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts
index a797bb2db2f167..b7922ae54df1d4 100644
--- a/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts
+++ b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts
@@ -3,9 +3,9 @@ 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;
+ const { isSystemWideAdmin } = req;
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" });
}
diff --git a/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts
index 2cb69377c1e4af..8faffc4b986054 100644
--- a/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts
+++ b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts
@@ -4,9 +4,9 @@ import { HttpError } from "@calcom/lib/http-error";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
-export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
+export function extractUserIdsFromQuery({ isSystemWideAdmin, query }: NextApiRequest) {
/** Guard: Only admins can query other users */
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
}
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
diff --git a/apps/api/v1/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts
index 8b90c378678ab3..1caf210a982688 100644
--- a/apps/api/v1/lib/utils/isAdmin.ts
+++ b/apps/api/v1/lib/utils/isAdmin.ts
@@ -1,10 +1,37 @@
import type { NextApiRequest } from "next";
import prisma from "@calcom/prisma";
-import { UserPermissionRole } from "@calcom/prisma/enums";
+import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
+
+import { ScopeOfAdmin } from "./scopeOfAdmin";
export const isAdminGuard = async (req: NextApiRequest) => {
const { userId } = req;
- const user = await prisma.user.findUnique({ where: { id: userId } });
- return user?.role === UserPermissionRole.ADMIN;
+ const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
+ if (!user) return { isAdmin: false, scope: null };
+
+ const { role: userRole } = user;
+ if (userRole === UserPermissionRole.ADMIN) return { isAdmin: true, scope: ScopeOfAdmin.SystemWide };
+
+ const orgOwnerOrAdminMemberships = await prisma.membership.findMany({
+ where: {
+ userId: userId,
+ accepted: true,
+ team: {
+ isOrganization: true,
+ },
+ OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
+ },
+ select: {
+ team: {
+ select: {
+ id: true,
+ isOrganization: true,
+ },
+ },
+ },
+ });
+ if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
+
+ return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
};
diff --git a/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts b/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts
new file mode 100644
index 00000000000000..9e80317de0a8f2
--- /dev/null
+++ b/apps/api/v1/lib/utils/retrieveScopedAccessibleUsers.ts
@@ -0,0 +1,92 @@
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+type AccessibleUsersType = {
+ memberUserIds: number[];
+ adminUserId: number;
+};
+
+const getAllOrganizationMemberships = async (
+ memberships: {
+ userId: number;
+ role: MembershipRole;
+ teamId: number;
+ }[],
+ orgId: number
+) => {
+ return memberships.reduce((acc, membership) => {
+ if (membership.teamId === orgId) {
+ acc.push(membership.userId);
+ }
+ return acc;
+ }, []);
+};
+
+const getAllAdminMemberships = async (userId: number) => {
+ return await prisma.membership.findMany({
+ where: {
+ userId: userId,
+ accepted: true,
+ OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
+ },
+ select: {
+ team: {
+ select: {
+ id: true,
+ isOrganization: true,
+ },
+ },
+ },
+ });
+};
+
+const getAllOrganizationMembers = async (organizationId: number) => {
+ return await prisma.membership.findMany({
+ where: {
+ teamId: organizationId,
+ accepted: true,
+ },
+ select: {
+ userId: true,
+ },
+ });
+};
+
+export const getAccessibleUsers = async ({
+ memberUserIds,
+ adminUserId,
+}: AccessibleUsersType): Promise => {
+ const memberships = await prisma.membership.findMany({
+ where: {
+ team: {
+ isOrganization: true,
+ },
+ accepted: true,
+ OR: [
+ { userId: { in: memberUserIds } },
+ { userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } },
+ ],
+ },
+ select: {
+ userId: true,
+ role: true,
+ teamId: true,
+ },
+ });
+
+ const orgId = memberships.find((membership) => membership.userId === adminUserId)?.teamId;
+ if (!orgId) return [];
+
+ const allAccessibleMemberUserIds = await getAllOrganizationMemberships(memberships, orgId);
+ const accessibleUserIds = allAccessibleMemberUserIds.filter((userId) => userId !== adminUserId);
+ return accessibleUserIds;
+};
+
+export const retrieveOrgScopedAccessibleUsers = async ({ adminId }: { adminId: number }) => {
+ const adminMemberships = await getAllAdminMemberships(adminId);
+ const organizationId = adminMemberships.find((membership) => membership.team.isOrganization)?.team.id;
+ if (!organizationId) return [];
+
+ const allMemberships = await getAllOrganizationMembers(organizationId);
+ return allMemberships.map((membership) => membership.userId);
+};
diff --git a/apps/api/v1/lib/utils/scopeOfAdmin.ts b/apps/api/v1/lib/utils/scopeOfAdmin.ts
new file mode 100644
index 00000000000000..ed0985669962de
--- /dev/null
+++ b/apps/api/v1/lib/utils/scopeOfAdmin.ts
@@ -0,0 +1,4 @@
+export const ScopeOfAdmin = {
+ SystemWide: "SystemWide",
+ OrgOwnerOrAdmin: "OrgOwnerOrAdmin",
+} as const;
diff --git a/apps/api/v1/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts
index 6b5e84298c99fc..867bc4d5ce3bb4 100644
--- a/apps/api/v1/lib/validations/booking.ts
+++ b/apps/api/v1/lib/validations/booking.ts
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
export const schemaBookingGetParams = z.object({
dateFrom: iso8601.optional(),
dateTo: iso8601.optional(),
+ order: z.enum(["asc", "desc"]).default("asc"),
+ sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
});
const schemaBookingEditParams = z
@@ -55,7 +57,7 @@ export const schemaBookingReadPublic = Booking.extend({
timeZone: true,
locale: true,
})
- .optional(),
+ .nullish(),
payment: z
.array(
_PaymentModel.pick({
@@ -82,4 +84,5 @@ export const schemaBookingReadPublic = Booking.extend({
metadata: true,
status: true,
responses: true,
+ fromReschedule: true,
});
diff --git a/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts
index b9ec495f47bfe3..ef6d811ea996c3 100644
--- a/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts
+++ b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts
@@ -14,3 +14,7 @@ export const withValidQueryIdTransformParseInt = withValidation({
type: "Zod",
mode: "query",
});
+
+export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({
+ recordingId: z.string(),
+});
diff --git a/apps/api/v1/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts
index 3bc0af2c499b3d..84e3c565b86354 100644
--- a/apps/api/v1/lib/validations/team.ts
+++ b/apps/api/v1/lib/validations/team.ts
@@ -6,6 +6,9 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
hideBranding: true,
metadata: true,
pendingPayment: true,
+ isOrganization: true,
+ isPlatform: true,
+ smsLockState: true,
});
const schemaTeamRequiredParams = z.object({
diff --git a/apps/api/v1/next.config.js b/apps/api/v1/next.config.js
index d875b8169c8257..289dfbb9e8049a 100644
--- a/apps/api/v1/next.config.js
+++ b/apps/api/v1/next.config.js
@@ -2,7 +2,12 @@ const { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
const plugins = [withAxiom];
+
+/** @type {import("next").NextConfig} */
const nextConfig = {
+ experimental: {
+ instrumentationHook: true,
+ },
transpilePackages: [
"@calcom/app-store",
"@calcom/core",
@@ -87,12 +92,12 @@ const nextConfig = {
};
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
- nextConfig["sentry"] = {
- autoInstrumentServerFunctions: true,
- hideSourceMaps: true,
- };
-
- plugins.push(withSentryConfig);
+ plugins.push((nextConfig) =>
+ withSentryConfig(nextConfig, {
+ autoInstrumentServerFunctions: true,
+ hideSourceMaps: true,
+ })
+ );
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
diff --git a/apps/api/v1/next.d.ts b/apps/api/v1/next.d.ts
index 5c1be26eb44639..a8d7fbeb1dbd46 100644
--- a/apps/api/v1/next.d.ts
+++ b/apps/api/v1/next.d.ts
@@ -11,7 +11,8 @@ export declare module "next" {
method: string;
// session: { user: { id: number } };
// query: Partial<{ [key: string]: string | string[] }>;
- isAdmin: boolean;
+ isSystemWideAdmin: boolean;
+ isOrganizationOwnerOrAdmin: boolean;
pagination: { take: number; skip: number };
}
}
diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json
index 5a752cbfbd790d..9df6ad3643b3d7 100644
--- a/apps/api/v1/package.json
+++ b/apps/api/v1/package.json
@@ -30,7 +30,7 @@
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
- "@sentry/nextjs": "^7.73.0",
+ "@sentry/nextjs": "^8.8.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.4",
diff --git a/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts
index c0e3fb14388033..f76ec117c606b3 100644
--- a/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
export async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdAsString.parse(req.query);
// Admin can check any api key
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Check if user can access the api key
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId },
diff --git a/apps/api/v1/pages/api/api-keys/_get.ts b/apps/api/v1/pages/api/api-keys/_get.ts
index 8f18e3ebae59d1..84d61c3879be3e 100644
--- a/apps/api/v1/pages/api/api-keys/_get.ts
+++ b/apps/api/v1/pages/api/api-keys/_get.ts
@@ -16,8 +16,8 @@ type CustomNextApiRequest = NextApiRequest & {
function handleAdminRequests(req: CustomNextApiRequest) {
// To match type safety with runtime
if (!hasReqArgs(req)) throw Error("Missing req.args");
- const { userId, isAdmin } = req;
- if (isAdmin && req.query.userId) {
+ const { userId, isSystemWideAdmin } = req;
+ if (isSystemWideAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
req.args.where = { userId: { in: userIds } };
@@ -30,8 +30,8 @@ function hasReqArgs(req: CustomNextApiRequest): req is Ensure) {
- const { isAdmin } = req;
- if (isAdmin) return;
+ const { isSystemWideAdmin } = req;
+ if (isSystemWideAdmin) return;
const { userId } = req;
const { bookingId } = body;
if (bookingId) {
diff --git a/apps/api/v1/pages/api/attendees/_get.ts b/apps/api/v1/pages/api/attendees/_get.ts
index b3ec13db7bd7b9..9f1f456766af36 100644
--- a/apps/api/v1/pages/api/attendees/_get.ts
+++ b/apps/api/v1/pages/api/attendees/_get.ts
@@ -31,8 +31,8 @@ import { schemaAttendeeReadPublic } from "~/lib/validations/attendee";
* description: No attendees were found
*/
async function handler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- const args: Prisma.AttendeeFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } };
+ const { userId, isSystemWideAdmin } = req;
+ const args: Prisma.AttendeeFindManyArgs = isSystemWideAdmin ? {} : { where: { booking: { userId } } };
const data = await prisma.attendee.findMany(args);
const attendees = data.map((attendee) => schemaAttendeeReadPublic.parse(attendee));
if (!attendees) throw new HttpError({ statusCode: 404, message: "No attendees were found" });
diff --git a/apps/api/v1/pages/api/attendees/_post.ts b/apps/api/v1/pages/api/attendees/_post.ts
index 610f3062822cf7..03ca47a61409f8 100644
--- a/apps/api/v1/pages/api/attendees/_post.ts
+++ b/apps/api/v1/pages/api/attendees/_post.ts
@@ -52,10 +52,10 @@ import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "~/lib/
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const body = schemaAttendeeCreateBodyParams.parse(req.body);
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
const userBooking = await prisma.booking.findFirst({
where: { userId, id: body.bookingId },
select: { id: true },
diff --git a/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts
index 406c808511cb47..245f5c9cb05272 100644
--- a/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts
@@ -5,10 +5,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin, query } = req;
+ const { userId, isSystemWideAdmin, query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
/** Admins can skip the ownership verification */
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
/**
* There's a caveat here. If the availability exists but the user doesn't own it,
* the user will see a 404 error which may or not be the desired behavior.
diff --git a/apps/api/v1/pages/api/availabilities/_post.ts b/apps/api/v1/pages/api/availabilities/_post.ts
index a0f915241fbd8a..2e0a8e1247c08a 100644
--- a/apps/api/v1/pages/api/availabilities/_post.ts
+++ b/apps/api/v1/pages/api/availabilities/_post.ts
@@ -86,8 +86,8 @@ async function postHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- if (isAdmin) return;
+ const { userId, isSystemWideAdmin } = req;
+ if (isSystemWideAdmin) return;
const data = schemaAvailabilityCreateBodyParams.parse(req.body);
const schedule = await prisma.schedule.findFirst({
where: { userId, id: data.scheduleId },
diff --git a/apps/api/v1/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts
index 580d149c6fbf11..935619cc19c559 100644
--- a/apps/api/v1/pages/api/availability/_get.ts
+++ b/apps/api/v1/pages/api/availability/_get.ts
@@ -189,7 +189,7 @@ const availabilitySchema = z
);
async function handler(req: NextApiRequest) {
- const { isAdmin, userId: reqUserId } = req;
+ const { isSystemWideAdmin, userId: reqUserId } = req;
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
if (!teamId)
return getUserAvailability({
@@ -224,7 +224,7 @@ async function handler(req: NextApiRequest) {
const isUserAdminOrOwner =
memberRoles[reqUserId] == MembershipRole.ADMIN ||
memberRoles[reqUserId] == MembershipRole.OWNER ||
- isAdmin;
+ isSystemWideAdmin;
if (!isUserAdminOrOwner) throw new HttpError({ statusCode: 403, message: "Forbidden" });
const availabilities = members.map(async (user) => {
return {
diff --git a/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts
index 1970d5d20859e8..542f16265b5c42 100644
--- a/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = 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;
+ if (isSystemWideAdmin) return;
// Find all references where the user has bookings
const bookingReference = await prisma.bookingReference.findFirst({
where: { id, booking: { userId } },
diff --git a/apps/api/v1/pages/api/booking-references/[id]/_patch.ts b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts
index d90ddeb31d7605..b699de7cd959a7 100644
--- a/apps/api/v1/pages/api/booking-references/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts
@@ -60,12 +60,12 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* description: Authorization information is missing or invalid.
*/
export async function patchHandler(req: NextApiRequest) {
- const { query, body, isAdmin, userId } = req;
+ const { query, body, isSystemWideAdmin, userId } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const data = schemaBookingEditBodyParams.parse(body);
/* If user tries to update bookingId, we run extra checks */
if (data.bookingId) {
- const args: Prisma.BookingFindFirstOrThrowArgs = isAdmin
+ const args: Prisma.BookingFindFirstOrThrowArgs = isSystemWideAdmin
? /* If admin, we only check that the booking exists */
{ where: { id: data.bookingId } }
: /* For non-admins we make sure the booking belongs to the user */
diff --git a/apps/api/v1/pages/api/booking-references/_get.ts b/apps/api/v1/pages/api/booking-references/_get.ts
index 15dc11c1c04023..5794d3fe1db5bf 100644
--- a/apps/api/v1/pages/api/booking-references/_get.ts
+++ b/apps/api/v1/pages/api/booking-references/_get.ts
@@ -30,8 +30,10 @@ import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-refe
* description: No booking references were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- const args: Prisma.BookingReferenceFindManyArgs = isAdmin ? {} : { where: { booking: { userId } } };
+ const { userId, isSystemWideAdmin } = req;
+ const args: Prisma.BookingReferenceFindManyArgs = isSystemWideAdmin
+ ? {}
+ : { where: { booking: { userId } } };
const data = await prisma.bookingReference.findMany(args);
return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) };
}
diff --git a/apps/api/v1/pages/api/booking-references/_post.ts b/apps/api/v1/pages/api/booking-references/_post.ts
index 98761421f93467..d551ac98907f2e 100644
--- a/apps/api/v1/pages/api/booking-references/_post.ts
+++ b/apps/api/v1/pages/api/booking-references/_post.ts
@@ -62,9 +62,9 @@ import {
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const body = schemaBookingCreateBodyParams.parse(req.body);
- const args: Prisma.BookingFindFirstOrThrowArgs = isAdmin
+ const args: Prisma.BookingFindFirstOrThrowArgs = isSystemWideAdmin
? /* If admin, we only check that the booking exists */
{ where: { id: body.bookingId } }
: /* For non-admins we make sure the booking belongs to the user */
diff --git a/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts
index b365b675e06b5f..41225f0455f65d 100644
--- a/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts
@@ -3,15 +3,33 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin, query } = req;
- if (isAdmin) {
+ const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin, query } = req;
+ if (isSystemWideAdmin) {
return;
}
const { id } = schemaQueryIdParseInt.parse(query);
+ if (isOrganizationOwnerOrAdmin) {
+ const booking = await prisma.booking.findUnique({
+ where: { id },
+ select: { userId: true },
+ });
+ if (booking) {
+ const bookingUserId = booking.userId;
+ if (bookingUserId) {
+ const accessibleUsersIds = await getAccessibleUsers({
+ adminUserId: userId,
+ memberUserIds: [bookingUserId],
+ });
+ if (accessibleUsersIds.length > 0) return;
+ }
+ }
+ }
+
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
where: { id: userId },
include: {
@@ -43,7 +61,7 @@ async function authMiddleware(req: NextApiRequest) {
});
if (!teamBookings) {
- throw new HttpError({ statusCode: 401, message: "You are not authorized" });
+ throw new HttpError({ statusCode: 403, message: "You are not authorized" });
}
}
}
diff --git a/apps/api/v1/pages/api/bookings/[id]/_patch.ts b/apps/api/v1/pages/api/bookings/[id]/_patch.ts
index ed36ec8ed43f18..d26eb3c3b6c62a 100644
--- a/apps/api/v1/pages/api/bookings/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/bookings/[id]/_patch.ts
@@ -5,6 +5,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
import { schemaBookingEditBodyParams, schemaBookingReadPublic } from "~/lib/validations/booking";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@@ -109,14 +110,27 @@ export async function patchHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest, body: z.infer) {
- const { isAdmin } = req;
- if (body.userId && !isAdmin) {
+ const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
+ if (body.userId && !isSystemWideAdmin && !isOrganizationOwnerOrAdmin) {
// Organizer has to be a cal user and we can't allow a booking to be transfered to some other cal user's name
throw new HttpError({
statusCode: 403,
message: "Only admin can change the organizer of a booking",
});
}
+
+ if (body.userId && isOrganizationOwnerOrAdmin) {
+ const accessibleUsersIds = await getAccessibleUsers({
+ adminUserId: userId,
+ memberUserIds: [body.userId],
+ });
+ if (accessibleUsersIds.length === 0) {
+ throw new HttpError({
+ statusCode: 403,
+ message: "Only admin can change the organizer of a booking",
+ });
+ }
+ }
}
export default defaultResponder(patchHandler);
diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts
new file mode 100644
index 00000000000000..3ec371dd984ac5
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts
@@ -0,0 +1,99 @@
+import type { NextApiRequest } from "next";
+
+import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient";
+import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
+import { HttpError } from "@calcom/lib/http-error";
+import { defaultResponder } from "@calcom/lib/server";
+import prisma from "@calcom/prisma";
+import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
+import type { PartialReference } from "@calcom/types/EventManager";
+
+import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
+
+/**
+ * @swagger
+ * /bookings/{id}/recordings:
+ * get:
+ * summary: Find all Cal video recordings of that booking
+ * operationId: getRecordingsByBookingId
+ * parameters:
+ * - in: path
+ * name: id
+ * schema:
+ * type: integer
+ * required: true
+ * description: ID of the booking for which recordings need to be fetched. Recording download link is only valid for 12 hours and you would have to fetch the recordings again to get new download link
+ * - in: query
+ * name: apiKey
+ * required: true
+ * schema:
+ * type: string
+ * description: Your API key
+ * tags:
+ * - bookings
+ * responses:
+ * 200:
+ * description: OK
+ * content:
+ * application/json:
+ * schema:
+ * $ref: "#/components/schemas/ArrayOfRecordings"
+ * examples:
+ * recordings:
+ * value:
+ * - id: "ad90a2e7-154f-49ff-a815-5da1db7bf899"
+ * room_name: "0n22w24AQ5ZFOtEKX2gX"
+ * start_ts: 1716215386
+ * status: "finished"
+ * max_participants: 1
+ * duration: 11
+ * share_token: "x94YK-69Gnh7"
+ * download_link: "https://daily-meeting-recordings..."
+ * 401:
+ * description: Authorization information is missing or invalid.
+ * 404:
+ * description: Booking was not found
+ */
+
+export async function getHandler(req: NextApiRequest) {
+ const { query } = req;
+ const { id } = schemaQueryIdParseInt.parse(query);
+
+ const booking = await prisma.booking.findUnique({
+ where: { id },
+ include: { references: true },
+ });
+
+ if (!booking)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Booking found with booking id ${id}`,
+ });
+
+ const roomName =
+ booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
+ undefined;
+
+ if (!roomName)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Cal Video reference found with booking id ${booking.id}`,
+ });
+
+ const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
+
+ if (!recordings || !("data" in recordings)) return [];
+
+ const recordingWithDownloadLink = recordings.data.map((recording: RecordingItemSchema) => {
+ return getDownloadLinkOfCalVideoByRecordingId(recording.id)
+ .then((res) => ({
+ ...recording,
+ download_link: res?.download_link,
+ }))
+ .catch((err) => ({ ...recording, download_link: null, error: err.message }));
+ });
+ const res = await Promise.all(recordingWithDownloadLink);
+ return res;
+}
+
+export default defaultResponder(getHandler);
diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts
new file mode 100644
index 00000000000000..8d5bc44ed5ddb0
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts
@@ -0,0 +1,16 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+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"),
+ })(req, res);
+ })
+);
diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts
new file mode 100644
index 00000000000000..67cac58bdd4ecd
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/_get.ts
@@ -0,0 +1,94 @@
+import type { NextApiRequest } from "next";
+
+import {
+ getTranscriptsAccessLinkFromRecordingId,
+ checkIfRoomNameMatchesInRecording,
+} from "@calcom/core/videoClient";
+import { HttpError } from "@calcom/lib/http-error";
+import { defaultResponder } from "@calcom/lib/server";
+import prisma from "@calcom/prisma";
+import type { PartialReference } from "@calcom/types/EventManager";
+
+import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTransformParseInt";
+
+/**
+ * @swagger
+ * /bookings/{id}/transcripts/{recordingId}:
+ * get:
+ * summary: Find all Cal video transcripts of that recording
+ * operationId: getTranscriptsByRecordingId
+ * parameters:
+ * - in: path
+ * name: id
+ * schema:
+ * type: integer
+ * required: true
+ * description: ID of the booking for which transcripts need to be fetched.
+ * - in: path
+ * name: recordingId
+ * schema:
+ * type: string
+ * required: true
+ * description: ID of the recording(daily.co recording id) for which transcripts need to be fetched.
+ * - in: query
+ * name: apiKey
+ * required: true
+ * schema:
+ * type: string
+ * description: Your API key
+ * tags:
+ * - bookings
+ * responses:
+ * 200:
+ * description: OK
+ * content:
+ * application/json:
+ * 401:
+ * description: Authorization information is missing or invalid.
+ * 404:
+ * description: Booking was not found
+ */
+
+export async function getHandler(req: NextApiRequest) {
+ const { query } = req;
+ const { id, recordingId } = getTranscriptFromRecordingId.parse(query);
+
+ await checkIfRecordingBelongsToBooking(id, recordingId);
+
+ const transcriptsAccessLinks = await getTranscriptsAccessLinkFromRecordingId(recordingId);
+
+ return transcriptsAccessLinks;
+}
+
+const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
+ const booking = await prisma.booking.findUnique({
+ where: { id: bookingId },
+ include: { references: true },
+ });
+
+ if (!booking)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Booking found with booking id ${bookingId}`,
+ });
+
+ const roomName =
+ booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
+ undefined;
+
+ if (!roomName)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Booking Reference with Daily Video found with booking id ${bookingId}`,
+ });
+
+ const canUserAccessRecordingId = await checkIfRoomNameMatchesInRecording(roomName, recordingId);
+ if (!canUserAccessRecordingId) {
+ throw new HttpError({
+ statusCode: 403,
+ message: `This Recording Id ${recordingId} does not belong to booking ${bookingId}`,
+ });
+ }
+};
+
+export default defaultResponder(getHandler);
diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts
new file mode 100644
index 00000000000000..3085d27a86745d
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/[recordingId]/index.ts
@@ -0,0 +1,16 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+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"),
+ })(req, res);
+ })
+);
diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts
new file mode 100644
index 00000000000000..2200f064662d81
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts
@@ -0,0 +1,73 @@
+import type { NextApiRequest } from "next";
+
+import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
+import { HttpError } from "@calcom/lib/http-error";
+import { defaultResponder } from "@calcom/lib/server";
+import prisma from "@calcom/prisma";
+import type { PartialReference } from "@calcom/types/EventManager";
+
+import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
+
+/**
+ * @swagger
+ * /bookings/{id}/transcripts:
+ * get:
+ * summary: Find all Cal video transcripts of that booking
+ * operationId: getTranscriptsByBookingId
+ * parameters:
+ * - in: path
+ * name: id
+ * schema:
+ * type: integer
+ * required: true
+ * description: ID of the booking for which recordings need to be fetched.
+ * - in: query
+ * name: apiKey
+ * required: true
+ * schema:
+ * type: string
+ * description: Your API key
+ * tags:
+ * - bookings
+ * responses:
+ * 200:
+ * description: OK
+ * content:
+ * application/json:
+ * 401:
+ * description: Authorization information is missing or invalid.
+ * 404:
+ * description: Booking was not found
+ */
+
+export async function getHandler(req: NextApiRequest) {
+ const { query } = req;
+ const { id } = schemaQueryIdParseInt.parse(query);
+
+ const booking = await prisma.booking.findUnique({
+ where: { id },
+ include: { references: true },
+ });
+
+ if (!booking)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Booking found with booking id ${id}`,
+ });
+
+ const roomName =
+ booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
+ undefined;
+
+ if (!roomName)
+ throw new HttpError({
+ statusCode: 404,
+ message: `No Cal Video reference found with booking id ${booking.id}`,
+ });
+
+ const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
+
+ return transcripts;
+}
+
+export default defaultResponder(getHandler);
diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts
new file mode 100644
index 00000000000000..8d5bc44ed5ddb0
--- /dev/null
+++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts
@@ -0,0 +1,16 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+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"),
+ })(req, res);
+ })
+);
diff --git a/apps/api/v1/pages/api/bookings/_get.ts b/apps/api/v1/pages/api/bookings/_get.ts
index 67ae16f45635e4..d1aab8b2ef9e6a 100644
--- a/apps/api/v1/pages/api/bookings/_get.ts
+++ b/apps/api/v1/pages/api/bookings/_get.ts
@@ -5,6 +5,11 @@ import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
+import { withMiddleware } from "~/lib/helpers/withMiddleware";
+import {
+ getAccessibleUsers,
+ retrieveOrgScopedAccessibleUsers,
+} from "~/lib/utils/retrieveScopedAccessibleUsers";
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@@ -46,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* type: string
* format: email
* example: [john.doe@example.com, jane.doe@example.com]
+ * - in: query
+ * name: order
+ * required: false
+ * schema:
+ * type: string
+ * enum: [asc, desc]
+ * - in: query
+ * name: sortBy
+ * required: false
+ * schema:
+ * type: string
+ * enum: [createdAt, updatedAt]
* operationId: listBookings
* tags:
* - bookings
@@ -108,7 +125,11 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* 404:
* description: No bookings were found
*/
-
+type GetAdminArgsType = {
+ adminDidQueryUserIds?: boolean;
+ requestedUserIds: number[];
+ userId: number;
+};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
@@ -161,12 +182,20 @@ function buildWhereClause(
};
}
-async function handler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
-
- const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
+export async function handler(req: NextApiRequest) {
+ const {
+ userId,
+ isSystemWideAdmin,
+ isOrganizationOwnerOrAdmin,
+ pagination: { take, skip },
+ } = req;
+ const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
const args: Prisma.BookingFindManyArgs = {};
+ if (req.query.take && req.query.page) {
+ args.take = take;
+ args.skip = skip;
+ }
args.include = {
attendees: true,
user: true,
@@ -182,19 +211,32 @@ async function handler(req: NextApiRequest) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
/** Only admins can query other users */
- if (isAdmin) {
- if (req.query.userId) {
+ if (isSystemWideAdmin) {
+ if (req.query.userId || filterByAttendeeEmails) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
- const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
- const users = await prisma.user.findMany({
- where: { id: { in: userIds } },
- select: { email: true },
- });
- const userEmails = users.map((u) => u.email);
- args.where = buildWhereClause(userId, attendeeEmails, userIds, userEmails);
- } else if (filterByAttendeeEmails) {
- args.where = buildWhereClause(null, attendeeEmails, [], []);
+ const requestedUserIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
+
+ const systemWideAdminArgs = {
+ adminDidQueryUserIds: !!req.query.userId,
+ requestedUserIds,
+ userId,
+ };
+ const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
+ args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
}
+ } else if (isOrganizationOwnerOrAdmin) {
+ let requestedUserIds = [userId];
+ if (req.query.userId || filterByAttendeeEmails) {
+ const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
+ requestedUserIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
+ }
+ const orgWideAdminArgs = {
+ adminDidQueryUserIds: !!req.query.userId,
+ requestedUserIds,
+ userId,
+ };
+ const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
+ args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
} else {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -203,7 +245,7 @@ async function handler(req: NextApiRequest) {
},
});
if (!user) {
- throw new HttpError({ message: "User not found", statusCode: 500 });
+ throw new HttpError({ message: "User not found", statusCode: 404 });
}
args.where = buildWhereClause(userId, attendeeEmails, [], []);
}
@@ -221,8 +263,69 @@ async function handler(req: NextApiRequest) {
};
}
+ if (sortBy === "updatedAt") {
+ args.orderBy = {
+ updatedAt: order,
+ };
+ }
+
+ if (sortBy === "createdAt") {
+ args.orderBy = {
+ createdAt: order,
+ };
+ }
+
const data = await prisma.booking.findMany(args);
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
-export default defaultResponder(handler);
+const handleSystemWideAdminArgs = async ({
+ adminDidQueryUserIds,
+ requestedUserIds,
+ userId,
+}: GetAdminArgsType) => {
+ if (adminDidQueryUserIds) {
+ const users = await prisma.user.findMany({
+ where: { id: { in: requestedUserIds } },
+ select: { email: true },
+ });
+ const userEmails = users.map((u) => u.email);
+
+ return { userId, userIds: requestedUserIds, userEmails };
+ }
+ return { userId: null, userIds: [], userEmails: [] };
+};
+
+const handleOrgWideAdminArgs = async ({
+ adminDidQueryUserIds,
+ requestedUserIds,
+ userId,
+}: GetAdminArgsType) => {
+ if (adminDidQueryUserIds) {
+ const accessibleUsersIds = await getAccessibleUsers({
+ adminUserId: userId,
+ memberUserIds: requestedUserIds,
+ });
+
+ if (!accessibleUsersIds.length) throw new HttpError({ message: "No User found", statusCode: 404 });
+ const users = await prisma.user.findMany({
+ where: { id: { in: accessibleUsersIds } },
+ select: { email: true },
+ });
+ const userEmails = users.map((u) => u.email);
+ return { userId, userIds: accessibleUsersIds, userEmails };
+ } else {
+ const accessibleUsersIds = await retrieveOrgScopedAccessibleUsers({
+ adminId: userId,
+ });
+
+ const users = await prisma.user.findMany({
+ where: { id: { in: accessibleUsersIds } },
+ select: { email: true },
+ });
+ const userEmails = users.map((u) => u.email);
+ return { userId, userIds: accessibleUsersIds, userEmails };
+ }
+};
+
+export default withMiddleware("pagination")(defaultResponder(handler));
diff --git a/apps/api/v1/pages/api/bookings/_post.ts b/apps/api/v1/pages/api/bookings/_post.ts
index c94fe6c461bedb..919a7a2bfc5eab 100644
--- a/apps/api/v1/pages/api/bookings/_post.ts
+++ b/apps/api/v1/pages/api/bookings/_post.ts
@@ -2,8 +2,12 @@ import type { NextApiRequest } from "next";
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
+
/**
* @swagger
* /bookings:
@@ -204,10 +208,28 @@ import { defaultResponder } from "@calcom/lib/server";
* description: Authorization information is missing or invalid.
*/
async function handler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- if (isAdmin) req.userId = req.body.userId || userId;
+ const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
+ if (isSystemWideAdmin) req.userId = req.body.userId || userId;
+
+ if (isOrganizationOwnerOrAdmin) {
+ const accessibleUsersIds = await getAccessibleUsers({
+ adminUserId: userId,
+ memberUserIds: [req.body.userId || userId],
+ });
+ const [requestedUserId] = accessibleUsersIds;
+ req.userId = requestedUserId || userId;
+ }
+
+ try {
+ return await handleNewBooking(req, getBookingDataSchemaForApi);
+ } catch (error: unknown) {
+ const knownError = error as Error;
+ if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
+ throw new HttpError({ statusCode: 400, message: knownError.message });
+ }
- return await handleNewBooking(req, getBookingDataSchemaForApi);
+ throw error;
+ }
}
export default defaultResponder(handler);
diff --git a/apps/api/v1/pages/api/connected-calendars/_get.ts b/apps/api/v1/pages/api/connected-calendars/_get.ts
index 47085c33acb163..a6d5452af0bb4d 100644
--- a/apps/api/v1/pages/api/connected-calendars/_get.ts
+++ b/apps/api/v1/pages/api/connected-calendars/_get.ts
@@ -97,9 +97,10 @@ import { schemaConnectedCalendarsReadPublic } from "~/lib/validations/connected-
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
- if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
+ if (!isSystemWideAdmin && req.query.userId)
+ throw new HttpError({ statusCode: 403, message: "ADMIN required" });
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
diff --git a/apps/api/v1/pages/api/credential-sync/_patch.ts b/apps/api/v1/pages/api/credential-sync/_patch.ts
index ac7aac2ecb5ed2..c4cc8109afd21d 100644
--- a/apps/api/v1/pages/api/credential-sync/_patch.ts
+++ b/apps/api/v1/pages/api/credential-sync/_patch.ts
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
-import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
+import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
@@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
- const key = minimumTokenResponseSchema.parse(decryptedKey);
+ const key = OAuth2UniversalSchema.parse(decryptedKey);
const credential = await prisma.credential.update({
where: {
diff --git a/apps/api/v1/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts
index 6a6b7aebd982b6..32d74da2c10a62 100644
--- a/apps/api/v1/pages/api/credential-sync/_post.ts
+++ b/apps/api/v1/pages/api/credential-sync/_post.ts
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
-import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
+import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { HttpError } from "@calcom/lib/http-error";
@@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
- const key = minimumTokenResponseSchema.parse(decryptedKey);
+ const key = OAuth2UniversalSchema.parse(decryptedKey);
// Need to get app type
const app = await prisma.app.findUnique({
diff --git a/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts
index bc5888acbbf4e6..05243a39226175 100644
--- a/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Check if the current user can access the event type of this input
const eventTypeCustomInput = await prisma.eventTypeCustomInput.findFirst({
where: { id, eventType: { userId } },
diff --git a/apps/api/v1/pages/api/custom-inputs/_get.ts b/apps/api/v1/pages/api/custom-inputs/_get.ts
index 02e8909451184f..00259fa64943e4 100644
--- a/apps/api/v1/pages/api/custom-inputs/_get.ts
+++ b/apps/api/v1/pages/api/custom-inputs/_get.ts
@@ -29,8 +29,10 @@ import { schemaEventTypeCustomInputPublic } from "~/lib/validations/event-type-c
* description: No eventTypeCustomInputs were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- const args: Prisma.EventTypeCustomInputFindManyArgs = isAdmin ? {} : { where: { eventType: { userId } } };
+ const { userId, isSystemWideAdmin } = req;
+ const args: Prisma.EventTypeCustomInputFindManyArgs = isSystemWideAdmin
+ ? {}
+ : { 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/v1/pages/api/custom-inputs/_post.ts b/apps/api/v1/pages/api/custom-inputs/_post.ts
index cd6e04a6983c98..3c01c416de7cbe 100644
--- a/apps/api/v1/pages/api/custom-inputs/_post.ts
+++ b/apps/api/v1/pages/api/custom-inputs/_post.ts
@@ -80,10 +80,10 @@ import {
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { eventTypeId, ...body } = schemaEventTypeCustomInputBodyParams.parse(req.body);
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
/* We check that the user has access to the event type he's trying to add a custom input to. */
const eventType = await prisma.eventType.findFirst({
where: { id: eventTypeId, userId },
diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts
index 276cf44f446e1e..7878b05b91a0d4 100644
--- a/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts
@@ -6,9 +6,9 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
const userEventTypes = await prisma.eventType.findMany({
where: { userId },
select: { id: true },
diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts
index 75221c5192ee03..064634b2f22ac4 100644
--- a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts
@@ -83,10 +83,10 @@ type UserCredentialType = {
};
export async function patchHandler(req: NextApiRequest) {
- const { userId, isAdmin, query, body } = req;
+ const { userId, isSystemWideAdmin, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
- const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
+ const assignedUserId = isSystemWideAdmin ? parsedBody.userId || userId : userId;
validateIntegrationInput(parsedBody);
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
diff --git a/apps/api/v1/pages/api/destination-calendars/_post.ts b/apps/api/v1/pages/api/destination-calendars/_post.ts
index e8fdf266b2615c..1d8379335cf0da 100644
--- a/apps/api/v1/pages/api/destination-calendars/_post.ts
+++ b/apps/api/v1/pages/api/destination-calendars/_post.ts
@@ -61,11 +61,11 @@ import {
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin, body } = req;
+ const { userId, isSystemWideAdmin, body } = req;
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
await checkPermissions(req, userId);
- const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
+ const assignedUserId = isSystemWideAdmin && parsedBody.userId ? parsedBody.userId : userId;
/* Check if credentialId data matches the ownership and integration passed in */
const userCredentials = await prisma.credential.findMany({
@@ -120,19 +120,20 @@ async function postHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest, userId: number) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
const body = schemaDestinationCalendarCreateBodyParams.parse(req.body);
/* Non-admin users can only create destination calendars for themselves */
- if (!isAdmin && body.userId)
+ if (!isSystemWideAdmin && body.userId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `userId`",
});
/* Admin users are required to pass in a userId */
- if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" });
+ if (isSystemWideAdmin && !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) {
+ if (!isSystemWideAdmin && body.eventTypeId) {
const ownsEventType = await prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } });
if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
diff --git a/apps/api/v1/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts
index fc320d011b9ed7..06199655355bb8 100644
--- a/apps/api/v1/pages/api/docs.ts
+++ b/apps/api/v1/pages/api/docs.ts
@@ -27,6 +27,37 @@ const swaggerHandler = withSwagger({
$ref: "#/components/schemas/Booking",
},
},
+ ArrayOfRecordings: {
+ type: "array",
+ items: {
+ $ref: "#/components/schemas/Recording",
+ },
+ },
+ Recording: {
+ properties: {
+ id: {
+ type: "string",
+ },
+ room_name: {
+ type: "string",
+ },
+ start_ts: {
+ type: "number",
+ },
+ status: {
+ type: "string",
+ },
+ max_participants: {
+ type: "number",
+ },
+ duration: {
+ type: "number",
+ },
+ download_link: {
+ type: "string",
+ },
+ },
+ },
Booking: {
properties: {
id: {
@@ -57,6 +88,11 @@ const swaggerHandler = withSwagger({
type: "string",
example: "Europe/London",
},
+ fromReschedule: {
+ type: "string",
+ nullable: true,
+ format: "uuid",
+ },
attendees: {
type: "array",
items: {
diff --git a/apps/api/v1/pages/api/event-types/[id]/_delete.ts b/apps/api/v1/pages/api/event-types/[id]/_delete.ts
index 519047a127dabe..40a90db5f343a6 100644
--- a/apps/api/v1/pages/api/event-types/[id]/_delete.ts
+++ b/apps/api/v1/pages/api/event-types/[id]/_delete.ts
@@ -46,9 +46,9 @@ export async function deleteHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
/** Only event type owners can delete it */
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
diff --git a/apps/api/v1/pages/api/event-types/[id]/_get.ts b/apps/api/v1/pages/api/event-types/[id]/_get.ts
index e21c8bbe0f3237..6410a47b0f9d68 100644
--- a/apps/api/v1/pages/api/event-types/[id]/_get.ts
+++ b/apps/api/v1/pages/api/event-types/[id]/_get.ts
@@ -87,7 +87,7 @@ async function checkPermissions(
req: NextApiRequest,
eventType: (T & Partial>) | null
) {
- if (req.isAdmin) return true;
+ if (req.isSystemWideAdmin) return true;
if (eventType?.teamId) {
req.query.teamId = String(eventType.teamId);
await canAccessTeamEventOrThrow(req, {
diff --git a/apps/api/v1/pages/api/event-types/[id]/_patch.ts b/apps/api/v1/pages/api/event-types/[id]/_patch.ts
index 68b270324beefa..1e17c21d31ec44 100644
--- a/apps/api/v1/pages/api/event-types/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/event-types/[id]/_patch.ts
@@ -237,9 +237,9 @@ export async function patchHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest, body: z.infer) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
/** Only event type owners can modify it */
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
diff --git a/apps/api/v1/pages/api/event-types/_get.ts b/apps/api/v1/pages/api/event-types/_get.ts
index e57926d66e32bb..1db594350a6499 100644
--- a/apps/api/v1/pages/api/event-types/_get.ts
+++ b/apps/api/v1/pages/api/event-types/_get.ts
@@ -43,10 +43,10 @@ import getCalLink from "./_utils/getCalLink";
* description: No event types were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const { slug } = schemaQuerySlug.parse(req.query);
- const shouldUseUserId = !isAdmin || !slug || !!req.query.userId;
+ const shouldUseUserId = !isSystemWideAdmin || !slug || !!req.query.userId;
// When user is admin and no query params are provided we should return all event types.
// But currently we return only the event types of the user. Not changing this for backwards compatibility.
const data = await prisma.eventType.findMany({
@@ -74,9 +74,9 @@ async function getHandler(req: NextApiRequest) {
};
}
// TODO: Extract & reuse.
-function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
+function extractUserIdsFromQuery({ isSystemWideAdmin, query }: NextApiRequest) {
/** Guard: Only admins can query other users */
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
}
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
diff --git a/apps/api/v1/pages/api/event-types/_post.ts b/apps/api/v1/pages/api/event-types/_post.ts
index 19aec7ca338898..9f196385d2766b 100644
--- a/apps/api/v1/pages/api/event-types/_post.ts
+++ b/apps/api/v1/pages/api/event-types/_post.ts
@@ -265,7 +265,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin, body } = req;
+ const { userId, isSystemWideAdmin, body } = req;
const {
hosts = [],
@@ -291,7 +291,7 @@ async function postHandler(req: NextApiRequest) {
await checkUserMembership(req);
}
- if (isAdmin && parsedBody.userId) {
+ if (isSystemWideAdmin && parsedBody.userId) {
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
}
@@ -311,18 +311,18 @@ async function postHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
const body = schemaEventTypeCreateBodyParams.parse(req.body);
/* Non-admin users can only create event types for themselves */
- if (!isAdmin && body.userId)
+ if (!isSystemWideAdmin && body.userId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `userId`",
});
if (
body.teamId &&
- !isAdmin &&
- !(await canUserAccessTeamWithRole(req.userId, isAdmin, body.teamId, {
+ !isSystemWideAdmin &&
+ !(await canUserAccessTeamWithRole(req.userId, isSystemWideAdmin, body.teamId, {
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
}))
)
@@ -331,7 +331,7 @@ async function checkPermissions(req: NextApiRequest) {
message: "ADMIN required for `teamId`",
});
/* Admin users are required to pass in a userId or teamId */
- if (isAdmin && !body.userId && !body.teamId)
+ if (isSystemWideAdmin && !body.userId && !body.teamId)
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
}
diff --git a/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts
index 1055dc0d327154..76c7cba3a7d3a4 100644
--- a/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts
+++ b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts
@@ -10,9 +10,9 @@ export default async function checkTeamEventEditPermission(
req: NextApiRequest,
body: Pick, "teamId" | "userId">
) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
let userId = req.userId;
- if (isAdmin && body.userId) {
+ if (isSystemWideAdmin && body.userId) {
userId = body.userId;
}
if (body.teamId) {
diff --git a/apps/api/v1/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts
index 94703111a32ad0..7bc2a18d7447f2 100644
--- a/apps/api/v1/pages/api/invites/_post.ts
+++ b/apps/api/v1/pages/api/invites/_post.ts
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
language: data.language,
teamId: data.teamId,
usernameOrEmail: data.usernameOrEmail,
- isOrg: data.isOrg,
});
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
@@ -58,10 +57,10 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
}
async function checkPermissions(req: NextApiRequest, body: TInviteMemberInputSchema) {
- const { userId, isAdmin } = req;
- if (isAdmin) return;
+ const { userId, isSystemWideAdmin } = req;
+ if (isSystemWideAdmin) return;
// To prevent auto-accepted invites, limit it to ADMIN users
- if (!isAdmin && "accepted" in body)
+ if (!isSystemWideAdmin && "accepted" in body)
throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" });
// Only team OWNERS and ADMINS can add other members
const membership = await prisma.membership.findFirst({
diff --git a/apps/api/v1/pages/api/me/_get.ts b/apps/api/v1/pages/api/me/_get.ts
index 637ebc1bb7bb20..d7887a15da00de 100644
--- a/apps/api/v1/pages/api/me/_get.ts
+++ b/apps/api/v1/pages/api/me/_get.ts
@@ -7,7 +7,12 @@ import { schemaUserReadPublic } from "~/lib/validations/user";
async function handler({ userId }: NextApiRequest) {
const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
- return { user: schemaUserReadPublic.parse(data) };
+ return {
+ user: schemaUserReadPublic.parse({
+ ...data,
+ avatar: data.avatarUrl,
+ }),
+ };
}
export default defaultResponder(handler);
diff --git a/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts
index e5bb676583507f..f14317eae39305 100644
--- a/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { membershipIdSchema } from "~/lib/validations/membership";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { teamId } = membershipIdSchema.parse(req.query);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Only team members can modify a membership
const membership = await prisma.membership.findFirst({ where: { userId, teamId } });
if (!membership) throw new HttpError({ statusCode: 403, message: "Forbidden" });
diff --git a/apps/api/v1/pages/api/memberships/[id]/_delete.ts b/apps/api/v1/pages/api/memberships/[id]/_delete.ts
index 1e624f3adc84e2..e6c39eb4821efd 100644
--- a/apps/api/v1/pages/api/memberships/[id]/_delete.ts
+++ b/apps/api/v1/pages/api/memberships/[id]/_delete.ts
@@ -43,10 +43,10 @@ export async function deleteHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { isAdmin, userId, query } = req;
+ const { isSystemWideAdmin, 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) {
+ if (isSystemWideAdmin) {
return;
}
diff --git a/apps/api/v1/pages/api/memberships/[id]/_patch.ts b/apps/api/v1/pages/api/memberships/[id]/_patch.ts
index 3e7dcaffb945cb..f44e573922b790 100644
--- a/apps/api/v1/pages/api/memberships/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/memberships/[id]/_patch.ts
@@ -52,11 +52,11 @@ export async function patchHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { userId: queryUserId, teamId } = membershipIdSchema.parse(req.query);
const data = membershipEditBodySchema.parse(req.body);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Only the invited user can accept the invite
if ("accepted" in data && queryUserId !== userId)
throw new HttpError({
diff --git a/apps/api/v1/pages/api/memberships/_get.ts b/apps/api/v1/pages/api/memberships/_get.ts
index 8a42a5dd3af68c..c175718a8b41e9 100644
--- a/apps/api/v1/pages/api/memberships/_get.ts
+++ b/apps/api/v1/pages/api/memberships/_get.ts
@@ -46,26 +46,28 @@ async function getHandler(req: NextApiRequest) {
* Returns requested users IDs only if admin, otherwise return only current user ID
*/
function getUserIds(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
/** Only admins can query other users */
- if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
- if (isAdmin && req.query.userId) {
+ if (!isSystemWideAdmin && req.query.userId)
+ throw new HttpError({ statusCode: 403, message: "ADMIN required" });
+ if (isSystemWideAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
return userIds;
}
// Return all memberships for ADMIN, limit to current user to non-admins
- return isAdmin ? undefined : [userId];
+ return isSystemWideAdmin ? undefined : [userId];
}
/**
* Returns requested teams IDs only if admin
*/
function getTeamIds(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
/** Only admins can query other teams */
- if (!isAdmin && req.query.teamId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
- if (isAdmin && req.query.teamId) {
+ if (!isSystemWideAdmin && req.query.teamId)
+ throw new HttpError({ statusCode: 403, message: "ADMIN required" });
+ if (isSystemWideAdmin && req.query.teamId) {
const query = schemaQuerySingleOrMultipleTeamIds.parse(req.query);
const teamIds = Array.isArray(query.teamId) ? query.teamId : [query.teamId];
return teamIds;
diff --git a/apps/api/v1/pages/api/memberships/_post.ts b/apps/api/v1/pages/api/memberships/_post.ts
index e1ee1a622c12bf..9c4b1af12ef483 100644
--- a/apps/api/v1/pages/api/memberships/_post.ts
+++ b/apps/api/v1/pages/api/memberships/_post.ts
@@ -37,11 +37,11 @@ async function postHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- if (isAdmin) return;
+ const { userId, isSystemWideAdmin } = req;
+ if (isSystemWideAdmin) return;
const body = membershipCreateBodySchema.parse(req.body);
// To prevent auto-accepted invites, limit it to ADMIN users
- if (!isAdmin && "accepted" in body)
+ if (!isSystemWideAdmin && "accepted" in body)
throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" });
// Only team OWNERS and ADMINS can add other members
const membership = await prisma.membership.findFirst({
diff --git a/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts
index d622dfcbf68b94..ef44111c2ea268 100644
--- a/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Check if the current user can access the schedule
const schedule = await prisma.schedule.findFirst({
where: { id, userId },
diff --git a/apps/api/v1/pages/api/schedules/[id]/_patch.ts b/apps/api/v1/pages/api/schedules/[id]/_patch.ts
index f9009e30bfb3d5..c9236eff8496a2 100644
--- a/apps/api/v1/pages/api/schedules/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/schedules/[id]/_patch.ts
@@ -91,8 +91,8 @@ export async function patchHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest, body: z.infer) {
- const { isAdmin } = req;
- if (isAdmin) return;
+ const { isSystemWideAdmin } = req;
+ if (isSystemWideAdmin) return;
if (body.userId) {
throw new HttpError({ statusCode: 403, message: "Non admin cannot change the owner of a schedule" });
}
diff --git a/apps/api/v1/pages/api/schedules/_get.ts b/apps/api/v1/pages/api/schedules/_get.ts
index bbe893516e5796..d70c64aecaaf9a 100644
--- a/apps/api/v1/pages/api/schedules/_get.ts
+++ b/apps/api/v1/pages/api/schedules/_get.ts
@@ -77,17 +77,17 @@ export const schemaUserIds = z
*/
async function handler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- const args: Prisma.ScheduleFindManyArgs = isAdmin ? {} : { where: { userId } };
+ const { userId, isSystemWideAdmin } = req;
+ const args: Prisma.ScheduleFindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
args.include = { availability: true };
- if (!isAdmin && req.query.userId)
+ if (!isSystemWideAdmin && req.query.userId)
throw new HttpError({
statusCode: 401,
message: "Unauthorized: Only admins can query other users",
});
- if (isAdmin && req.query.userId) {
+ if (isSystemWideAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
args.where = { userId: { in: userIds } };
diff --git a/apps/api/v1/pages/api/schedules/_post.ts b/apps/api/v1/pages/api/schedules/_post.ts
index 05215e3d47a896..8b8de204726f84 100644
--- a/apps/api/v1/pages/api/schedules/_post.ts
+++ b/apps/api/v1/pages/api/schedules/_post.ts
@@ -80,14 +80,14 @@ import { schemaCreateScheduleBodyParams, schemaSchedulePublic } from "~/lib/vali
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const body = schemaCreateScheduleBodyParams.parse(req.body);
let args: Prisma.ScheduleCreateArgs = { data: { ...body, userId } };
/* If ADMIN we create the schedule for selected user */
- if (isAdmin && body.userId) args = { data: { ...body, userId: body.userId } };
+ if (isSystemWideAdmin && body.userId) args = { data: { ...body, userId: body.userId } };
- if (!isAdmin && body.userId)
+ if (!isSystemWideAdmin && body.userId)
throw new HttpError({ statusCode: 403, message: "ADMIN required for `userId`" });
// We create default availabilities for the schedule
diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts
index 620ba818fb3418..09ce0c39407a70 100644
--- a/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts
@@ -5,10 +5,10 @@ import { HttpError } from "@calcom/lib/http-error";
import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { userId: queryUserId } = selectedCalendarIdSchema.parse(req.query);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Check if the current user requesting is the same as the one being requested
if (userId !== queryUserId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
index 84b60d12106b0c..602e85b8ff8db4 100644
--- a/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
@@ -53,14 +53,15 @@ import {
* description: Authorization information is missing or invalid.
*/
export async function patchHandler(req: NextApiRequest) {
- const { query, isAdmin } = req;
+ const { query, isSystemWideAdmin } = 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 };
- if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
+ if (!isSystemWideAdmin && bodyUserId)
+ throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
- if (isAdmin && bodyUserId) {
+ if (isSystemWideAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: bodyUserId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = bodyUserId;
diff --git a/apps/api/v1/pages/api/selected-calendars/_get.ts b/apps/api/v1/pages/api/selected-calendars/_get.ts
index 1d4bdf9d6bf48f..254d3428e24252 100644
--- a/apps/api/v1/pages/api/selected-calendars/_get.ts
+++ b/apps/api/v1/pages/api/selected-calendars/_get.ts
@@ -32,13 +32,14 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* description: No selected calendars were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
/* Admin gets all selected calendar by default, otherwise only the user's ones */
- const args: Prisma.SelectedCalendarFindManyArgs = isAdmin ? {} : { where: { userId } };
+ const args: Prisma.SelectedCalendarFindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
/** Only admins can query other users */
- if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
- if (isAdmin && req.query.userId) {
+ if (!isSystemWideAdmin && req.query.userId)
+ throw new HttpError({ statusCode: 403, message: "ADMIN required" });
+ if (isSystemWideAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
args.where = { userId: { in: userIds } };
diff --git a/apps/api/v1/pages/api/selected-calendars/_post.ts b/apps/api/v1/pages/api/selected-calendars/_post.ts
index d0509df9c1cebb..5d0a419883eb8c 100644
--- a/apps/api/v1/pages/api/selected-calendars/_post.ts
+++ b/apps/api/v1/pages/api/selected-calendars/_post.ts
@@ -50,13 +50,14 @@ import {
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body);
const args: Prisma.SelectedCalendarCreateArgs = { data: { ...body, userId } };
- if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
+ if (!isSystemWideAdmin && bodyUserId)
+ throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
- if (isAdmin && bodyUserId) {
+ if (isSystemWideAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: bodyUserId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = bodyUserId;
diff --git a/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts
index 693eb544a544db..f98cfbef8a420c 100644
--- a/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts
@@ -8,10 +8,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { schemaQueryTeamId } from "~/lib/validations/shared/queryTeamId";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { teamId } = schemaQueryTeamId.parse(req.query);
/** Admins can skip the ownership verification */
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
/** Non-members will see a 404 error which may or not be the desired behavior. */
await prisma.team.findFirstOrThrow({
where: { id: teamId, members: { some: { userId } } },
@@ -22,24 +22,24 @@ export async function checkPermissions(
req: NextApiRequest,
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { teamId } = schemaQueryTeamId.parse({
teamId: req.query.teamId,
version: req.query.version,
apiKey: req.query.apiKey,
});
- return canUserAccessTeamWithRole(userId, isAdmin, teamId, role);
+ return canUserAccessTeamWithRole(userId, isSystemWideAdmin, teamId, role);
}
export async function canUserAccessTeamWithRole(
userId: number,
- isAdmin: boolean,
+ isSystemWideAdmin: boolean,
teamId: number,
role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER
) {
const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } };
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
- if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
+ if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
const team = await prisma.team.findFirst(args);
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
return team;
diff --git a/apps/api/v1/pages/api/teams/[teamId]/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/_get.ts
index 829a4104b60354..2e76910f9eb991 100644
--- a/apps/api/v1/pages/api/teams/[teamId]/_get.ts
+++ b/apps/api/v1/pages/api/teams/[teamId]/_get.ts
@@ -37,11 +37,11 @@ import { schemaTeamReadPublic } from "~/lib/validations/team";
* description: Team was not found
*/
export async function getHandler(req: NextApiRequest) {
- const { isAdmin, userId } = req;
+ const { isSystemWideAdmin, 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
- if (!isAdmin) where.members = { some: { userId } };
+ if (!isSystemWideAdmin) where.members = { some: { userId } };
const data = await prisma.team.findFirstOrThrow({ where });
return { team: schemaTeamReadPublic.parse(data) };
}
diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts
index 2e1fcc5ca92f53..36737f24a7e070 100644
--- a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts
+++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts
@@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) {
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
- seats: _team.members.length,
+ seatsUsed: _team.members.length,
userId,
pricePerSeat: null,
});
diff --git a/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts
index 5ef33379c0ce9a..bd17989b193aa0 100644
--- a/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts
+++ b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts
@@ -42,13 +42,13 @@ const querySchema = z.object({
* description: No event types were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { teamId } = querySchema.parse(req.query);
const args: Prisma.EventTypeFindManyArgs = {
where: {
- team: isAdmin
+ team: isSystemWideAdmin
? {
id: teamId,
}
diff --git a/apps/api/v1/pages/api/teams/[teamId]/publish.ts b/apps/api/v1/pages/api/teams/[teamId]/publish.ts
index 781a42387c846d..d23eb57b38d627 100644
--- a/apps/api/v1/pages/api/teams/[teamId]/publish.ts
+++ b/apps/api/v1/pages/api/teams/[teamId]/publish.ts
@@ -21,7 +21,7 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
user: {
id: req.userId,
username: "" /* Not used in this context */,
- role: req.isAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER,
+ role: req.isSystemWideAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER,
profile: {
id: null,
organizationId: null,
diff --git a/apps/api/v1/pages/api/teams/_get.ts b/apps/api/v1/pages/api/teams/_get.ts
index c5ebe8cf081a48..4b28a07b394a53 100644
--- a/apps/api/v1/pages/api/teams/_get.ts
+++ b/apps/api/v1/pages/api/teams/_get.ts
@@ -30,10 +30,10 @@ import { schemaTeamsReadPublic } from "~/lib/validations/team";
* description: No teams were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const where: Prisma.TeamWhereInput = {};
// If user is not ADMIN, return only his data.
- if (!isAdmin) where.members = { some: { userId } };
+ if (!isSystemWideAdmin) where.members = { some: { userId } };
const data = await prisma.team.findMany({ where });
return { teams: schemaTeamsReadPublic.parse(data) };
}
diff --git a/apps/api/v1/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts
index 8fcc00fe2211f7..32722a4602887c 100644
--- a/apps/api/v1/pages/api/teams/_post.ts
+++ b/apps/api/v1/pages/api/teams/_post.ts
@@ -80,12 +80,12 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { body, userId, isAdmin } = req;
+ const { body, userId, isSystemWideAdmin } = req;
const { ownerId, ...data } = schemaTeamCreateBodyParams.parse(body);
await checkPermissions(req);
- const effectiveUserId = isAdmin && ownerId ? ownerId : userId;
+ const effectiveUserId = isSystemWideAdmin && ownerId ? ownerId : userId;
if (data.slug) {
const alreadyExist = await prisma.team.findFirst({
@@ -162,11 +162,11 @@ async function postHandler(req: NextApiRequest) {
}
async function checkPermissions(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
const body = schemaTeamCreateBodyParams.parse(req.body);
/* Non-admin users can only create teams for themselves */
- if (!isAdmin && body.ownerId)
+ if (!isSystemWideAdmin && body.ownerId)
throw new HttpError({
statusCode: 401,
message: "ADMIN required for `ownerId`",
diff --git a/apps/api/v1/pages/api/users/[userId]/_delete.ts b/apps/api/v1/pages/api/users/[userId]/_delete.ts
index 90d38aad366b83..436c1fdf0e7e22 100644
--- a/apps/api/v1/pages/api/users/[userId]/_delete.ts
+++ b/apps/api/v1/pages/api/users/[userId]/_delete.ts
@@ -38,10 +38,11 @@ import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
* description: Authorization information is missing or invalid.
*/
export async function deleteHandler(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = 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" });
+ if (!isSystemWideAdmin && query.userId !== req.userId)
+ throw new HttpError({ statusCode: 403, message: "Forbidden" });
const user = await prisma.user.findUnique({
where: { id: query.userId },
diff --git a/apps/api/v1/pages/api/users/[userId]/_get.ts b/apps/api/v1/pages/api/users/[userId]/_get.ts
index 215cf8173561a1..19b9a6c293a613 100644
--- a/apps/api/v1/pages/api/users/[userId]/_get.ts
+++ b/apps/api/v1/pages/api/users/[userId]/_get.ts
@@ -38,13 +38,17 @@ import { schemaUserReadPublic } from "~/lib/validations/user";
* description: User was not found
*/
export async function getHandler(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = 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" });
+ if (!isSystemWideAdmin && query.userId !== req.userId)
+ throw new HttpError({ statusCode: 403, message: "Forbidden" });
const data = await prisma.user.findUnique({ where: { id: query.userId } });
- const user = schemaUserReadPublic.parse(data);
+ const user = schemaUserReadPublic.parse({
+ ...data,
+ avatar: data?.avatarUrl,
+ });
return { user };
}
diff --git a/apps/api/v1/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts
index e622a43114f74f..cd7ecceaeee4e6 100644
--- a/apps/api/v1/pages/api/users/[userId]/_patch.ts
+++ b/apps/api/v1/pages/api/users/[userId]/_patch.ts
@@ -2,7 +2,9 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
+import { uploadAvatar } from "@calcom/lib/server/avatar";
import prisma from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user";
@@ -95,14 +97,16 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
* description: Insufficient permissions to access resource.
*/
export async function patchHandler(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = 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" });
+ if (!isSystemWideAdmin && query.userId !== req.userId)
+ throw new HttpError({ statusCode: 403, message: "Forbidden" });
- const body = await schemaUserEditBodyParams.parseAsync(req.body);
+ const { avatar, ...body }: { avatar?: string | undefined } & Prisma.UserUpdateInput =
+ await schemaUserEditBodyParams.parseAsync(req.body);
// disable role or branding changes unless admin.
- if (!isAdmin) {
+ if (!isSystemWideAdmin) {
if (body.role) body.role = undefined;
if (body.hideBranding) body.hideBranding = undefined;
}
@@ -118,6 +122,14 @@ export async function patchHandler(req: NextApiRequest) {
message: "Bad request: Invalid default schedule id",
});
}
+
+ if (avatar) {
+ body.avatarUrl = await uploadAvatar({
+ userId: query.userId,
+ avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
+ });
+ }
+
const data = await prisma.user.update({
where: { id: query.userId },
data: body,
diff --git a/apps/api/v1/pages/api/users/_get.ts b/apps/api/v1/pages/api/users/_get.ts
index dcb26d70b68aa0..81761e90ccec7b 100644
--- a/apps/api/v1/pages/api/users/_get.ts
+++ b/apps/api/v1/pages/api/users/_get.ts
@@ -45,12 +45,12 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
export async function getHandler(req: NextApiRequest) {
const {
userId,
- isAdmin,
+ isSystemWideAdmin,
pagination: { take, skip },
} = req;
const where: Prisma.UserWhereInput = {};
// If user is not ADMIN, return only his data.
- if (!isAdmin) where.id = userId;
+ if (!isSystemWideAdmin) where.id = userId;
if (req.query.email) {
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
diff --git a/apps/api/v1/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts
index 9b23ebeca5bb07..a8e3fbfae9a6ec 100644
--- a/apps/api/v1/pages/api/users/_post.ts
+++ b/apps/api/v1/pages/api/users/_post.ts
@@ -88,9 +88,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { isAdmin } = req;
+ const { isSystemWideAdmin } = req;
// If user is not ADMIN, return unauthorized.
- if (!isAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" });
+ if (!isSystemWideAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" });
const data = await schemaUserCreateBodyParams.parseAsync(req.body);
const user = await prisma.user.create({ data });
req.statusCode = 201;
diff --git a/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts
index ce45765eaa0770..5598f3ed8b0aa5 100644
--- a/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts
+++ b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts
@@ -6,10 +6,10 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
async function authMiddleware(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdAsString.parse(req.query);
// Admins can just skip this check
- if (isAdmin) return;
+ if (isSystemWideAdmin) return;
// Check if the current user can access the webhook
const webhook = await prisma.webhook.findFirst({
where: { id, appId: null, OR: [{ userId }, { eventType: { team: { members: { some: { userId } } } } }] },
diff --git a/apps/api/v1/pages/api/webhooks/[id]/_patch.ts b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts
index f41f7751505263..7d749b4a61b764 100644
--- a/apps/api/v1/pages/api/webhooks/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts
@@ -68,7 +68,7 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
* description: Authorization information is missing or invalid.
*/
export async function patchHandler(req: NextApiRequest) {
- const { query, userId, isAdmin } = req;
+ const { query, userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdAsString.parse(query);
const {
eventTypeId,
@@ -80,14 +80,15 @@ export async function patchHandler(req: NextApiRequest) {
if (eventTypeId) {
const where: Prisma.EventTypeWhereInput = { id: eventTypeId };
- if (!isAdmin) where.userId = userId;
+ if (!isSystemWideAdmin) where.userId = userId;
await prisma.eventType.findFirstOrThrow({ where });
args.data.eventTypeId = eventTypeId;
}
- if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
+ if (!isSystemWideAdmin && bodyUserId)
+ throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
- if (isAdmin && bodyUserId) {
+ if (isSystemWideAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: bodyUserId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = bodyUserId;
diff --git a/apps/api/v1/pages/api/webhooks/_get.ts b/apps/api/v1/pages/api/webhooks/_get.ts
index 79b712e742d5a0..1d1e5b02dccef1 100644
--- a/apps/api/v1/pages/api/webhooks/_get.ts
+++ b/apps/api/v1/pages/api/webhooks/_get.ts
@@ -34,14 +34,15 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook";
* description: No webhooks were found
*/
async function getHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
- const args: Prisma.WebhookFindManyArgs = isAdmin
+ const { userId, isSystemWideAdmin } = req;
+ const args: Prisma.WebhookFindManyArgs = isSystemWideAdmin
? {}
: { where: { OR: [{ eventType: { userId } }, { userId }] } };
/** Only admins can query other users */
- if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
- if (isAdmin && req.query.userId) {
+ if (!isSystemWideAdmin && req.query.userId)
+ throw new HttpError({ statusCode: 403, message: "ADMIN required" });
+ if (isSystemWideAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
args.where = { OR: [{ eventType: { userId: { in: userIds } } }, { userId: { in: userIds } }] };
diff --git a/apps/api/v1/pages/api/webhooks/_post.ts b/apps/api/v1/pages/api/webhooks/_post.ts
index 36e470e0c9eb9f..29a78cf580e018 100644
--- a/apps/api/v1/pages/api/webhooks/_post.ts
+++ b/apps/api/v1/pages/api/webhooks/_post.ts
@@ -66,7 +66,7 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
- const { userId, isAdmin } = req;
+ const { userId, isSystemWideAdmin } = req;
const {
eventTypeId,
userId: bodyUserId,
@@ -80,14 +80,15 @@ async function postHandler(req: NextApiRequest) {
if (eventTypeId) {
const where: Prisma.EventTypeWhereInput = { id: eventTypeId };
- if (!isAdmin) where.userId = userId;
+ if (!isSystemWideAdmin) where.userId = userId;
await prisma.eventType.findFirstOrThrow({ where });
args.data.eventTypeId = eventTypeId;
}
- if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
+ if (!isSystemWideAdmin && bodyUserId)
+ throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
- if (isAdmin && bodyUserId) {
+ if (isSystemWideAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: bodyUserId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = bodyUserId;
diff --git a/apps/api/v1/sentry.edge.config.ts b/apps/api/v1/sentry.edge.config.ts
deleted file mode 100644
index 0e1d2cbecdb1d1..00000000000000
--- a/apps/api/v1/sentry.edge.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { init as SentryInit } from "@sentry/nextjs";
-
-SentryInit({
- dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
-});
diff --git a/apps/api/v1/sentry.server.config.ts b/apps/api/v1/sentry.server.config.ts
deleted file mode 100644
index 0798e21c3581b9..00000000000000
--- a/apps/api/v1/sentry.server.config.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { init as SentryInit } from "@sentry/nextjs";
-
-SentryInit({
- dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
- tracesSampleRate: 1.0,
-});
diff --git a/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts
new file mode 100644
index 00000000000000..5554d6411d2a4a
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts
@@ -0,0 +1,87 @@
+import type { Request, Response } from "express";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { createMocks } from "node-mocks-http";
+import { describe, it, expect } from "vitest";
+
+import prisma from "@calcom/prisma";
+
+import handler from "../../../../pages/api/bookings/[id]/_patch";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+describe("PATCH /api/bookings", () => {
+ it("Returns 403 when user has no permission to the booking", async () => {
+ const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ const { req, res } = createMocks({
+ method: "PATCH",
+ body: {
+ title: booking.title,
+ startTime: booking.startTime.toISOString(),
+ endTime: booking.endTime.toISOString(),
+ userId: memberUser.id,
+ },
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = memberUser.id;
+
+ await handler(req, res);
+ expect(res.statusCode).toBe(403);
+ });
+
+ it("Allows PATCH when user is system-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ const { req, res } = createMocks({
+ method: "PATCH",
+ body: {
+ title: booking.title,
+ startTime: booking.startTime.toISOString(),
+ endTime: booking.endTime.toISOString(),
+ userId: proUser.id,
+ },
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = adminUser.id;
+ req.isSystemWideAdmin = true;
+
+ await handler(req, res);
+ expect(res.statusCode).toBe(200);
+ });
+
+ it("Allows PATCH when user is org-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
+
+ const { req, res } = createMocks({
+ method: "PATCH",
+ body: {
+ title: booking.title,
+ startTime: booking.startTime.toISOString(),
+ endTime: booking.endTime.toISOString(),
+ userId: memberUser.id,
+ },
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = adminUser.id;
+ req.isOrganizationOwnerOrAdmin = true;
+
+ await handler(req, res);
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts
new file mode 100644
index 00000000000000..7a0cc731bd9563
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts
@@ -0,0 +1,136 @@
+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, vi, afterEach } from "vitest";
+
+import {
+ getRecordingsOfCalVideoByRoomName,
+ getDownloadLinkOfCalVideoByRecordingId,
+} from "@calcom/core/videoClient";
+import { buildBooking } from "@calcom/lib/test/builder";
+
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
+
+import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
+import handler from "../../../../../pages/api/bookings/[id]/recordings/_get";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+const adminUserId = 1;
+const memberUserId = 10;
+
+vi.mock("@calcom/core/videoClient", () => {
+ return {
+ getRecordingsOfCalVideoByRoomName: vi.fn(),
+ getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
+ };
+});
+
+vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
+ return {
+ getAccessibleUsers: vi.fn(),
+ };
+});
+
+afterEach(() => {
+ vi.resetAllMocks();
+});
+
+const mockGetRecordingsAndDownloadLink = () => {
+ const download_link = "https://URL";
+ const recordingItem = {
+ id: "TEST_ID",
+ room_name: "0n22w24AQ5ZFOtEKX2gX",
+ start_ts: 1716215386,
+ status: "finished",
+ max_participants: 1,
+ duration: 11,
+ share_token: "TEST_TOKEN",
+ };
+
+ vi.mocked(getRecordingsOfCalVideoByRoomName).mockResolvedValue({ data: [recordingItem], total_count: 1 });
+
+ vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
+ download_link,
+ });
+
+ return [{ ...recordingItem, download_link }];
+};
+
+describe("GET /api/bookings/[id]/recordings", () => {
+ test("Returns recordings if user is system-wide admin", async () => {
+ const userId = 2;
+
+ const bookingId = 1111;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId,
+ references: [
+ {
+ id: 1,
+ type: "daily_video",
+ uid: "17OHkCH53pBa03FhxMbw",
+ meetingId: "17OHkCH53pBa03FhxMbw",
+ meetingPassword: "password",
+ meetingUrl: "https://URL",
+ },
+ ],
+ })
+ );
+
+ const mockedRecordings = mockGetRecordingsAndDownloadLink();
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ },
+ });
+
+ req.isSystemWideAdmin = true;
+ req.userId = adminUserId;
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(JSON.parse(res._getData())).toEqual(mockedRecordings);
+ });
+
+ test("Allows GET recordings when user is org-wide admin", async () => {
+ const bookingId = 3333;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId: memberUserId,
+ references: [
+ { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
+ ],
+ })
+ );
+
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ },
+ });
+
+ req.userId = adminUserId;
+ req.isOrganizationOwnerOrAdmin = true;
+ const mockedRecordings = mockGetRecordingsAndDownloadLink();
+ vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts
new file mode 100644
index 00000000000000..c6320fc9e711b0
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/[recordingId]/_get.test.ts
@@ -0,0 +1,129 @@
+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, vi, afterEach } from "vitest";
+
+import {
+ getTranscriptsAccessLinkFromRecordingId,
+ checkIfRoomNameMatchesInRecording,
+} from "@calcom/core/videoClient";
+import { buildBooking } from "@calcom/lib/test/builder";
+
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
+
+import authMiddleware from "../../../../../../pages/api/bookings/[id]/_auth-middleware";
+import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[recordingId]/_get";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+vi.mock("@calcom/core/videoClient", () => {
+ return {
+ getTranscriptsAccessLinkFromRecordingId: vi.fn(),
+ checkIfRoomNameMatchesInRecording: vi.fn(),
+ };
+});
+
+vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
+ return {
+ getAccessibleUsers: vi.fn(),
+ };
+});
+
+afterEach(() => {
+ vi.resetAllMocks();
+});
+
+const mockGetTranscripts = () => {
+ const downloadLinks = [{ format: "json", link: "https://URL1" }];
+
+ vi.mocked(getTranscriptsAccessLinkFromRecordingId).mockResolvedValue(downloadLinks);
+ vi.mocked(checkIfRoomNameMatchesInRecording).mockResolvedValue(true);
+
+ return downloadLinks;
+};
+
+const recordingId = "abc-xyz";
+
+describe("GET /api/bookings/[id]/transcripts/[recordingId]", () => {
+ test("Returns transcripts if user is system-wide admin", async () => {
+ const adminUserId = 1;
+ const userId = 2;
+
+ const bookingId = 1111;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId,
+ references: [
+ {
+ id: 1,
+ type: "daily_video",
+ uid: "17OHkCH53pBa03FhxMbw",
+ meetingId: "17OHkCH53pBa03FhxMbw",
+ meetingPassword: "password",
+ meetingUrl: "https://URL",
+ },
+ ],
+ })
+ );
+
+ const mockedTranscripts = mockGetTranscripts();
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ recordingId,
+ },
+ });
+
+ req.isSystemWideAdmin = true;
+ req.userId = adminUserId;
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
+ });
+
+ test("Allows GET transcripts when user is org-wide admin", async () => {
+ const adminUserId = 1;
+ const memberUserId = 10;
+ const bookingId = 3333;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId: memberUserId,
+ references: [
+ { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
+ ],
+ })
+ );
+
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ recordingId,
+ },
+ });
+
+ req.userId = adminUserId;
+ req.isOrganizationOwnerOrAdmin = true;
+ mockGetTranscripts();
+
+ vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts
new file mode 100644
index 00000000000000..a821935bd853d6
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts
@@ -0,0 +1,120 @@
+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, vi, afterEach } from "vitest";
+
+import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
+import { buildBooking } from "@calcom/lib/test/builder";
+
+import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
+
+import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
+import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+vi.mock("@calcom/core/videoClient", () => {
+ return {
+ getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
+ };
+});
+
+vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
+ return {
+ getAccessibleUsers: vi.fn(),
+ };
+});
+
+afterEach(() => {
+ vi.resetAllMocks();
+});
+
+const mockGetTranscripts = () => {
+ const downloadLinks = ["https://URL1", "https://URL2"];
+
+ vi.mocked(getAllTranscriptsAccessLinkFromRoomName).mockResolvedValue(downloadLinks);
+
+ return downloadLinks;
+};
+
+describe("GET /api/bookings/[id]/transcripts", () => {
+ test("Returns transcripts if user is system-wide admin", async () => {
+ const adminUserId = 1;
+ const userId = 2;
+
+ const bookingId = 1111;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId,
+ references: [
+ {
+ id: 1,
+ type: "daily_video",
+ uid: "17OHkCH53pBa03FhxMbw",
+ meetingId: "17OHkCH53pBa03FhxMbw",
+ meetingPassword: "password",
+ meetingUrl: "https://URL",
+ },
+ ],
+ })
+ );
+
+ const mockedTranscripts = mockGetTranscripts();
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ },
+ });
+
+ req.isSystemWideAdmin = true;
+ req.userId = adminUserId;
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
+ });
+
+ test("Allows GET transcripts when user is org-wide admin", async () => {
+ const adminUserId = 1;
+ const memberUserId = 10;
+ const bookingId = 3333;
+
+ prismaMock.booking.findUnique.mockResolvedValue(
+ buildBooking({
+ id: bookingId,
+ userId: memberUserId,
+ references: [
+ { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
+ ],
+ })
+ );
+
+ const { req, res } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: bookingId,
+ },
+ });
+
+ req.userId = adminUserId;
+ req.isOrganizationOwnerOrAdmin = true;
+ mockGetTranscripts();
+
+ vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
+
+ await authMiddleware(req);
+ await handler(req, res);
+
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts b/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts
new file mode 100644
index 00000000000000..b0fd7d888e05f8
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/_auth-middleware.integration-test.ts
@@ -0,0 +1,92 @@
+import type { Request, Response } from "express";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { createMocks } from "node-mocks-http";
+import { describe, it, expect } from "vitest";
+
+import type { HttpError } from "@calcom/lib/http-error";
+import prisma from "@calcom/prisma";
+
+import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+describe("Bookings auth middleware", () => {
+ it("Returns 403 when user has no permission to the booking", async () => {
+ const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ const { req } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = trialUser.id;
+
+ try {
+ await authMiddleware(req);
+ } catch (error) {
+ const httpError = error as HttpError;
+ expect(httpError.statusCode).toBe(403);
+ }
+ });
+
+ it("No error is thrown when user is the booking user", async () => {
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ const { req } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = proUser.id;
+
+ await authMiddleware(req);
+ });
+
+ it("No error is thrown when user is system-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ const { req } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = adminUser.id;
+ req.isSystemWideAdmin = true;
+
+ await authMiddleware(req);
+ });
+
+ it("No error is thrown when user is org-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
+ const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
+
+ const { req } = createMocks({
+ method: "GET",
+ body: {},
+ query: {
+ id: booking.id,
+ },
+ });
+
+ req.userId = adminUser.id;
+ req.isOrganizationOwnerOrAdmin = true;
+
+ await authMiddleware(req);
+ });
+});
diff --git a/apps/api/v1/test/lib/bookings/_get.integration-test.ts b/apps/api/v1/test/lib/bookings/_get.integration-test.ts
new file mode 100644
index 00000000000000..7687f5e6c8f8fc
--- /dev/null
+++ b/apps/api/v1/test/lib/bookings/_get.integration-test.ts
@@ -0,0 +1,108 @@
+import type { Request, Response } from "express";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { createMocks } from "node-mocks-http";
+import { describe, expect, it } from "vitest";
+
+import prisma from "@calcom/prisma";
+
+import { handler } from "../../../pages/api/bookings/_get";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+const DefaultPagination = {
+ take: 10,
+ skip: 0,
+};
+
+describe("GET /api/bookings", async () => {
+ const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
+ const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
+
+ it("Does not return bookings of other users when user has no permission", async () => {
+ const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
+
+ const { req } = createMocks({
+ method: "GET",
+ query: {
+ userId: proUser.id,
+ },
+ pagination: DefaultPagination,
+ });
+
+ req.userId = memberUser.id;
+
+ const responseData = await handler(req);
+ const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
+
+ expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
+ expect(groupedUsers.size).toBe(1);
+ expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
+ });
+
+ it("Returns bookings for regular user", async () => {
+ const { req } = createMocks({
+ method: "GET",
+ pagination: DefaultPagination,
+ });
+
+ req.userId = proUser.id;
+
+ const responseData = await handler(req);
+ expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined();
+ expect(responseData.bookings.find((b) => b.userId !== proUser.id)).toBeUndefined();
+ });
+
+ it("Returns bookings for specified user when accessed by system-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const { req } = createMocks({
+ method: "GET",
+ pagination: DefaultPagination,
+ query: {
+ userId: proUser.id,
+ },
+ });
+
+ req.isSystemWideAdmin = true;
+ req.userId = adminUser.id;
+
+ const responseData = await handler(req);
+ expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined();
+ expect(responseData.bookings.find((b) => b.userId !== proUser.id)).toBeUndefined();
+ });
+
+ it("Returns bookings for all users when accessed by system-wide admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const { req } = createMocks({
+ method: "GET",
+ pagination: {
+ take: 100,
+ skip: 0,
+ },
+ });
+
+ req.isSystemWideAdmin = true;
+ req.userId = adminUser.id;
+
+ const responseData = await handler(req);
+ const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
+ expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined();
+ expect(groupedUsers.size).toBeGreaterThan(2);
+ });
+
+ it("Returns bookings for org users when accessed by org admin", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const { req } = createMocks({
+ method: "GET",
+ pagination: DefaultPagination,
+ });
+
+ req.userId = adminUser.id;
+ req.isOrganizationOwnerOrAdmin = true;
+
+ const responseData = await handler(req);
+ const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));
+ expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeUndefined();
+ expect(groupedUsers.size).toBeGreaterThanOrEqual(2);
+ });
+});
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
index 84c057c2228ff0..836246bbb99637 100644
--- a/apps/api/v1/test/lib/event-types/[id]/_get.test.ts
+++ b/apps/api/v1/test/lib/event-types/[id]/_get.test.ts
@@ -55,7 +55,7 @@ describe("GET /api/event-types/[id]", () => {
})
);
- req.isAdmin = true;
+ req.isSystemWideAdmin = true;
req.userId = 333333;
await handler(req, res);
@@ -91,7 +91,7 @@ describe("GET /api/event-types/[id]", () => {
],
});
- req.isAdmin = false;
+ req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
@@ -131,7 +131,7 @@ describe("GET /api/event-types/[id]", () => {
})
);
- req.isAdmin = false;
+ req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
diff --git a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
index 764c0daee1f036..1b53565fd7ba70 100644
--- a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
+++ b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
@@ -1,13 +1,17 @@
+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, vi, it, expect, afterEach } from "vitest";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
+import prisma from "@calcom/prisma";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
+import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
@@ -40,7 +44,7 @@ describe("Verify API key", () => {
};
vi.mocked(checkLicense).mockResolvedValue(false);
- vi.mocked(isAdminGuard).mockResolvedValue(false);
+ vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -51,7 +55,7 @@ describe("Verify API key", () => {
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
- it("It should thow an error if no api key is provided", async () => {
+ it("It should throw an error if no api key is provided", async () => {
const { req, res } = createMocks({
method: "POST",
body: {},
@@ -62,7 +66,7 @@ describe("Verify API key", () => {
};
vi.mocked(checkLicense).mockResolvedValue(true);
- vi.mocked(isAdminGuard).mockResolvedValue(false);
+ vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -73,4 +77,70 @@ describe("Verify API key", () => {
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
+
+ it("It should set correct permissions for system-wide admin", async () => {
+ const { req, res } = createMocks({
+ method: "POST",
+ body: {},
+ query: {
+ apiKey: "cal_test_key",
+ },
+ prisma,
+ });
+
+ prismaMock.apiKey.findUnique.mockResolvedValue({
+ id: 1,
+ userId: 2,
+ });
+
+ const middleware = {
+ fn: verifyApiKey,
+ };
+
+ vi.mocked(checkLicense).mockResolvedValue(true);
+ vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
+
+ const serverNext = vi.fn((next: void) => Promise.resolve(next));
+
+ const middlewareSpy = vi.spyOn(middleware, "fn");
+
+ await middleware.fn(req, res, serverNext);
+
+ expect(middlewareSpy).toBeCalled();
+ expect(req.isSystemWideAdmin).toBe(true);
+ expect(req.isOrganizationOwnerOrAdmin).toBe(false);
+ });
+
+ it("It should set correct permissions for org-level admin", async () => {
+ const { req, res } = createMocks({
+ method: "POST",
+ body: {},
+ query: {
+ apiKey: "cal_test_key",
+ },
+ prisma,
+ });
+
+ prismaMock.apiKey.findUnique.mockResolvedValue({
+ id: 1,
+ userId: 2,
+ });
+
+ const middleware = {
+ fn: verifyApiKey,
+ };
+
+ vi.mocked(checkLicense).mockResolvedValue(true);
+ vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
+
+ const serverNext = vi.fn((next: void) => Promise.resolve(next));
+
+ const middlewareSpy = vi.spyOn(middleware, "fn");
+
+ await middleware.fn(req, res, serverNext);
+
+ expect(middlewareSpy).toBeCalled();
+ expect(req.isSystemWideAdmin).toBe(false);
+ expect(req.isOrganizationOwnerOrAdmin).toBe(true);
+ });
});
diff --git a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts
new file mode 100644
index 00000000000000..43dca2c9e7f614
--- /dev/null
+++ b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts
@@ -0,0 +1,76 @@
+import type { Request, Response } from "express";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { createMocks } from "node-mocks-http";
+import { describe, it, expect } from "vitest";
+
+import prisma from "@calcom/prisma";
+
+import { isAdminGuard } from "../../../lib/utils/isAdmin";
+import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
+
+type CustomNextApiRequest = NextApiRequest & Request;
+type CustomNextApiResponse = NextApiResponse & Response;
+
+describe("isAdmin guard", () => {
+ it("Returns false when user does not exist in the system", async () => {
+ const { req } = createMocks({
+ method: "POST",
+ body: {},
+ });
+
+ req.userId = 0;
+
+ const { isAdmin, scope } = await isAdminGuard(req);
+
+ expect(isAdmin).toBe(false);
+ expect(scope).toBe(null);
+ });
+
+ it("Returns false when org user is a member", async () => {
+ const { req } = createMocks({
+ method: "POST",
+ body: {},
+ });
+
+ const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
+
+ req.userId = memberUser.id;
+
+ const { isAdmin, scope } = await isAdminGuard(req);
+
+ expect(isAdmin).toBe(false);
+ expect(scope).toBe(null);
+ });
+
+ it("Returns system-wide admin when user is marked as such", async () => {
+ const { req } = createMocks({
+ method: "POST",
+ body: {},
+ });
+
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
+
+ req.userId = adminUser.id;
+
+ const { isAdmin, scope } = await isAdminGuard(req);
+
+ expect(isAdmin).toBe(true);
+ expect(scope).toBe(ScopeOfAdmin.SystemWide);
+ });
+
+ it("Returns org-wide admin when user is set as such", async () => {
+ const { req } = createMocks({
+ method: "POST",
+ body: {},
+ });
+
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+
+ req.userId = adminUser.id;
+
+ const { isAdmin, scope } = await isAdminGuard(req);
+
+ expect(isAdmin).toBe(true);
+ expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
+ });
+});
diff --git a/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts b/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts
new file mode 100644
index 00000000000000..2314aaa50d3d99
--- /dev/null
+++ b/apps/api/v1/test/lib/utils/retrieveScopedAccessibleUsers.integration-test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+
+import prisma from "@calcom/prisma";
+
+import {
+ getAccessibleUsers,
+ retrieveOrgScopedAccessibleUsers,
+} from "../../../lib/utils/retrieveScopedAccessibleUsers";
+
+describe("retrieveScopedAccessibleUsers tests", () => {
+ describe("getAccessibleUsers", () => {
+ it("Does not return members when only admin user ID is supplied", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const accessibleUserIds = await getAccessibleUsers({
+ memberUserIds: [],
+ adminUserId: adminUser.id,
+ });
+
+ expect(accessibleUserIds.length).toBe(0);
+ });
+
+ it("Does not return members when admin user ID is not an admin of the user", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
+ const memberOneUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member1-acme@example.com" },
+ });
+ const accessibleUserIds = await getAccessibleUsers({
+ memberUserIds: [memberOneUser.id],
+ adminUserId: adminUser.id,
+ });
+
+ expect(accessibleUserIds.length).toBe(0);
+ });
+
+ it("Returns members when admin user ID is supplied and members IDs are supplied", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
+ const memberOneUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member1-acme@example.com" },
+ });
+ const memberTwoUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member2-acme@example.com" },
+ });
+ const accessibleUserIds = await getAccessibleUsers({
+ memberUserIds: [memberOneUser.id, memberTwoUser.id],
+ adminUserId: adminUser.id,
+ });
+
+ expect(accessibleUserIds.length).toBe(2);
+ expect(accessibleUserIds).toContain(memberOneUser.id);
+ expect(accessibleUserIds).toContain(memberTwoUser.id);
+ });
+ });
+
+ describe("retrieveOrgScopedAccessibleUsers", () => {
+ it("Does not return members when admin user ID is an admin of an org", async () => {
+ const memberOneUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member1-acme@example.com" },
+ });
+
+ const accessibleUserIds = await retrieveOrgScopedAccessibleUsers({
+ adminId: memberOneUser.id,
+ });
+
+ expect(accessibleUserIds.length).toBe(0);
+ });
+
+ it("Returns members when admin user ID is an admin of an org", async () => {
+ const adminUser = await prisma.user.findFirstOrThrow({
+ where: { email: "owner1-acme@example.com" },
+ });
+
+ const accessibleUserIds = await retrieveOrgScopedAccessibleUsers({
+ adminId: adminUser.id,
+ });
+
+ const memberOneUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member1-acme@example.com" },
+ });
+
+ const memberTwoUser = await prisma.user.findFirstOrThrow({
+ where: { email: "member2-acme@example.com" },
+ });
+
+ expect(accessibleUserIds.length).toBe(3);
+ expect(accessibleUserIds).toContain(memberOneUser.id);
+ expect(accessibleUserIds).toContain(memberTwoUser.id);
+ expect(accessibleUserIds).toContain(adminUser.id);
+ });
+ });
+});
diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example
index e06b413342bb5f..0826ed44ce1892 100644
--- a/apps/api/v2/.env.example
+++ b/apps/api/v2/.env.example
@@ -10,4 +10,17 @@ 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
+NEXT_PUBLIC_SENTRY_DSN=
+
+# Stripe Billing
+STRIPE_PRICE_ID_STARTER=
+STRIPE_PRICE_ID_ESSENTIALS=
+STRIPE_PRICE_ID_ENTERPRISE=
+STRIPE_API_KEY=
+STRIPE_WEBHOOK_SECRET=
+
+WEB_APP_URL=http://localhost:3000/
+CALCOM_LICENSE_KEY=
+API_KEY_PREFIX=cal_
+GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
+IS_E2E=false
\ No newline at end of file
diff --git a/apps/api/v2/.eslintrc.js b/apps/api/v2/.eslintrc.js
index 1c4289dc6b6b03..4408591ef198ba 100644
--- a/apps/api/v2/.eslintrc.js
+++ b/apps/api/v2/.eslintrc.js
@@ -12,7 +12,7 @@ module.exports = {
node: true,
jest: true,
},
- ignorePatterns: [".eslintrc.js"],
+ ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
diff --git a/apps/api/v2/.prettierrc.js b/apps/api/v2/.prettierrc.js
index ba4100a4efe045..de9853706fbf7c 100644
--- a/apps/api/v2/.prettierrc.js
+++ b/apps/api/v2/.prettierrc.js
@@ -2,5 +2,6 @@ const rootConfig = require("../../../packages/config/prettier-preset");
module.exports = {
...rootConfig,
+ importOrder: ["^./instrument", ...rootConfig.importOrder],
importOrderParserPlugins: ["typescript", "decorators-legacy"],
};
diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md
index 64af00f7fb28dd..92c3f76482274d 100644
--- a/apps/api/v2/README.md
+++ b/apps/api/v2/README.md
@@ -42,6 +42,16 @@ $ yarn prisma generate
Copy `.env.example` to `.env` and fill values.
+## Add license Key to deployments table in DB
+
+id, logo theme licenseKey agreedLicenseAt
+1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
+
+your CALCOM_LICENSE_KEY env var need to contain the same value
+
+.env
+CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
+
## Running the app
```bash
diff --git a/apps/api/v2/nest-cli.json b/apps/api/v2/nest-cli.json
index 1eecbdbf6888f3..45d207286db5a1 100644
--- a/apps/api/v2/nest-cli.json
+++ b/apps/api/v2/nest-cli.json
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
+ "entryFile": "./apps/api/v2/src/main.js",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json
index d88131c3e10fa1..b1f5701746149c 100644
--- a/apps/api/v2/package.json
+++ b/apps/api/v2/package.json
@@ -9,11 +9,11 @@
"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",
+ "dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
+ "dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
+ "dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
- "start:prod": "node dist/src/main",
+ "start:prod": "node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
@@ -25,11 +25,13 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
- "@calcom/platform-libraries": "*",
+ "@calcom/platform-libraries-0.0.19": "npm:@calcom/platform-libraries@0.0.19",
+ "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
+ "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@@ -37,9 +39,9 @@
"@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",
+ "@nestjs/throttler": "^5.1.2",
+ "@sentry/node": "^8.8.0",
+ "body-parser": "^1.20.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@@ -54,8 +56,10 @@
"next-auth": "^4.22.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
+ "querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
+ "stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"zod": "^3.22.4"
diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts
index a589abe5260121..ea4ab52594484d 100644
--- a/apps/api/v2/src/app.module.ts
+++ b/apps/api/v2/src/app.module.ts
@@ -1,13 +1,21 @@
-import { AppLoggerMiddleware } from "@/app.logger.middleware";
-import { RewriterMiddleware } from "@/app.rewrites.middleware";
import appConfig from "@/config/app";
+import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
+import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
+import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
+import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
+import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
+import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
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 { RedisModule } from "@/modules/redis/redis.module";
+import { RedisService } from "@/modules/redis/redis.service";
+import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
-import { RouterModule } from "@nestjs/core";
+import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
+import { seconds, ThrottlerModule } from "@nestjs/throttler";
+import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -18,32 +26,54 @@ import { AppController } from "./app.controller";
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 })),
- // }),
- // }),
+ RedisModule,
+ ThrottlerModule.forRootAsync({
+ imports: [RedisModule],
+ inject: [RedisService],
+ useFactory: (redisService: RedisService) => ({
+ throttlers: [
+ {
+ name: "short",
+ ttl: seconds(10),
+ limit: 3,
+ },
+ {
+ name: "medium",
+ ttl: seconds(30),
+ limit: 10,
+ },
+ ],
+ storage: new ThrottlerStorageRedisService(redisService.redis),
+ }),
+ }),
PrismaModule,
EndpointsModule,
AuthModule,
JwtModule,
- //register prefix for all routes in EndpointsModule
- RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
+ providers: [
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: ResponseInterceptor,
+ },
+ ],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
- consumer.apply(AppLoggerMiddleware).forRoutes("*");
- consumer.apply(RewriterMiddleware).forRoutes("/");
+ consumer
+ .apply(RawBodyMiddleware)
+ .forRoutes({
+ path: "/api/v2/billing/webhook",
+ method: RequestMethod.POST,
+ })
+ .apply(JsonBodyMiddleware)
+ .forRoutes("*")
+ .apply(RequestIdMiddleware)
+ .forRoutes("*")
+ .apply(AppLoggerMiddleware)
+ .forRoutes("*")
+ .apply(RewriterMiddleware)
+ .forRoutes("/");
}
}
diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts
index 417839abbc186f..3ce349268c19a4 100644
--- a/apps/api/v2/src/app.ts
+++ b/apps/api/v2/src/app.ts
@@ -1,26 +1,41 @@
-import { getEnv } from "@/env";
+import "./instrument";
+
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 { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
+import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
+import { Request } from "express";
import helmet from "helmet";
-import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
+import {
+ API_VERSIONS,
+ VERSION_2024_04_15,
+ API_VERSIONS_ENUM,
+ CAL_API_VERSION_HEADER,
+ 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",
+ type: VersioningType.CUSTOM,
+ extractor: (request: unknown) => {
+ const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
+ if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
+ return headerVersion;
+ }
+ return VERSION_2024_04_15;
+ },
+ defaultVersion: VERSION_2024_04_15,
});
app.use(helmet());
@@ -28,7 +43,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
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"],
+ allowedHeaders: [
+ X_CAL_CLIENT_ID,
+ X_CAL_SECRET_KEY,
+ CAL_API_VERSION_HEADER,
+ "Accept",
+ "Authorization",
+ "Content-Type",
+ "Origin",
+ ],
maxAge: 86_400,
});
@@ -46,15 +69,11 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
})
);
- 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));
+ if (process.env.SENTRY_DSN) {
+ Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
+ }
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts
index 904579396aadc2..87db1b66459488 100644
--- a/apps/api/v2/src/config/app.ts
+++ b/apps/api/v2/src/config/app.ts
@@ -15,6 +15,9 @@ const loadConfig = (): AppConfig => {
? `:${Number(getEnv("API_PORT", "5555"))}`
: ""
}/v2`,
+ keyPrefix: getEnv("API_KEY_PREFIX", "cal_"),
+ licenseKey: getEnv("CALCOM_LICENSE_KEY", ""),
+ licenseKeyUrl: getEnv("GET_LICENSE_KEY_URL", "https://console.cal.com/api/license"),
},
db: {
readUrl: getEnv("DATABASE_READ_URL"),
@@ -24,6 +27,14 @@ const loadConfig = (): AppConfig => {
next: {
authSecret: getEnv("NEXTAUTH_SECRET"),
},
+ stripe: {
+ apiKey: getEnv("STRIPE_API_KEY"),
+ webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
+ },
+ app: {
+ baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
+ },
+ e2e: getEnv("IS_E2E", false),
};
};
diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts
index e7690ac63fd675..ed08ad55a38494 100644
--- a/apps/api/v2/src/config/type.ts
+++ b/apps/api/v2/src/config/type.ts
@@ -6,6 +6,9 @@ export type AppConfig = {
port: number;
path: string;
url: string;
+ keyPrefix: string;
+ licenseKey: string;
+ licenseKeyUrl: string;
};
db: {
readUrl: string;
@@ -15,4 +18,12 @@ export type AppConfig = {
next: {
authSecret: string;
};
+ stripe: {
+ apiKey: string;
+ webhookSecret: string;
+ };
+ app: {
+ baseUrl: string;
+ };
+ e2e: boolean;
};
diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts
index 9725cac786469d..8e1be0f3e7c74e 100644
--- a/apps/api/v2/src/ee/bookings/bookings.module.ts
+++ b/apps/api/v2/src/ee/bookings/bookings.module.ts
@@ -1,13 +1,15 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
+import { BillingModule } from "@/modules/billing/billing.module";
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 { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Module } from "@nestjs/common";
@Module({
- imports: [PrismaModule, TokensModule],
+ imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
})
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
index 891a8e7b62a700..cead965ba3b6cd 100644
--- 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
@@ -2,11 +2,10 @@ 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 { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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";
@@ -17,10 +16,10 @@ 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 { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
-import { handleNewBooking } from "@calcom/platform-libraries";
+import { handleNewBooking } from "@calcom/platform-libraries-0.0.19";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -29,7 +28,7 @@ describe("Bookings Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
- let schedulesService: SchedulesService;
+ let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
@@ -40,10 +39,10 @@ describe("Bookings Endpoints", () => {
let createdBooking: Awaited>;
beforeAll(async () => {
- const moduleRef = await withAccessTokenAuth(
+ const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
- imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule],
+ imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -55,13 +54,13 @@ describe("Bookings Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
- schedulesService = moduleRef.get(SchedulesService);
+ schedulesService = moduleRef.get(SchedulesService_2024_04_15);
user = await userRepositoryFixture.create({
email: userEmail,
});
- const userSchedule: CreateScheduleInput = {
+ const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
@@ -140,7 +139,6 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/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];
@@ -241,7 +239,8 @@ describe("Bookings Endpoints", () => {
// });
// });
- it("should cancel a booking", async () => {
+ // cancelling a booking hangs the test for some reason
+ it.skip("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts
index 5981893abb2a87..315188dfb9217c 100644
--- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts
+++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts
@@ -1,11 +1,15 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
+import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
+import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+import { BillingService } from "@/modules/billing/services/billing.service";
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";
@@ -30,20 +34,19 @@ 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 { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
-} from "@calcom/platform-libraries";
+ handleMarkNoShow,
+ getAllUserBookings,
+ getBookingInfo,
+ handleCancelBooking,
+ getBookingForReschedule,
+} from "@calcom/platform-libraries-0.0.19";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -71,22 +74,23 @@ const DEFAULT_PLATFORM_PARAMS = {
};
@Controller({
- path: "/bookings",
- version: "2",
+ path: "/v2/bookings",
+ version: API_VERSIONS_VALUES,
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
export class BookingsController {
- private readonly logger = new Logger("ee bookings controller");
+ private readonly logger = new Logger("BookingsController");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
- private readonly oAuthClientRepository: OAuthClientRepository
+ private readonly oAuthClientRepository: OAuthClientRepository,
+ private readonly billingService: BillingService
) {}
@Get("/")
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
@Permissions([BOOKING_READ])
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
@ApiQuery({ name: "limit", type: "number", required: false })
@@ -148,17 +152,21 @@ export class BookingsController {
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise> {
const oAuthClientId = clientId?.toString();
- const locationUrl = body.locationUrl;
+
+ const { orgSlug, locationUrl } = body;
+ req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
+
+ void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: booking,
};
} catch (err) {
- handleBookingErrors(err);
+ this.handleBookingErrors(err);
}
throw new InternalServerErrorException("Could not create booking.");
}
@@ -179,7 +187,7 @@ export class BookingsController {
status: SUCCESS_STATUS,
};
} catch (err) {
- handleBookingErrors(err);
+ this.handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
@@ -187,6 +195,27 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
+ @Post("/:bookingUid/mark-no-show")
+ @Permissions([BOOKING_WRITE])
+ @UseGuards(ApiAuthGuard)
+ async markNoShow(
+ @Body() body: MarkNoShowInput,
+ @Param("bookingUid") bookingUid: string
+ ): Promise {
+ try {
+ const markNoShowResponse = await handleMarkNoShow({
+ bookingUid: bookingUid,
+ attendees: body.attendees,
+ noShowHost: body.noShowHost,
+ });
+
+ return { status: SUCCESS_STATUS, data: markNoShowResponse };
+ } catch (err) {
+ this.handleBookingErrors(err, "no-show");
+ }
+ throw new InternalServerErrorException("Could not mark no show.");
+ }
+
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@@ -198,12 +227,15 @@ export class BookingsController {
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiBookingRequest(req, oAuthClientId)
);
+
+ void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
+
return {
status: SUCCESS_STATUS,
data: createdBookings,
};
} catch (err) {
- handleBookingErrors(err, "recurring");
+ this.handleBookingErrors(err, "recurring");
}
throw new InternalServerErrorException("Could not create recurring booking.");
}
@@ -220,17 +252,20 @@ export class BookingsController {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId)
);
+
+ void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
+
return {
status: SUCCESS_STATUS,
data: instantMeeting,
};
} catch (err) {
- handleBookingErrors(err, "instant");
+ this.handleBookingErrors(err, "instant");
}
throw new InternalServerErrorException("Could not create instant booking.");
}
- async getOwnerId(req: Request): Promise {
+ private async getOwnerId(req: Request): Promise {
try {
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
if (accessToken) {
@@ -241,7 +276,7 @@ export class BookingsController {
}
}
- async getOAuthClientsParams(req: BookingRequest, clientId: string): Promise {
+ private async getOAuthClientsParams(clientId: string): Promise {
const res = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
@@ -260,32 +295,38 @@ export class BookingsController {
}
}
- async createNextApiBookingRequest(
+ private async createNextApiBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
): Promise {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
- ? await this.getOAuthClientsParams(req, oAuthClientId)
+ ? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
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);
- }
+ private handleBookingErrors(
+ err: Error | HttpError | unknown,
+ type?: "recurring" | `instant` | "no-show"
+ ): void {
+ const errMsg =
+ type === "no-show"
+ ? `Error while marking no-show.`
+ : `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);
- }
+ if (err instanceof Error) {
+ const error = err as Error;
+ throw new InternalServerErrorException(error?.message ?? errMsg);
+ }
- throw new InternalServerErrorException(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
index c9dc1da1b69263..7101bb80a2d518 100644
--- a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts
+++ b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts
@@ -99,6 +99,10 @@ export class CreateBookingInput {
@Type(() => Response)
responses!: Response;
+ @IsString()
+ @IsOptional()
+ orgSlug?: string;
+
@IsString()
@IsOptional()
locationUrl?: string;
diff --git a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts
index 471e8ab8dfa4e3..757b60dc222d4e 100644
--- a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts
+++ b/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
-import type { AppsStatus } from "@calcom/platform-libraries";
+import type { AppsStatus } from "@calcom/platform-libraries-0.0.19";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
diff --git a/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts b/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts
new file mode 100644
index 00000000000000..0630f08fcc3395
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts
@@ -0,0 +1,22 @@
+import { Type } from "class-transformer";
+import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
+
+class Attendee {
+ @IsEmail()
+ email!: string;
+
+ @IsBoolean()
+ noShow!: boolean;
+}
+
+export class MarkNoShowInput {
+ @IsBoolean()
+ @IsOptional()
+ noShowHost?: boolean;
+
+ @ValidateNested()
+ @Type(() => Attendee)
+ @IsArray()
+ @IsOptional()
+ attendees?: Attendee[];
+}
diff --git a/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts b/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts
new file mode 100644
index 00000000000000..5b1e5de68d14b2
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts
@@ -0,0 +1,45 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+
+class Attendee {
+ @IsEmail()
+ email!: string;
+
+ @IsBoolean()
+ noShow!: boolean;
+}
+
+class HandleMarkNoShowData {
+ @IsString()
+ message!: string;
+
+ @IsBoolean()
+ @IsOptional()
+ noShowHost?: boolean;
+
+ @IsString()
+ @IsOptional()
+ messageKey?: string;
+
+ @ValidateNested()
+ @Type(() => Attendee)
+ @IsArray()
+ @IsOptional()
+ attendees?: Attendee[];
+}
+
+export class MarkNoShowOutput {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: HandleMarkNoShowData,
+ })
+ @ValidateNested()
+ @Type(() => HandleMarkNoShowData)
+ data!: HandleMarkNoShowData;
+}
diff --git a/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts b/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..d1c6e630496cb0
--- /dev/null
+++ b/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts
@@ -0,0 +1,200 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+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";
+import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
+
+import {
+ GOOGLE_CALENDAR,
+ OFFICE_365_CALENDAR,
+ GOOGLE_CALENDAR_TYPE,
+ GOOGLE_CALENDAR_ID,
+} from "@calcom/platform-constants";
+import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
+
+const CLIENT_REDIRECT_URI = "http://localhost:5555";
+
+describe("Platform Calendars 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 office365Credentials: Credential;
+ let googleCalendarCredentials: 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("office365-connect@gmail.com", oAuthClient.id);
+ const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
+ accessTokenSecret = tokens.accessToken;
+ refreshTokenSecret = tokens.refreshToken;
+ await app.init();
+ jest
+ .spyOn(CalendarsService.prototype, "getCalendars")
+ .mockImplementation(CalendarsServiceMock.prototype.getCalendars);
+ });
+
+ 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/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
+ await request(app.getHttpServer())
+ .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
+ .set("Authorization", `Bearer invalid_access_token`)
+ .expect(401);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
+ const response = await request(app.getHttpServer())
+ .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
+ .set("Authorization", `Bearer ${accessTokenSecret}`)
+ .set("Origin", CLIENT_REDIRECT_URI)
+ .expect(200);
+ const data = response.body.data;
+ expect(data.authUrl).toBeDefined();
+ });
+
+ it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
+ const response = await request(app.getHttpServer())
+ .get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
+ .set("Authorization", `Bearer ${accessTokenSecret}`)
+ .set("Origin", CLIENT_REDIRECT_URI)
+ .expect(200);
+ const data = response.body.data;
+ expect(data.authUrl).toBeDefined();
+ });
+
+ it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
+ await request(app.getHttpServer())
+ .get(`/v2/calendars/random-calendar/connect`)
+ .set("Authorization", `Bearer ${accessTokenSecret}`)
+ .set("Origin", CLIENT_REDIRECT_URI)
+ .expect(400);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
+ await request(app.getHttpServer())
+ .get(
+ `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
+ )
+ .expect(400);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
+ await request(app.getHttpServer())
+ .get(
+ `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
+ )
+ .expect(400);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
+ await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
+ await request(app.getHttpServer())
+ .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
+ .set("Authorization", `Bearer ${accessTokenSecret}`)
+ .set("Origin", CLIENT_REDIRECT_URI)
+ .expect(400);
+ });
+
+ it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
+ office365Credentials = await credentialsRepositoryFixture.create(
+ OFFICE_365_CALENDAR_TYPE,
+ {},
+ user.id,
+ OFFICE_365_CALENDAR_ID
+ );
+ await request(app.getHttpServer())
+ .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
+ .set("Authorization", `Bearer ${accessTokenSecret}`)
+ .set("Origin", CLIENT_REDIRECT_URI)
+ .expect(200);
+ });
+
+ it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
+ googleCalendarCredentials = await credentialsRepositoryFixture.create(
+ GOOGLE_CALENDAR_TYPE,
+ {},
+ user.id,
+ GOOGLE_CALENDAR_ID
+ );
+ await request(app.getHttpServer())
+ .get(`/v2/calendars/${GOOGLE_CALENDAR}/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(office365Credentials.id);
+ await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await app.close();
+ });
+});
diff --git a/apps/api/v2/src/ee/calendars/calendars.interface.ts b/apps/api/v2/src/ee/calendars/calendars.interface.ts
new file mode 100644
index 00000000000000..9767e7ee814216
--- /dev/null
+++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts
@@ -0,0 +1,17 @@
+import { Request } from "express";
+
+import { ApiResponse } from "@calcom/platform-types";
+
+export interface CalendarApp {
+ save(state: string, code: string, origin: string): Promise<{ url: string }>;
+ check(userId: number): Promise;
+}
+
+export interface CredentialSyncCalendarApp {
+ save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
+ check(userId: number): Promise;
+}
+
+export interface OAuthCalendarApp extends CalendarApp {
+ connect(authorization: string, req: Request): Promise>;
+}
diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts
index fe6b382605aace..93b37b6f2bd854 100644
--- a/apps/api/v2/src/ee/calendars/calendars.module.ts
+++ b/apps/api/v2/src/ee/calendars/calendars.module.ts
@@ -1,13 +1,27 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
+import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
+import { OutlookService } from "@/ee/calendars/services/outlook.service";
+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 { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
- imports: [PrismaModule, UsersModule],
- providers: [CredentialsRepository, CalendarsService],
+ imports: [PrismaModule, UsersModule, TokensModule],
+ providers: [
+ CredentialsRepository,
+ CalendarsService,
+ OutlookService,
+ GoogleCalendarService,
+ AppleCalendarService,
+ SelectedCalendarsRepository,
+ AppsRepository,
+ ],
controllers: [CalendarsController],
exports: [CalendarsService],
})
diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
index 4a1e705672052f..79dde7b9f80ab9 100644
--- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
+++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
@@ -1,24 +1,59 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
+import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
+import { OutlookService } from "@/ee/calendars/services/outlook.service";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
-import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
+import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
-import { Controller, Get, UseGuards, Query } from "@nestjs/common";
+import {
+ Controller,
+ Get,
+ UseGuards,
+ Query,
+ HttpStatus,
+ HttpCode,
+ Req,
+ Param,
+ Headers,
+ Redirect,
+ BadRequestException,
+ Post,
+ Body,
+} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
+import { User } from "@prisma/client";
+import { Request } from "express";
+import { z } from "zod";
-import { SUCCESS_STATUS } from "@calcom/platform-constants";
-import { CalendarBusyTimesInput } from "@calcom/platform-types";
+import { APPS_READ } from "@calcom/platform-constants";
+import {
+ SUCCESS_STATUS,
+ CALENDARS,
+ GOOGLE_CALENDAR,
+ OFFICE_365_CALENDAR,
+ APPLE_CALENDAR,
+} from "@calcom/platform-constants";
+import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
- path: "/calendars",
- version: "2",
+ path: "/v2/calendars",
+ version: API_VERSIONS_VALUES,
})
-@UseGuards(AccessTokenGuard)
@DocsTags("Calendars")
export class CalendarsController {
- constructor(private readonly calendarsService: CalendarsService) {}
+ constructor(
+ private readonly calendarsService: CalendarsService,
+ private readonly outlookService: OutlookService,
+ private readonly googleCalendarService: GoogleCalendarService,
+ private readonly appleCalendarService: AppleCalendarService
+ ) {}
+ @UseGuards(ApiAuthGuard)
@Get("/busy-times")
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@@ -47,6 +82,7 @@ export class CalendarsController {
}
@Get("/")
+ @UseGuards(ApiAuthGuard)
async getCalendars(@GetUser("id") userId: number): Promise {
const calendars = await this.calendarsService.getCalendars(userId);
@@ -55,4 +91,96 @@ export class CalendarsController {
data: calendars,
};
}
+
+ @UseGuards(ApiAuthGuard)
+ @Get("/:calendar/connect")
+ @HttpCode(HttpStatus.OK)
+ async redirect(
+ @Req() req: Request,
+ @Headers("Authorization") authorization: string,
+ @Param("calendar") calendar: string,
+ @Query("redir") redir?: string | null
+ ): Promise> {
+ switch (calendar) {
+ case OFFICE_365_CALENDAR:
+ return await this.outlookService.connect(authorization, req, redir ?? "");
+ case GOOGLE_CALENDAR:
+ return await this.googleCalendarService.connect(authorization, req, redir ?? "");
+ default:
+ throw new BadRequestException(
+ "Invalid calendar type, available calendars are: ",
+ CALENDARS.join(", ")
+ );
+ }
+ }
+
+ @Get("/:calendar/save")
+ @HttpCode(HttpStatus.OK)
+ @Redirect(undefined, 301)
+ async save(
+ @Query("state") state: string,
+ @Query("code") code: string,
+ @Param("calendar") calendar: string
+ ): Promise<{ url: string }> {
+ // state params contains our user access token
+ const stateParams = new URLSearchParams(state);
+ const { accessToken, origin, redir } = z
+ .object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
+ .parse({
+ accessToken: stateParams.get("accessToken"),
+ origin: stateParams.get("origin"),
+ redir: stateParams.get("redir"),
+ });
+ switch (calendar) {
+ case OFFICE_365_CALENDAR:
+ return await this.outlookService.save(code, accessToken, origin, redir ?? "");
+ case GOOGLE_CALENDAR:
+ return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
+ default:
+ throw new BadRequestException(
+ "Invalid calendar type, available calendars are: ",
+ CALENDARS.join(", ")
+ );
+ }
+ }
+
+ @UseGuards(ApiAuthGuard)
+ @Post("/:calendar/credentials")
+ async syncCredentials(
+ @GetUser() user: User,
+ @Param("calendar") calendar: string,
+ @Body() body: { username: string; password: string }
+ ): Promise<{ status: string }> {
+ const { username, password } = body;
+
+ switch (calendar) {
+ case APPLE_CALENDAR:
+ return await this.appleCalendarService.save(user.id, user.email, username, password);
+ default:
+ throw new BadRequestException(
+ "Invalid calendar type, available calendars are: ",
+ CALENDARS.join(", ")
+ );
+ }
+ }
+
+ @Get("/:calendar/check")
+ @HttpCode(HttpStatus.OK)
+ @UseGuards(ApiAuthGuard, PermissionsGuard)
+ @Permissions([APPS_READ])
+ async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise {
+ switch (calendar) {
+ case OFFICE_365_CALENDAR:
+ return await this.outlookService.check(userId);
+ case GOOGLE_CALENDAR:
+ return await this.googleCalendarService.check(userId);
+ case APPLE_CALENDAR:
+ return await this.appleCalendarService.check(userId);
+ default:
+ throw new BadRequestException(
+ "Invalid calendar type, available calendars are: ",
+ CALENDARS.join(", ")
+ );
+ }
+ }
}
diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts
new file mode 100644
index 00000000000000..4c0601466d76ba
--- /dev/null
+++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts
@@ -0,0 +1,92 @@
+import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
+import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
+import { BadRequestException, UnauthorizedException } from "@nestjs/common";
+import { Injectable } from "@nestjs/common";
+
+import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
+import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.19";
+
+@Injectable()
+export class AppleCalendarService implements CredentialSyncCalendarApp {
+ constructor(
+ private readonly calendarsService: CalendarsService,
+ private readonly credentialRepository: CredentialsRepository
+ ) {}
+
+ async save(
+ userId: number,
+ userEmail: string,
+ username: string,
+ password: string
+ ): Promise<{ status: string }> {
+ return await this.saveCalendarCredentials(userId, userEmail, username, password);
+ }
+
+ async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ return await this.checkIfCalendarConnected(userId);
+ }
+
+ async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
+ APPLE_CALENDAR_TYPE,
+ userId
+ );
+
+ if (!appleCalendarCredentials) {
+ throw new BadRequestException("Credentials for apple calendar not found.");
+ }
+
+ if (appleCalendarCredentials.invalid) {
+ throw new BadRequestException("Invalid apple calendar credentials.");
+ }
+
+ const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
+ const appleCalendar = connectedCalendars.find(
+ (cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
+ );
+ if (!appleCalendar) {
+ throw new UnauthorizedException("Apple calendar not connected.");
+ }
+ if (appleCalendar.error?.message) {
+ throw new UnauthorizedException(appleCalendar.error?.message);
+ }
+
+ return {
+ status: SUCCESS_STATUS,
+ };
+ }
+
+ async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
+ if (username.length <= 1 || password.length <= 1)
+ throw new BadRequestException(`Username or password cannot be empty`);
+
+ const data = {
+ type: APPLE_CALENDAR_TYPE,
+ key: symmetricEncrypt(
+ JSON.stringify({ username, password }),
+ process.env.CALENDSO_ENCRYPTION_KEY || ""
+ ),
+ userId: userId,
+ teamId: null,
+ appId: APPLE_CALENDAR_ID,
+ invalid: false,
+ };
+
+ try {
+ const dav = new CalendarService({
+ id: 0,
+ ...data,
+ user: { email: userEmail },
+ });
+ await dav?.listCalendars();
+ await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
+ } catch (reason) {
+ throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
+ }
+
+ return {
+ status: SUCCESS_STATUS,
+ };
+ }
+}
diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
index 6fee7cb40e8910..4fbbba049e05e5 100644
--- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts
+++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
@@ -1,3 +1,4 @@
+import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
CredentialsWithUserEmail,
@@ -11,21 +12,26 @@ import {
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { DateTime } from "luxon";
+import { z } from "zod";
-import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
-import { getBusyCalendarTimes } from "@calcom/platform-libraries";
+import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.19";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class CalendarsService {
+ private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
+
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
+ private readonly appsRepository: AppsRepository,
private readonly dbRead: PrismaReadService,
- private readonly dbWrite: PrismaWriteService
+ private readonly dbWrite: PrismaWriteService,
+ private readonly config: ConfigService
) {}
async getCalendars(userId: number) {
@@ -110,4 +116,24 @@ export class CalendarsService {
});
return composedSelectedCalendars;
}
+
+ async getAppKeys(appName: string) {
+ const app = await this.appsRepository.getAppBySlug(appName);
+
+ if (!app) {
+ throw new NotFoundException();
+ }
+
+ const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
+
+ if (!client_id) {
+ throw new NotFoundException();
+ }
+
+ if (!client_secret) {
+ throw new NotFoundException();
+ }
+
+ return { client_id, client_secret };
+ }
}
diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts
new file mode 100644
index 00000000000000..9f0d25471c8be2
--- /dev/null
+++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts
@@ -0,0 +1,162 @@
+import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
+import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+import { AppsRepository } from "@/modules/apps/apps.repository";
+import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
+import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
+import { TokensRepository } from "@/modules/tokens/tokens.repository";
+import { Logger, NotFoundException } from "@nestjs/common";
+import { BadRequestException, UnauthorizedException } from "@nestjs/common";
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { Prisma } from "@prisma/client";
+import { Request } from "express";
+import { google } from "googleapis";
+import { z } from "zod";
+
+import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
+
+const CALENDAR_SCOPES = [
+ "https://www.googleapis.com/auth/calendar.readonly",
+ "https://www.googleapis.com/auth/calendar.events",
+];
+
+@Injectable()
+export class GoogleCalendarService implements OAuthCalendarApp {
+ private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
+ private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
+ private logger = new Logger("GcalService");
+
+ constructor(
+ private readonly config: ConfigService,
+ private readonly appsRepository: AppsRepository,
+ private readonly credentialRepository: CredentialsRepository,
+ private readonly calendarsService: CalendarsService,
+ private readonly tokensRepository: TokensRepository,
+ private readonly selectedCalendarsRepository: SelectedCalendarsRepository
+ ) {}
+
+ async connect(
+ authorization: string,
+ req: Request,
+ redir?: string
+ ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
+ const accessToken = authorization.replace("Bearer ", "");
+ const origin = req.get("origin") ?? req.get("host");
+ const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
+
+ return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
+ }
+
+ async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
+ return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
+ }
+
+ async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ return await this.checkIfCalendarConnected(userId);
+ }
+
+ async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
+ const oAuth2Client = await this.getOAuthClient(this.redirectUri);
+
+ const authUrl = oAuth2Client.generateAuthUrl({
+ access_type: "offline",
+ scope: CALENDAR_SCOPES,
+ prompt: "consent",
+ state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
+ });
+
+ return authUrl;
+ }
+
+ 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;
+ }
+
+ async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ 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 };
+ }
+
+ async saveCalendarCredentialsAndRedirect(
+ code: string,
+ accessToken: string,
+ origin: string,
+ redir?: string
+ ) {
+ // User chose not to authorize your app or didn't authorize your app
+ // redirect directly without oauth code
+ if (!code) {
+ return { url: redir || 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.getOAuthClient(this.redirectUri);
+ const token = await oAuth2Client.getToken(parsedCode);
+ // Google oAuth Credentials are stored in token.tokens
+ const key = token.tokens;
+ const credential = await this.credentialRepository.createAppCredential(
+ GOOGLE_CALENDAR_TYPE,
+ key as Prisma.InputJsonValue,
+ 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_TYPE
+ );
+ }
+
+ return { url: redir || origin };
+ }
+}
diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts
new file mode 100644
index 00000000000000..060427fdcee819
--- /dev/null
+++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts
@@ -0,0 +1,189 @@
+import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
+import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
+import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
+import { TokensRepository } from "@/modules/tokens/tokens.repository";
+import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
+import { BadRequestException, UnauthorizedException } from "@nestjs/common";
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { Request } from "express";
+import { stringify } from "querystring";
+import { z } from "zod";
+
+import {
+ SUCCESS_STATUS,
+ OFFICE_365_CALENDAR,
+ OFFICE_365_CALENDAR_ID,
+ OFFICE_365_CALENDAR_TYPE,
+} from "@calcom/platform-constants";
+
+@Injectable()
+export class OutlookService implements OAuthCalendarApp {
+ private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
+
+ constructor(
+ private readonly config: ConfigService,
+ private readonly calendarsService: CalendarsService,
+ private readonly credentialRepository: CredentialsRepository,
+ private readonly tokensRepository: TokensRepository,
+ private readonly selectedCalendarsRepository: SelectedCalendarsRepository
+ ) {}
+
+ async connect(
+ authorization: string,
+ req: Request,
+ redir?: string
+ ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
+ const accessToken = authorization.replace("Bearer ", "");
+ const origin = req.get("origin") ?? req.get("host");
+ const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
+
+ return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
+ }
+
+ async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
+ return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
+ }
+
+ async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ return await this.checkIfCalendarConnected(userId);
+ }
+
+ async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
+ const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
+
+ const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
+ const params = {
+ response_type: "code",
+ scope: scopes.join(" "),
+ client_id,
+ prompt: "select_account",
+ redirect_uri: this.redirectUri,
+ state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
+ };
+
+ const query = stringify(params);
+
+ const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
+
+ return url;
+ }
+
+ async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
+ "office365_calendar",
+ userId
+ );
+
+ if (!office365CalendarCredentials) {
+ throw new BadRequestException("Credentials for office_365_calendar not found.");
+ }
+
+ if (office365CalendarCredentials.invalid) {
+ throw new BadRequestException("Invalid office 365 calendar credentials.");
+ }
+
+ const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
+ const office365Calendar = connectedCalendars.find(
+ (cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
+ );
+ if (!office365Calendar) {
+ throw new UnauthorizedException("Office 365 calendar not connected.");
+ }
+ if (office365Calendar.error?.message) {
+ throw new UnauthorizedException(office365Calendar.error?.message);
+ }
+
+ return {
+ status: SUCCESS_STATUS,
+ };
+ }
+
+ async getOAuthCredentials(code: string) {
+ const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
+ const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
+
+ const toUrlEncoded = (payload: Record) =>
+ Object.keys(payload)
+ .map((key) => `${key}=${encodeURIComponent(payload[key])}`)
+ .join("&");
+
+ const body = toUrlEncoded({
+ client_id,
+ grant_type: "authorization_code",
+ code,
+ scope: scopes.join(" "),
+ redirect_uri: this.redirectUri,
+ client_secret,
+ });
+
+ const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
+ },
+ body,
+ });
+
+ const responseBody = await response.json();
+
+ return responseBody;
+ }
+
+ async getDefaultCalendar(accessToken: string): Promise {
+ const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ });
+ const responseBody = await response.json();
+
+ return responseBody as OfficeCalendar;
+ }
+
+ async saveCalendarCredentialsAndRedirect(
+ code: string,
+ accessToken: string,
+ origin: string,
+ redir?: string
+ ) {
+ // if code is not defined, user denied to authorize office 365 app, just redirect straight away
+ if (!code) {
+ return { url: redir || origin };
+ }
+
+ const parsedCode = z.string().parse(code);
+
+ const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
+
+ if (!ownerId) {
+ throw new UnauthorizedException("Invalid Access token.");
+ }
+
+ const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
+
+ const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
+
+ if (defaultCalendar?.id) {
+ const credential = await this.credentialRepository.createAppCredential(
+ OFFICE_365_CALENDAR_TYPE,
+ office365OAuthCredentials,
+ ownerId
+ );
+
+ await this.selectedCalendarsRepository.createSelectedCalendar(
+ defaultCalendar.id,
+ credential.id,
+ ownerId,
+ OFFICE_365_CALENDAR_TYPE
+ );
+ }
+
+ return {
+ url: redir || origin,
+ };
+ }
+}
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
deleted file mode 100644
index 5163794ae9135a..00000000000000
--- a/apps/api/v2/src/ee/event-types/event-types.module.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-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/constants/constants.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/constants/constants.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/constants/constants.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/constants/constants.ts
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..9df8253bddb773
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts
@@ -0,0 +1,437 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { Editable } from "@/ee/event-types/event-types_2024_04_15//inputs/enums/editable";
+import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
+import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
+import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
+import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
+import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
+import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
+import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/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 { withApiAuth } from "test/utils/withApiAuth";
+
+import {
+ SUCCESS_STATUS,
+ VERSION_2024_06_11,
+ VERSION_2024_04_15,
+ CAL_API_VERSION_HEADER,
+} from "@calcom/platform-constants";
+import {
+ EventTypesByViewer,
+ EventTypesPublic,
+ eventTypeBookingFields,
+ eventTypeLocations,
+} from "@calcom/platform-libraries-0.0.19";
+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_2024_04_15, 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 withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ providers: [PrismaExceptionFilter, HttpExceptionFilter],
+ imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, 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_2024_04_15 = {
+ title: "Test Event Type",
+ slug: "test-event-type",
+ description: "A description of the test event type.",
+ length: 60,
+ hidden: false,
+ disableGuests: true,
+ slotInterval: 15,
+ afterEventBuffer: 5,
+ beforeEventBuffer: 10,
+ minimumBookingNotice: 120,
+ locations: [
+ {
+ type: "Online",
+ link: "https://example.com/meet",
+ displayLocationPublicly: true,
+ },
+ ],
+ };
+
+ return request(app.getHttpServer())
+ .post("/api/v2/event-types")
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ .send(body)
+ .expect(201)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.data).toHaveProperty("id");
+ expect(responseBody.data.title).toEqual(body.title);
+ expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
+ expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
+ expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
+ expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
+ expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
+
+ eventType = responseBody.data;
+ });
+ });
+
+ it("should update event type", async () => {
+ const newTitle = "Updated title";
+
+ const body: UpdateEventTypeInput_2024_04_15 = {
+ title: newTitle,
+ disableGuests: false,
+ slotInterval: 30,
+ afterEventBuffer: 10,
+ beforeEventBuffer: 15,
+ minimumBookingNotice: 240,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/api/v2/event-types/${eventType.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ .send(body)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.data.title).toEqual(newTitle);
+ expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
+ expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
+ expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
+ expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
+ expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
+
+ eventType.title = newTitle;
+ eventType.disableGuests = responseBody.data.disableGuests ?? false;
+ eventType.slotInterval = body.slotInterval ?? null;
+ eventType.minimumBookingNotice = body.minimumBookingNotice ?? 10;
+ eventType.beforeEventBuffer = body.beforeEventBuffer ?? 10;
+ eventType.afterEventBuffer = body.afterEventBuffer ?? 10;
+ });
+ });
+
+ it("should return 400 if param event type id is null", async () => {
+ const locations = [{ type: "inPerson", address: "123 Main St" }];
+
+ const body: UpdateEventTypeInput_2024_04_15 = {
+ locations,
+ };
+
+ return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400);
+ });
+
+ it("should update event type locations", async () => {
+ const locations = [{ type: "inPerson", address: "123 Main St" }];
+
+ const body: UpdateEventTypeInput_2024_04_15 = {
+ locations,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/api/v2/event-types/${eventType.id}`)
+ .send(body)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ const responseLocations = eventTypeLocations.parse(responseBody.data.locations);
+ expect(responseLocations).toBeDefined();
+ expect(responseLocations.length).toEqual(locations.length);
+ expect(responseLocations).toEqual(locations);
+ eventType.locations = responseLocations;
+ });
+ });
+
+ it("should update event type bookingFields", async () => {
+ const bookingFieldName = "location-name";
+ const bookingFields = [
+ {
+ name: bookingFieldName,
+ type: BaseField.radio,
+ label: "Location",
+ options: [
+ {
+ label: "Via Bari 10, Roma, 90119, Italy",
+ value: "Via Bari 10, Roma, 90119, Italy",
+ },
+ {
+ label: "Via Reale 28, Roma, 9001, Italy",
+ value: "Via Reale 28, Roma, 9001, Italy",
+ },
+ ],
+ sources: [
+ {
+ id: "user",
+ type: "user",
+ label: "User",
+ fieldRequired: true,
+ },
+ ],
+ editable: Editable.user,
+ required: true,
+ placeholder: "",
+ },
+ ];
+
+ const body: UpdateEventTypeInput_2024_04_15 = {
+ bookingFields,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/api/v2/event-types/${eventType.id}`)
+ .send(body)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields);
+ expect(responseBookingFields).toBeDefined();
+ // note(Lauris): response bookingFields are already existing default bookingFields + the new one
+ const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
+ expect(responseBookingField).toEqual(bookingFields[0]);
+ eventType.bookingFields = responseBookingFields;
+ });
+ });
+
+ it(`/GET/:id`, async () => {
+ const response = await request(app.getHttpServer())
+ .get(`/api/v2/event-types/${eventType.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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/:id with version VERSION_2024_06_11`, async () => {
+ const response = await request(app.getHttpServer())
+ .get(`/api/v2/event-types/${eventType.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ // note: bearer token value mocked using "withApiAuth" 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`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ // note: bearer token value mocked using "withApiAuth" 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}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
+ .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/event-types_2024_04_15/controllers/event-types.controller.ts
similarity index 61%
rename from apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts
index 3c8c7076fbe996..9b5ba327c0ea7c 100644
--- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts
@@ -1,17 +1,22 @@
-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 { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
+import { EventTypeIdParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input";
+import { GetPublicEventTypeQueryParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input";
+import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
+import { CreateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output";
+import { DeleteEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output";
+import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
+import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
+import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output";
+import {
+ GetEventTypesData,
+ GetEventTypesOutput,
+} from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output";
+import { UpdateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output";
+import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
+import { VERSION_2024_04_15, VERSION_2024_06_11 } from "@/lib/api-versions";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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";
@@ -29,31 +34,31 @@ import {
Delete,
Query,
InternalServerErrorException,
+ ParseIntPipe,
} 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 { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
- path: "event-types",
- version: "2",
+ path: "/v2/event-types",
+ version: [VERSION_2024_04_15, VERSION_2024_06_11],
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
-export class EventTypesController {
+export class EventTypesController_2024_04_15 {
constructor(
- private readonly eventTypesService: EventTypesService,
+ private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly prismaReadService: PrismaReadService
) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async createEventType(
- @Body() body: CreateEventTypeInput,
+ @Body() body: CreateEventTypeInput_2024_04_15,
@GetUser() user: UserWithProfile
): Promise {
const eventType = await this.eventTypesService.createUserEventType(user, body);
@@ -66,9 +71,9 @@ export class EventTypesController {
@Get("/:eventTypeId")
@Permissions([EVENT_TYPE_READ])
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async getEventType(
- @Param("eventTypeId") eventTypeId: string,
+ @Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser() user: UserWithProfile
): Promise {
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
@@ -85,7 +90,7 @@ export class EventTypesController {
@Get("/")
@Permissions([EVENT_TYPE_READ])
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async getEventTypes(@GetUser() user: UserWithProfile): Promise {
const eventTypes = await getEventTypesByViewer({
id: user.id,
@@ -96,7 +101,7 @@ export class EventTypesController {
return {
status: SUCCESS_STATUS,
- data: eventTypes,
+ data: eventTypes as GetEventTypesData,
};
}
@@ -104,7 +109,7 @@ export class EventTypesController {
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
- @Query() queryParams: GetPublicEventTypeQueryParams
+ @Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
): Promise {
try {
const event = await getPublicEvent(
@@ -141,11 +146,12 @@ export class EventTypesController {
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
- @Param("eventTypeId") eventTypeId: number,
- @Body() body: UpdateEventTypeInput,
+ @Param() params: EventTypeIdParams_2024_04_15,
+ @Param("eventTypeId", ParseIntPipe) eventTypeId: number,
+ @Body() body: UpdateEventTypeInput_2024_04_15,
@GetUser() user: UserWithProfile
): Promise {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
@@ -158,9 +164,10 @@ export class EventTypesController {
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async deleteEventType(
- @Param("eventTypeId") eventTypeId: number,
+ @Param() params: EventTypeIdParams_2024_04_15,
+ @Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
): Promise {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts
new file mode 100644
index 00000000000000..7b54e9c7f7e508
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.module.ts
@@ -0,0 +1,17 @@
+import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/controllers/event-types.controller";
+import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
+import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/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_2024_04_15, EventTypesService_2024_04_15],
+ controllers: [EventTypesController_2024_04_15],
+ exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
+})
+export class EventTypesModule_2024_04_15 {}
diff --git a/apps/api/v2/src/ee/event-types/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts
similarity index 86%
rename from apps/api/v2/src/ee/event-types/event-types.repository.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts
index 560513b98255ef..e4e7b03f60d956 100644
--- a/apps/api/v2/src/ee/event-types/event-types.repository.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/event-types.repository.ts
@@ -1,19 +1,19 @@
-import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
+import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
-import type { PrismaClient } from "@calcom/prisma";
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";
+import { getEventTypeById } from "@calcom/platform-libraries-0.0.19";
+import type { PrismaClient } from "@calcom/prisma";
@Injectable()
-export class EventTypesRepository {
+export class EventTypesRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(
userId: number,
- body: Pick
+ body: Pick
) {
return this.dbWrite.prisma.eventType.create({
data: {
diff --git a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts
similarity index 65%
rename from apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts
index 469ea1f4efdc13..9bd7e346bc8b6d 100644
--- a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input.ts
@@ -1,7 +1,16 @@
-import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
+import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/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";
+import {
+ IsString,
+ IsNumber,
+ IsBoolean,
+ IsOptional,
+ ValidateNested,
+ Min,
+ IsArray,
+ IsInt,
+} from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
@@ -11,7 +20,7 @@ export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
-export class CreateEventTypeInput {
+export class CreateEventTypeInput_2024_04_15 {
@IsNumber()
@Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
@@ -37,9 +46,33 @@ export class CreateEventTypeInput {
@IsOptional()
@ValidateNested({ each: true })
- @Type(() => EventTypeLocation)
+ @Type(() => EventTypeLocation_2024_04_15)
@IsArray()
- locations?: EventTypeLocation[];
+ locations?: EventTypeLocation_2024_04_15[];
+
+ @IsBoolean()
+ @IsOptional()
+ disableGuests?: boolean;
+
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ slotInterval?: number;
+
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ minimumBookingNotice?: number;
+
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ beforeEventBuffer?: number;
+
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ afterEventBuffer?: number;
// @ApiHideProperty()
// @IsOptional()
diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/editable.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/inputs/enums/editable.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/editable.ts
diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/field-type.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/field-type.ts
diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/frequency.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/frequency.ts
diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/period-type.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/period-type.ts
diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type.ts
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts
new file mode 100644
index 00000000000000..37d108c7462122
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input.ts
@@ -0,0 +1,6 @@
+import { IsNumberString } from "class-validator";
+
+export class EventTypeIdParams_2024_04_15 {
+ @IsNumberString()
+ eventTypeId!: number;
+}
diff --git a/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts
similarity index 95%
rename from apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts
index a2584ec20e7fb3..96ca332e84f190 100644
--- a/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input.ts
@@ -4,7 +4,7 @@ import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validato
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
-export class EventTypeLocation {
+export class EventTypeLocation_2024_04_15 {
@IsString()
@DocsProperty({ example: "link" })
type!: 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/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts
similarity index 83%
rename from apps/api/v2/src/ee/event-types/inputs/get-public-event-type-query-params.input.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts
index a94dc9ea2698ce..02fc93647fdc4b 100644
--- 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/event-types_2024_04_15/inputs/get-public-event-type-query-params.input.ts
@@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
-export class GetPublicEventTypeQueryParams {
+export class GetPublicEventTypeQueryParams_2024_04_15 {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
- org?: string;
+ org?: string | null;
}
diff --git a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts
similarity index 84%
rename from apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts
index d03e451d44f369..cf9c3c901c24b9 100644
--- a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input.ts
@@ -1,7 +1,7 @@
-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 { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/editable";
+import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
+import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency";
+import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import { Type } from "class-transformer";
import {
IsString,
@@ -105,7 +105,7 @@ class VariantsConfig {
variants!: Record;
}
-export class BookingField {
+export class BookingField_2024_04_15 {
@IsEnum(BaseField)
type!: BaseField;
@@ -179,7 +179,7 @@ export class BookingField {
sources?: Source[];
}
-export class RecurringEvent {
+export class RecurringEvent_2024_04_15 {
@IsDate()
@IsOptional()
dtstart?: Date;
@@ -202,7 +202,7 @@ export class RecurringEvent {
tzid?: string;
}
-export class IntervalLimits {
+export class IntervalLimits_2024_04_15 {
@IsNumber()
@IsOptional()
PER_DAY?: number;
@@ -220,7 +220,7 @@ export class IntervalLimits {
PER_YEAR?: number;
}
-export class UpdateEventTypeInput {
+export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(1)
@IsOptional()
@@ -243,9 +243,9 @@ export class UpdateEventTypeInput {
hidden?: boolean;
@ValidateNested({ each: true })
- @Type(() => EventTypeLocation)
+ @Type(() => EventTypeLocation_2024_04_15)
@IsOptional()
- locations?: EventTypeLocation[];
+ locations?: EventTypeLocation_2024_04_15[];
// @IsInt()
// @IsOptional()
@@ -275,11 +275,11 @@ export class UpdateEventTypeInput {
// @IsOptional()
// parentId?: number;
- // @IsOptional()
- // @IsArray()
- // @ValidateNested({ each: true })
- // @Type(() => BookingField)
- // bookingFields?: BookingField[];
+ @IsOptional()
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => BookingField_2024_04_15)
+ bookingFields?: BookingField_2024_04_15[];
// @IsString()
// @IsOptional()
@@ -322,26 +322,28 @@ export class UpdateEventTypeInput {
// @IsOptional()
// recurringEvent?: RecurringEvent;
- // @IsBoolean()
- // @IsOptional()
- // disableGuests?: boolean;
+ @IsBoolean()
+ @IsOptional()
+ disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
- // @IsInt()
- // @Min(0)
- // @IsOptional()
- // minimumBookingNotice?: number;
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ minimumBookingNotice?: number;
- // @IsInt()
- // @IsOptional()
- // beforeEventBuffer?: number;
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ beforeEventBuffer?: number;
- // @IsInt()
- // @IsOptional()
- // afterEventBuffer?: number;
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
@@ -375,9 +377,10 @@ export class UpdateEventTypeInput {
// @IsOptional()
// currency?: string;
- // @IsInt()
- // @IsOptional()
- // slotInterval?: number;
+ @IsInt()
+ @Min(0)
+ @IsOptional()
+ slotInterval?: number;
// @IsString()
// @IsOptional()
diff --git a/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts
similarity index 85%
rename from apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts
index 1a77102d4c5f8c..c7675708fe99d9 100644
--- a/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output.ts
@@ -1,4 +1,4 @@
-import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
+import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
diff --git a/apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts
similarity index 92%
rename from apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts
index 738ab324995b22..76ddc81563d5f1 100644
--- a/apps/api/v2/src/ee/event-types/outputs/delete-event-type.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output.ts
@@ -2,7 +2,7 @@ import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
-} from "@/ee/event-types/inputs/create-event-type.input";
+} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
diff --git a/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts
similarity index 75%
rename from apps/api/v2/src/ee/event-types/outputs/event-type.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts
index a165dfa685fa7e..b95fe6ac801578 100644
--- a/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/event-type.output.ts
@@ -3,15 +3,15 @@ import {
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";
+} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
+import { PeriodType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/period-type";
+import { SchedulingType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type";
+import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import {
- BookingField,
- IntervalLimits,
- RecurringEvent,
-} from "@/ee/event-types/inputs/update-event-type.input";
+ BookingField_2024_04_15,
+ IntervalLimits_2024_04_15,
+ RecurringEvent_2024_04_15,
+} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
@@ -53,13 +53,14 @@ export class EventTypeOutput {
hidden!: boolean;
@ValidateNested({ each: true })
- @Type(() => EventTypeLocation)
+ @Type(() => EventTypeLocation_2024_04_15)
@IsArray()
- locations!: EventTypeLocation[] | null;
+ locations!: EventTypeLocation_2024_04_15[] | null;
@IsInt()
@ApiHideProperty()
- position!: number;
+ @IsOptional()
+ position?: number;
@IsInt()
@ApiHideProperty()
@@ -71,7 +72,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
- profileId!: number | null;
+ @IsOptional()
+ profileId?: number | null;
@IsInt()
@ApiHideProperty()
@@ -83,14 +85,15 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
- parentId!: number | null;
+ @IsOptional()
+ parentId?: number | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
- @Type(() => BookingField)
+ @Type(() => BookingField_2024_04_15)
@ApiHideProperty()
- bookingFields!: BookingField[] | null;
+ bookingFields!: BookingField_2024_04_15[] | null;
@IsString()
@ApiHideProperty()
@@ -129,10 +132,10 @@ export class EventTypeOutput {
requiresBookerEmailVerification!: boolean;
@ValidateNested()
- @Type(() => RecurringEvent)
+ @Type(() => RecurringEvent_2024_04_15)
@IsOptional()
@ApiHideProperty()
- recurringEvent!: RecurringEvent | null;
+ recurringEvent!: RecurringEvent_2024_04_15 | null;
@IsBoolean()
@ApiHideProperty()
@@ -176,7 +179,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
- scheduleId!: number | null;
+ @IsOptional()
+ scheduleId?: number | null;
@IsNumber()
@ApiHideProperty()
@@ -199,15 +203,15 @@ export class EventTypeOutput {
successRedirectUrl!: string | null;
@ValidateNested()
- @Type(() => IntervalLimits)
+ @Type(() => IntervalLimits_2024_04_15)
@IsOptional()
@ApiHideProperty()
- bookingLimits!: IntervalLimits;
+ bookingLimits!: IntervalLimits_2024_04_15;
@ValidateNested()
- @Type(() => IntervalLimits)
+ @Type(() => IntervalLimits_2024_04_15)
@ApiHideProperty()
- durationLimits!: IntervalLimits;
+ durationLimits!: IntervalLimits_2024_04_15;
@IsBoolean()
@ApiHideProperty()
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/event-types_2024_04_15/outputs/get-event-type-public.output.ts
similarity index 100%
rename from apps/api/v2/src/ee/event-types/outputs/get-event-type-public.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output.ts
diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts
similarity index 87%
rename from apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts
index 605be799e97903..4d0cc43eb41477 100644
--- a/apps/api/v2/src/ee/event-types/outputs/get-event-type.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output.ts
@@ -1,4 +1,4 @@
-import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
+import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
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/event-types_2024_04_15/outputs/get-event-types-public.output.ts
similarity index 93%
rename from apps/api/v2/src/ee/event-types/outputs/get-event-types-public.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output.ts
index c3789fce154fc5..1fecef2ffa7e63 100644
--- a/apps/api/v2/src/ee/event-types/outputs/get-event-types-public.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output.ts
@@ -2,7 +2,7 @@ import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
-} from "@/ee/event-types/inputs/create-event-type.input";
+} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
diff --git a/apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts
similarity index 85%
rename from apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts
index 735db07b935713..de791d0a7c54ce 100644
--- a/apps/api/v2/src/ee/event-types/outputs/get-event-types.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output.ts
@@ -1,4 +1,4 @@
-import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
+import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
@@ -12,7 +12,7 @@ class EventTypeGroup {
eventTypes!: EventTypeOutput[];
}
-class GetEventTypesData {
+export class GetEventTypesData {
@ValidateNested({ each: true })
@Type(() => EventTypeGroup)
@IsArray()
diff --git a/apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts
similarity index 85%
rename from apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts
index 642be558a6d930..52b68f4399da5e 100644
--- a/apps/api/v2/src/ee/event-types/outputs/update-event-type.output.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output.ts
@@ -1,4 +1,4 @@
-import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
+import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
diff --git a/apps/api/v2/src/ee/event-types/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts
similarity index 80%
rename from apps/api/v2/src/ee/event-types/services/event-types.service.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts
index e55b0b1eba056d..dc4f4b881cd5f9 100644
--- a/apps/api/v2/src/ee/event-types/services/event-types.service.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/services/event-types.service.ts
@@ -1,28 +1,36 @@
-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 { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
+import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
+import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
+import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
+import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
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 {
+ createEventType,
+ updateEventType,
+ EventTypesPublic,
+ getEventTypesPublic,
+} from "@calcom/platform-libraries-0.0.19";
import { EventType } from "@calcom/prisma/client";
@Injectable()
-export class EventTypesService {
+export class EventTypesService_2024_04_15 {
constructor(
- private readonly eventTypesRepository: EventTypesRepository,
+ private readonly eventTypesRepository: EventTypesRepository_2024_04_15,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
) {}
- async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
+ async createUserEventType(
+ user: UserWithProfile,
+ body: CreateEventTypeInput_2024_04_15
+ ): Promise {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
@@ -34,10 +42,10 @@ export class EventTypesService {
prisma: this.dbWrite.prisma,
},
});
- return eventType;
+ return eventType as EventTypeOutput;
}
- async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
+ async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_04_15) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
@@ -52,6 +60,7 @@ export class EventTypesService {
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
+ role: user.role,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
@@ -88,7 +97,7 @@ export class EventTypesService {
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
- return eventType;
+ return eventType as { eventType: EventTypeOutput };
}
async getEventTypesPublicByUsername(username: string): Promise {
@@ -113,7 +122,7 @@ export class EventTypesService {
return defaultEventTypes;
}
- async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) {
+ async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
await updateEventType({
@@ -126,9 +135,13 @@ export class EventTypesService {
},
});
- const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId);
+ const eventType = await this.getUserEventTypeForAtom(user, eventTypeId);
- return eventType;
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+
+ return eventType.eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/constants/constants.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/constants/constants.ts
new file mode 100644
index 00000000000000..690c1b4deceb6d
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/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/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts
similarity index 55%
rename from apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts
rename to apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts
index 77678f838786ba..c53d259472fb21 100644
--- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts
@@ -1,11 +1,6 @@
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 { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -14,17 +9,21 @@ 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 { 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 { withApiAuth } from "test/utils/withApiAuth";
-import { SUCCESS_STATUS } from "@calcom/platform-constants";
-import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries";
-import { ApiSuccessResponse } from "@calcom/platform-types";
+import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
+import {
+ ApiSuccessResponse,
+ CreateEventTypeInput_2024_06_14,
+ EventTypeOutput_2024_06_14,
+ UpdateEventTypeInput_2024_06_14,
+} from "@calcom/platform-types";
describe("Event types Endpoints", () => {
describe("Not authenticated", () => {
@@ -33,7 +32,7 @@ describe("Event types Endpoints", () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
- imports: [AppModule, UsersModule, EventTypesModule, TokensModule],
+ imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
@@ -68,15 +67,15 @@ describe("Event types Endpoints", () => {
const userEmail = "event-types-test-e2e@api.com";
const name = "bob-the-builder";
const username = name;
- let eventType: EventType;
+ let eventType: EventTypeOutput_2024_06_14;
let user: User;
beforeAll(async () => {
- const moduleRef = await withAccessTokenAuth(
+ const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
- imports: [AppModule, UsersModule, EventTypesModule, TokensModule],
+ imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
@@ -125,45 +124,74 @@ describe("Event types Endpoints", () => {
});
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,
+ const body: CreateEventTypeInput_2024_06_14 = {
+ title: "Coding class",
+ slug: "coding-class",
+ description: "Let's learn how to code like a pro.",
+ lengthInMinutes: 60,
locations: [
{
- type: "Online",
- link: "https://example.com/meet",
- displayLocationPublicly: true,
+ type: "integration",
+ integration: "cal-video",
+ },
+ ],
+ bookingFields: [
+ {
+ type: "select",
+ label: "select which language you want to learn",
+ slug: "select-language",
+ required: true,
+ placeholder: "select language",
+ options: ["javascript", "python", "cobol"],
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
- const responseBody: ApiSuccessResponse = response.body;
- expect(responseBody.data).toHaveProperty("id");
- expect(responseBody.data.title).toEqual(body.title);
+ const responseBody: ApiSuccessResponse = response.body;
+ const createdEventType = responseBody.data;
+ expect(createdEventType).toHaveProperty("id");
+ expect(createdEventType.title).toEqual(body.title);
+ expect(createdEventType.description).toEqual(body.description);
+ expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
+ expect(createdEventType.locations).toEqual(body.locations);
+ expect(createdEventType.bookingFields).toEqual(body.bookingFields);
+ expect(createdEventType.ownerId).toEqual(user.id);
+
eventType = responseBody.data;
});
});
it("should update event type", async () => {
- const newTitle = "Updated title";
+ const newTitle = "Coding class in Italian!";
- const body: UpdateEventTypeInput = {
+ const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(200)
- .then(async () => {
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ const updatedEventType = responseBody.data;
+ expect(updatedEventType.title).toEqual(body.title);
+
+ expect(updatedEventType.id).toEqual(eventType.id);
+ expect(updatedEventType.title).toEqual(newTitle);
+ expect(updatedEventType.description).toEqual(eventType.description);
+ expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
+ expect(updatedEventType.locations).toEqual(eventType.locations);
+ expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
+ expect(updatedEventType.ownerId).toEqual(user.id);
+
eventType.title = newTitle;
});
});
@@ -171,93 +199,74 @@ describe("Event types Endpoints", () => {
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// 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;
+ const responseBody: ApiSuccessResponse = response.body;
+ const fetchedEventType = responseBody.data;
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);
+ expect(fetchedEventType.id).toEqual(eventType.id);
+ expect(fetchedEventType.title).toEqual(eventType.title);
+ expect(fetchedEventType.description).toEqual(eventType.description);
+ expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
+ expect(fetchedEventType.locations).toEqual(eventType.locations);
+ expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
+ expect(fetchedEventType.ownerId).toEqual(user.id);
});
- it(`/GET/:username/public`, async () => {
+ it(`/GET/even-types by username`, async () => {
const response = await request(app.getHttpServer())
- .get(`/api/v2/event-types/${username}/public`)
+ .get(`/api/v2/event-types?username=${username}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// 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;
+ const responseBody: ApiSuccessResponse = 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);
- });
+ const fetchedEventType = responseBody.data?.[0];
- 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);
+ expect(fetchedEventType?.id).toEqual(eventType.id);
+ expect(fetchedEventType?.title).toEqual(eventType.title);
+ expect(fetchedEventType?.description).toEqual(eventType.description);
+ expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
+ expect(fetchedEventType?.locations).toEqual(eventType.locations);
+ expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
+ expect(fetchedEventType?.ownerId).toEqual(user.id);
});
- it(`/GET/public/:username/`, async () => {
+ it(`/GET/event-types by username and eventSlug`, async () => {
const response = await request(app.getHttpServer())
- .get(`/api/v2/event-types/${username}/public`)
+ .get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// 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;
+ const responseBody: ApiSuccessResponse = response.body;
+ const fetchedEventType = responseBody.data[0];
- 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);
+ expect(fetchedEventType?.id).toEqual(eventType.id);
+ expect(fetchedEventType?.title).toEqual(eventType.title);
+ expect(fetchedEventType?.description).toEqual(eventType.description);
+ expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
+ expect(fetchedEventType?.locations).toEqual(eventType.locations);
+ expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
+ expect(fetchedEventType?.ownerId).toEqual(user.id);
});
it(`/GET/:id not existing`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/event-types/1000`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(404);
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts
new file mode 100644
index 00000000000000..01c048c6f03df7
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts
@@ -0,0 +1,127 @@
+import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output";
+import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output";
+import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output";
+import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output";
+import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output";
+import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
+import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Param,
+ Post,
+ Body,
+ NotFoundException,
+ Patch,
+ HttpCode,
+ HttpStatus,
+ Delete,
+ Query,
+} from "@nestjs/common";
+import { ApiTags as DocsTags } from "@nestjs/swagger";
+
+import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ CreateEventTypeInput_2024_06_14,
+ UpdateEventTypeInput_2024_06_14,
+ GetEventTypesQuery_2024_06_14,
+} from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/event-types",
+ version: VERSION_2024_06_14_VALUE,
+})
+@UseGuards(PermissionsGuard)
+@DocsTags("Event types")
+export class EventTypesController_2024_06_14 {
+ constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
+
+ @Post("/")
+ @Permissions([EVENT_TYPE_WRITE])
+ @UseGuards(ApiAuthGuard)
+ async createEventType(
+ @Body() body: CreateEventTypeInput_2024_06_14,
+ @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(ApiAuthGuard)
+ async getEventTypeById(
+ @Param("eventTypeId") eventTypeId: string,
+ @GetUser() user: UserWithProfile
+ ): Promise {
+ const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventType,
+ };
+ }
+
+ @Get("/")
+ async getEventTypes(
+ @Query() queryParams: GetEventTypesQuery_2024_06_14
+ ): Promise {
+ const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventTypes,
+ };
+ }
+
+ @Patch("/:eventTypeId")
+ @Permissions([EVENT_TYPE_WRITE])
+ @UseGuards(ApiAuthGuard)
+ @HttpCode(HttpStatus.OK)
+ async updateEventType(
+ @Param("eventTypeId") eventTypeId: number,
+ @Body() body: UpdateEventTypeInput_2024_06_14,
+ @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(ApiAuthGuard)
+ 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,
+ lengthInMinutes: eventType.length,
+ slug: eventType.slug,
+ title: eventType.title,
+ },
+ };
+ }
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts
new file mode 100644
index 00000000000000..43fbc3e044218e
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.module.ts
@@ -0,0 +1,32 @@
+import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
+import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
+import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
+import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
+import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-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 { UsersService } from "@/modules/users/services/users.service";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Module } from "@nestjs/common";
+
+@Module({
+ imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
+ providers: [
+ EventTypesRepository_2024_06_14,
+ EventTypesService_2024_06_14,
+ InputEventTypesService_2024_06_14,
+ OutputEventTypesService_2024_06_14,
+ UsersRepository,
+ UsersService,
+ ],
+ controllers: [EventTypesController_2024_06_14],
+ exports: [
+ EventTypesService_2024_06_14,
+ EventTypesRepository_2024_06_14,
+ InputEventTypesService_2024_06_14,
+ OutputEventTypesService_2024_06_14,
+ ],
+})
+export class EventTypesModule_2024_06_14 {}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts
new file mode 100644
index 00000000000000..b41038585261ba
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts
@@ -0,0 +1,103 @@
+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,
+ transformApiEventTypeBookingFields,
+ transformApiEventTypeLocations,
+} from "@calcom/platform-libraries-0.0.19";
+import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
+import type { PrismaClient } from "@calcom/prisma";
+
+type InputEventTransformed = Omit<
+ CreateEventTypeInput_2024_06_14,
+ "lengthInMinutes" | "locations" | "bookingFields"
+> & {
+ length: number;
+ slug: string;
+ locations?: ReturnType;
+ bookingFields?: ReturnType;
+};
+
+@Injectable()
+export class EventTypesRepository_2024_06_14 {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async createUserEventType(userId: number, body: InputEventTransformed) {
+ return this.dbWrite.prisma.eventType.create({
+ data: {
+ ...body,
+ userId,
+ locations: body.locations,
+ bookingFields: body.bookingFields,
+ 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,
+ },
+ include: { users: true, schedule: true },
+ });
+ }
+
+ async getUserEventTypes(userId: number) {
+ return this.dbRead.prisma.eventType.findMany({
+ where: {
+ userId,
+ },
+ include: { users: true, schedule: true },
+ });
+ }
+
+ async getUserEventTypeForAtom(
+ user: UserWithProfile,
+ isUserOrganizationAdmin: boolean,
+ eventTypeId: number
+ ) {
+ return await getEventTypeById({
+ currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
+ eventTypeId,
+ userId: user.id,
+ prisma: this.dbRead.prisma as unknown as PrismaClient,
+ isUserOrganizationAdmin,
+ isTrpcCall: true,
+ });
+ }
+
+ async getEventTypeById(eventTypeId: number) {
+ return this.dbRead.prisma.eventType.findUnique({
+ where: { id: eventTypeId },
+ include: { users: true, schedule: true },
+ });
+ }
+
+ async getUserEventTypeBySlug(userId: number, slug: string) {
+ return this.dbRead.prisma.eventType.findUnique({
+ where: {
+ userId_slug: {
+ userId: userId,
+ slug: slug,
+ },
+ },
+ include: { users: true, schedule: true },
+ });
+ }
+
+ async deleteEventType(eventTypeId: number) {
+ return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
+ }
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts
new file mode 100644
index 00000000000000..89ff167e82ee0e
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class CreateEventTypeOutput_2024_06_14 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsIn([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: EventTypeOutput_2024_06_14,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => EventTypeOutput_2024_06_14)
+ data!: EventTypeOutput_2024_06_14;
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts
new file mode 100644
index 00000000000000..df64142ce4658e
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output.ts
@@ -0,0 +1,33 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { ApiProperty as DocsProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsIn, IsInt, IsString } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+import { CREATE_EVENT_LENGTH_EXAMPLE, CREATE_EVENT_TITLE_EXAMPLE } from "@calcom/platform-types";
+
+class DeleteData_2024_06_14 {
+ @IsInt()
+ @DocsProperty({ example: 1 })
+ id!: number;
+
+ @IsInt()
+ @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
+ lengthInMinutes!: number;
+
+ @IsString()
+ slug!: string;
+
+ @IsString()
+ @DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
+ title!: string;
+}
+
+export class DeleteEventTypeOutput_2024_06_14 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsIn([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @Type(() => DeleteData_2024_06_14)
+ data!: DeleteData_2024_06_14;
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts
new file mode 100644
index 00000000000000..b42034fd9c2590
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output.ts
@@ -0,0 +1,19 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsIn, ValidateNested } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class GetEventTypeOutput_2024_06_14 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsIn([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: EventTypeOutput_2024_06_14,
+ })
+ @ValidateNested()
+ @Type(() => EventTypeOutput_2024_06_14)
+ data!: EventTypeOutput_2024_06_14 | null;
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts
new file mode 100644
index 00000000000000..918b845d0edb9e
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output.ts
@@ -0,0 +1,17 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsArray, IsIn, ValidateNested } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class GetEventTypesOutput_2024_06_14 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsIn([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ValidateNested({ each: true })
+ @Type(() => EventTypeOutput_2024_06_14)
+ @IsArray()
+ data!: EventTypeOutput_2024_06_14[];
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts
new file mode 100644
index 00000000000000..7170de9f812d7a
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class UpdateEventTypeOutput_2024_06_14 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsIn([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: EventTypeOutput_2024_06_14,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => EventTypeOutput_2024_06_14)
+ data!: EventTypeOutput_2024_06_14;
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts
new file mode 100644
index 00000000000000..6a93b282a2d58f
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts
@@ -0,0 +1,258 @@
+import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
+import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
+import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
+import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
+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 { UsersService } from "@/modules/users/services/users.service";
+import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
+
+import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.19";
+import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.19";
+import { dynamicEvent } from "@calcom/platform-libraries-0.0.19";
+import {
+ CreateEventTypeInput_2024_06_14,
+ UpdateEventTypeInput_2024_06_14,
+ GetEventTypesQuery_2024_06_14,
+ EventTypeOutput_2024_06_14,
+} from "@calcom/platform-types";
+import { EventType } from "@calcom/prisma/client";
+
+@Injectable()
+export class EventTypesService_2024_06_14 {
+ constructor(
+ private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
+ private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
+ private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
+ private readonly membershipsRepository: MembershipsRepository,
+ private readonly usersRepository: UsersRepository,
+ private readonly usersService: UsersService,
+ private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
+ private readonly dbWrite: PrismaWriteService
+ ) {}
+
+ async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
+ await this.checkCanCreateEventType(user.id, body);
+ const eventTypeUser = await this.getUserToCreateEvent(user);
+ const bodyTransformed = this.inputEventTypesService.transformInputCreateEventType(body);
+ const { eventType: eventTypeCreated } = await createEventType({
+ input: bodyTransformed,
+ ctx: {
+ user: eventTypeUser,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ prisma: this.dbWrite.prisma,
+ },
+ });
+
+ const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeCreated.id);
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`);
+ }
+
+ return this.outputEventTypesService.getResponseEventType(user.id, eventType);
+ }
+
+ async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_06_14) {
+ const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
+ if (existsWithSlug) {
+ throw new BadRequestException("User already has an event type with this slug.");
+ }
+ }
+
+ async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
+ const user = await this.usersRepository.findByUsername(username);
+ if (!user) {
+ return null;
+ }
+
+ const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, eventTypeSlug);
+
+ if (!eventType) {
+ return null;
+ }
+
+ return this.outputEventTypesService.getResponseEventType(user.id, eventType);
+ }
+
+ async getEventTypesByUsername(username: string) {
+ const user = await this.usersRepository.findByUsername(username);
+ if (!user) {
+ return [];
+ }
+ return this.getUserEventTypes(user.id);
+ }
+
+ 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,
+ role: user.role,
+ 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 this.outputEventTypesService.getResponseEventType(userId, eventType);
+ }
+
+ async getUserEventTypes(userId: number) {
+ const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
+
+ const eventTypePromises = eventTypes.map(async (eventType) => {
+ return await this.outputEventTypesService.getResponseEventType(userId, eventType);
+ });
+
+ return await Promise.all(eventTypePromises);
+ }
+
+ 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 getEventTypes(queryParams: GetEventTypesQuery_2024_06_14): Promise {
+ const { username, eventSlug, usernames } = queryParams;
+
+ if (username && eventSlug) {
+ const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug);
+ return eventType ? [eventType] : [];
+ }
+
+ if (username) {
+ return await this.getEventTypesByUsername(username);
+ }
+
+ if (usernames) {
+ const dynamicEventType = await this.getDynamicEventType(usernames);
+ return [dynamicEventType];
+ }
+
+ return [];
+ }
+
+ async getDynamicEventType(usernames: string[]) {
+ const users = await this.usersService.getByUsernames(usernames);
+ const usersFiltered: UserWithProfile[] = [];
+ for (const user of users) {
+ if (user) {
+ usersFiltered.push(user);
+ }
+ }
+
+ return this.outputEventTypesService.getResponseEventType(0, {
+ ...dynamicEvent,
+ users: usersFiltered,
+ isInstantEvent: false,
+ });
+ }
+
+ 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_2024_06_14, user: UserWithProfile) {
+ this.checkCanUpdateEventType(user.id, eventTypeId);
+ const eventTypeUser = await this.getUserToUpdateEvent(user);
+ const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
+ await updateEventType({
+ input: { id: eventTypeId, ...bodyTransformed },
+ ctx: {
+ user: eventTypeUser,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ prisma: this.dbWrite.prisma,
+ },
+ });
+
+ const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+
+ return this.outputEventTypesService.getResponseEventType(user.id, 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, { id: eventTypeId, userId: existingEventType.ownerId });
+ }
+
+ 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/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts
new file mode 100644
index 00000000000000..0cae1baf8cb9e9
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from "@nestjs/common";
+
+import {
+ transformApiEventTypeBookingFields,
+ transformApiEventTypeLocations,
+} from "@calcom/platform-libraries-0.0.19";
+import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
+
+@Injectable()
+export class InputEventTypesService_2024_06_14 {
+ transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
+ const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
+ {
+ type: "integration",
+ integration: "cal-video",
+ },
+ ];
+
+ const { lengthInMinutes, locations, bookingFields, ...rest } = inputEventType;
+
+ const eventType = {
+ ...rest,
+ length: lengthInMinutes,
+ locations: this.transformInputLocations(locations || defaultLocations),
+ bookingFields: this.transformInputBookingFields(bookingFields),
+ };
+
+ return eventType;
+ }
+
+ transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
+ const { lengthInMinutes, locations, bookingFields, ...rest } = inputEventType;
+
+ const eventType = {
+ ...rest,
+ length: lengthInMinutes,
+ locations: locations ? this.transformInputLocations(locations) : undefined,
+ bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
+ };
+
+ return eventType;
+ }
+
+ transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
+ return transformApiEventTypeLocations(inputLocations);
+ }
+
+ transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
+ return transformApiEventTypeBookingFields(inputBookingFields);
+ }
+}
diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts
new file mode 100644
index 00000000000000..a5c2335e8b0416
--- /dev/null
+++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts
@@ -0,0 +1,150 @@
+import { Injectable } from "@nestjs/common";
+import type { EventType, User, Schedule } from "@prisma/client";
+
+import {
+ EventTypeMetaDataSchema,
+ userMetadata,
+ getResponseEventTypeLocations,
+ getResponseEventTypeBookingFields,
+ parseRecurringEvent,
+ TransformedLocationsSchema,
+ BookingFieldsSchema,
+} from "@calcom/platform-libraries-0.0.19";
+
+type EventTypeRelations = { users: User[]; schedule: Schedule | null };
+type DatabaseEventType = EventType & EventTypeRelations;
+
+type Input = Pick<
+ DatabaseEventType,
+ | "id"
+ | "length"
+ | "title"
+ | "description"
+ | "disableGuests"
+ | "slotInterval"
+ | "minimumBookingNotice"
+ | "beforeEventBuffer"
+ | "afterEventBuffer"
+ | "slug"
+ | "schedulingType"
+ | "requiresConfirmation"
+ | "price"
+ | "currency"
+ | "lockTimeZoneToggleOnBookingPage"
+ | "seatsPerTimeSlot"
+ | "forwardParamsSuccessRedirect"
+ | "successRedirectUrl"
+ | "seatsShowAvailabilityCount"
+ | "isInstantEvent"
+ | "locations"
+ | "bookingFields"
+ | "recurringEvent"
+ | "metadata"
+ | "users"
+ | "schedule"
+>;
+
+@Injectable()
+export class OutputEventTypesService_2024_06_14 {
+ async getResponseEventType(ownerId: number, databaseEventType: Input) {
+ const {
+ id,
+ length,
+ title,
+ description,
+ disableGuests,
+ slotInterval,
+ minimumBookingNotice,
+ beforeEventBuffer,
+ afterEventBuffer,
+ slug,
+ schedulingType,
+ requiresConfirmation,
+ price,
+ currency,
+ lockTimeZoneToggleOnBookingPage,
+ seatsPerTimeSlot,
+ forwardParamsSuccessRedirect,
+ successRedirectUrl,
+ seatsShowAvailabilityCount,
+ isInstantEvent,
+ } = databaseEventType;
+
+ const locations = this.transformLocations(databaseEventType.locations);
+ const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
+ const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
+ const metadata = this.transformMetadata(databaseEventType.metadata) || {};
+ const schedule = await this.getSchedule(databaseEventType);
+ const users = this.transformUsers(databaseEventType.users);
+
+ return {
+ id,
+ ownerId,
+ lengthInMinutes: length,
+ title,
+ slug,
+ description: description || "",
+ locations,
+ bookingFields,
+ recurringEvent,
+ disableGuests,
+ slotInterval,
+ minimumBookingNotice,
+ beforeEventBuffer,
+ afterEventBuffer,
+ schedulingType,
+ metadata,
+ requiresConfirmation,
+ price,
+ currency,
+ lockTimeZoneToggleOnBookingPage,
+ seatsPerTimeSlot,
+ forwardParamsSuccessRedirect,
+ successRedirectUrl,
+ seatsShowAvailabilityCount,
+ isInstantEvent,
+ users,
+ schedule,
+ };
+ }
+
+ transformLocations(locations: any) {
+ if (!locations) return [];
+ return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
+ }
+
+ transformBookingFields(inputBookingFields: any) {
+ if (!inputBookingFields) return [];
+ return getResponseEventTypeBookingFields(BookingFieldsSchema.parse(inputBookingFields));
+ }
+
+ transformRecurringEvent(recurringEvent: any) {
+ if (!recurringEvent) return null;
+ return parseRecurringEvent(recurringEvent);
+ }
+
+ transformMetadata(metadata: any) {
+ if (!metadata) return {};
+ return EventTypeMetaDataSchema.parse(metadata);
+ }
+
+ async getSchedule(databaseEventType: Input) {
+ return databaseEventType.schedule || null;
+ }
+
+ transformUsers(users: User[]) {
+ return users.map((user) => {
+ const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
+ return {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ avatarUrl: user.avatarUrl,
+ brandColor: user.brandColor,
+ darkBrandColor: user.darkBrandColor,
+ weekStart: user.weekStart,
+ metadata: metadata || {},
+ };
+ });
+ }
+}
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
index cd59a47225538b..be19aed4e59f48 100644
--- a/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts
+++ b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
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";
+import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
-class CalendarsServiceMock {
- async getCalendars() {
- return {
- connectedCalendars: [
- {
- integration: {
- type: "google_calendar",
- },
- },
- ],
- };
- }
-}
-
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
- .overrideProvider(CalendarsService)
- .useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
+ jest
+ .spyOn(CalendarsService.prototype, "getCalendars")
+ .mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
- it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
+ it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/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 () => {
+ it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
- it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
+ it(`/GET/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/v2/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);
+ .expect(301);
});
- it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
+ it(`/GET/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/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);
+ .expect(301);
});
- it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
+ it(`/GET/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/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);
+ .expect(301);
});
- it(`/GET/ee/gcal/check with access token but without origin`, async () => {
+ it(`/GET/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
- it(`/GET/ee/gcal/check without access token`, async () => {
+ it(`/GET/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
- it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
+ it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(400);
});
- it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
+ it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts
index 962133d08f0bd9..c9614446c0ea85 100644
--- a/apps/api/v2/src/ee/gcal/gcal.controller.ts
+++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts
@@ -2,10 +2,11 @@ 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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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";
@@ -26,16 +27,12 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags } from "@nestjs/swagger";
+import { Prisma } from "@prisma/client";
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";
+import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
@@ -44,8 +41,8 @@ const CALENDAR_SCOPES = [
// Controller for the GCalConnect Atom
@Controller({
- path: "/gcal",
- version: "2",
+ path: "/v2/gcal",
+ version: API_VERSIONS_VALUES,
})
@DocsTags("Google Calendar")
export class GcalController {
@@ -64,7 +61,7 @@ export class GcalController {
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async redirect(
@Headers("Authorization") authorization: string,
@Req() req: Request
@@ -85,60 +82,15 @@ export class GcalController {
@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 };
+ const url = new URL(this.config.get("api.url") + "/calendars/google/save");
+ url.searchParams.append("code", code);
+ url.searchParams.append("state", state);
+ return { url: url.href };
}
@Get("/check")
@HttpCode(HttpStatus.OK)
- @UseGuards(AccessTokenGuard, PermissionsGuard)
+ @UseGuards(ApiAuthGuard, PermissionsGuard)
@Permissions([APPS_READ])
async check(@GetUser("id") userId: number): Promise {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
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
index d5179acd75548c..82bc9cf48680fa 100644
--- a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts
+++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts
@@ -1,8 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
-import { SchedulesModule } from "@/ee/schedules/schedules.module";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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";
@@ -14,7 +13,7 @@ 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 { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { UserResponse } from "@calcom/platform-types";
@@ -31,17 +30,10 @@ describe("Me Endpoints", () => {
let user: User;
beforeAll(async () => {
- const moduleRef = await withAccessTokenAuth(
+ const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
- imports: [
- AppModule,
- PrismaModule,
- AvailabilitiesModule,
- UsersModule,
- TokensModule,
- SchedulesModule,
- ],
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -111,6 +103,21 @@ describe("Me Endpoints", () => {
});
});
+ it("should update user associated with access token given badly formatted timezone", async () => {
+ const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" };
+
+ return request(app.getHttpServer())
+ .patch("/v2/me")
+ .send(bodyWithBadlyFormattedTimeZone)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ expect(responseBody.data.timeZone).toEqual("America/New_York");
+ });
+ });
+
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
@@ -118,13 +125,13 @@ describe("Me Endpoints", () => {
});
it("should not update user associated with access token given invalid time format", async () => {
- const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
+ const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
return request(app.getHttpServer()).patch("/v2/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" };
+ const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
});
diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts
index 2c64df0397ed53..c22a9a606ece14 100644
--- a/apps/api/v2/src/ee/me/me.controller.ts
+++ b/apps/api/v2/src/ee/me/me.controller.ts
@@ -1,9 +1,10 @@
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 { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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";
@@ -14,15 +15,15 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
import { userSchemaResponse } from "@calcom/platform-types";
@Controller({
- path: "/me",
- version: "2",
+ path: "/v2/me",
+ version: API_VERSIONS_VALUES,
})
-@UseGuards(AccessTokenGuard, PermissionsGuard)
+@UseGuards(ApiAuthGuard, PermissionsGuard)
@DocsTags("Me")
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
- private readonly schedulesRepository: SchedulesService
+ private readonly schedulesService: SchedulesService_2024_04_15
) {}
@Get("/")
@@ -44,7 +45,7 @@ export class MeController {
): Promise {
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
if (bodySchedule.timeZone && user.defaultScheduleId) {
- await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
+ await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
timeZone: bodySchedule.timeZone,
});
}
diff --git a/apps/api/v2/src/ee/me/me.module.ts b/apps/api/v2/src/ee/me/me.module.ts
index b8802914f6c7d1..1ee07742cf412e 100644
--- a/apps/api/v2/src/ee/me/me.module.ts
+++ b/apps/api/v2/src/ee/me/me.module.ts
@@ -1,11 +1,11 @@
import { MeController } from "@/ee/me/me.controller";
-import { SchedulesModule } from "@/ee/schedules/schedules.module";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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],
+ imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
controllers: [MeController],
})
export class MeModule {}
diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts
index fdf3ef34bb995d..0e7df6730a5f31 100644
--- a/apps/api/v2/src/ee/platform-endpoints-module.ts
+++ b/apps/api/v2/src/ee/platform-endpoints-module.ts
@@ -1,10 +1,12 @@
import { BookingsModule } from "@/ee/bookings/bookings.module";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
-import { EventTypesModule } from "@/ee/event-types/event-types.module";
+import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
+import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/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 { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
+import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -13,9 +15,11 @@ import { Module } from "@nestjs/common";
imports: [
GcalModule,
ProviderModule,
- SchedulesModule,
+ SchedulesModule_2024_04_15,
+ SchedulesModule_2024_06_11,
MeModule,
- EventTypesModule,
+ EventTypesModule_2024_04_15,
+ EventTypesModule_2024_06_14,
CalendarsModule,
BookingsModule,
SlotsModule,
diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts
index 40230e2a2714ab..5df78e72734ef0 100644
--- a/apps/api/v2/src/ee/provider/provider.controller.ts
+++ b/apps/api/v2/src/ee/provider/provider.controller.ts
@@ -1,7 +1,8 @@
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
-import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -20,8 +21,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
- path: "/provider",
- version: "2",
+ path: "/v2/provider",
+ version: API_VERSIONS_VALUES,
})
@DocsTags("Cal provider")
export class CalProviderController {
@@ -44,7 +45,7 @@ export class CalProviderController {
@Get("/:clientId/access-token")
@HttpCode(HttpStatus.OK)
- @UseGuards(AccessTokenGuard)
+ @UseGuards(ApiAuthGuard)
async verifyAccessToken(
@Param("clientId") clientId: string,
@GetUser() user: UserWithProfile
diff --git a/apps/api/v2/src/ee/schedules/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules.module.ts
deleted file mode 100644
index 3fe53634ee2da2..00000000000000
--- a/apps/api/v2/src/ee/schedules/schedules.module.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-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/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts
similarity index 81%
rename from apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts
index 7e6dec77f52b23..c20768bd0bab95 100644
--- a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts
@@ -1,12 +1,11 @@
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 { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
+import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
+import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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";
@@ -17,10 +16,10 @@ 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 { withApiAuth } from "test/utils/withApiAuth";
-import { SUCCESS_STATUS } from "@calcom/platform-constants";
-import { UpdateScheduleInput } from "@calcom/platform-types";
+import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants";
+import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
describe("Schedules Endpoints", () => {
describe("User Authentication", () => {
@@ -32,23 +31,16 @@ describe("Schedules Endpoints", () => {
const userEmail = "schedules-controller-e2e@api.com";
let user: User;
- let createdSchedule: CreateScheduleOutput["data"];
+ let createdSchedule: CreateScheduleOutput_2024_04_15["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(
+ const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
- imports: [
- AppModule,
- PrismaModule,
- AvailabilitiesModule,
- UsersModule,
- TokensModule,
- SchedulesModule,
- ],
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -79,7 +71,7 @@ describe("Schedules Endpoints", () => {
const scheduleTimeZone = "Europe/Rome";
const isDefault = true;
- const body: CreateScheduleInput = {
+ const body: CreateScheduleInput_2024_04_15 = {
name: scheduleName,
timeZone: scheduleTimeZone,
isDefault,
@@ -87,10 +79,11 @@ describe("Schedules Endpoints", () => {
return request(app.getHttpServer())
.post("/api/v2/schedules")
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(201)
.then(async (response) => {
- const responseData: CreateScheduleOutput = response.body;
+ const responseData: CreateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.isDefault).toEqual(isDefault);
@@ -117,7 +110,7 @@ describe("Schedules Endpoints", () => {
.get("/api/v2/schedules/default")
.expect(200)
.then(async (response) => {
- const responseData: CreateScheduleOutput = response.body;
+ const responseData: CreateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.id).toEqual(createdSchedule.id);
@@ -137,7 +130,7 @@ describe("Schedules Endpoints", () => {
.get(`/api/v2/schedules`)
.expect(200)
.then((response) => {
- const responseData: GetSchedulesOutput = response.body;
+ const responseData: GetSchedulesOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data?.[0].id).toEqual(createdSchedule.id);
@@ -155,16 +148,17 @@ describe("Schedules Endpoints", () => {
it("should update schedule name", async () => {
const newScheduleName = "new-schedule-name";
- const body: UpdateScheduleInput = {
+ const body: UpdateScheduleInput_2024_04_15 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/api/v2/schedules/${createdSchedule.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(200)
.then((response: any) => {
- const responseData: UpdateScheduleOutput = response.body;
+ const responseData: UpdateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.schedule.name).toEqual(newScheduleName);
diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts
similarity index 59%
rename from apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts
index 1e953a9f5aec07..ad6d31c06a0af6 100644
--- a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts
@@ -1,13 +1,14 @@
-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 { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
+import { DeleteScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output";
+import { GetDefaultScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output";
+import { GetScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output";
+import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
+import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
+import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -23,27 +24,28 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
+import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
-import { UpdateScheduleInput } from "@calcom/platform-types";
+import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
-import { CreateScheduleInput } from "../inputs/create-schedule.input";
+import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input";
@Controller({
- path: "schedules",
- version: "2",
+ path: "/v2/schedules",
+ version: VERSION_2024_04_15_VALUE,
})
-@UseGuards(AccessTokenGuard, PermissionsGuard)
+@UseGuards(ApiAuthGuard, PermissionsGuard)
@DocsTags("Schedules")
-export class SchedulesController {
- constructor(private readonly schedulesService: SchedulesService) {}
+export class SchedulesController_2024_04_15 {
+ constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
@Post("/")
@Permissions([SCHEDULE_WRITE])
async createSchedule(
@GetUser() user: UserWithProfile,
- @Body() bodySchedule: CreateScheduleInput
- ): Promise {
+ @Body() bodySchedule: CreateScheduleInput_2024_04_15
+ ): Promise {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
@@ -55,8 +57,14 @@ export class SchedulesController {
@Get("/default")
@Permissions([SCHEDULE_READ])
- @ApiResponse({ status: 200, description: "Returns the default schedule", type: GetDefaultScheduleOutput })
- async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise {
+ @ApiResponse({
+ status: 200,
+ description: "Returns the default schedule",
+ type: GetDefaultScheduleOutput_2024_04_15,
+ })
+ async getDefaultSchedule(
+ @GetUser() user: UserWithProfile
+ ): Promise {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesService.formatScheduleForAtom(user, schedule)
@@ -70,10 +78,11 @@ export class SchedulesController {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
+ @Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
- ): Promise {
+ ): Promise {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
@@ -85,7 +94,7 @@ export class SchedulesController {
@Get("/")
@Permissions([SCHEDULE_READ])
- async getSchedules(@GetUser() user: UserWithProfile): Promise {
+ async getSchedules(@GetUser() user: UserWithProfile): Promise {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
@@ -100,9 +109,9 @@ export class SchedulesController {
@Permissions([SCHEDULE_WRITE])
async updateSchedule(
@GetUser() user: UserWithProfile,
- @Body() bodySchedule: UpdateScheduleInput,
+ @Body() bodySchedule: UpdateScheduleInput_2024_04_15,
@Param("scheduleId") scheduleId: string
- ): Promise {
+ ): Promise {
const updatedSchedule = await this.schedulesService.updateUserSchedule(
user,
Number(scheduleId),
@@ -121,7 +130,7 @@ export class SchedulesController {
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
- ): Promise {
+ ): Promise {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
diff --git a/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts
similarity index 96%
rename from apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts
index f03698705b2afc..2c3724c20c8229 100644
--- a/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-availability.input.ts
@@ -3,7 +3,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Transform, TransformFnParams } from "class-transformer";
import { IsArray, IsDate, IsNumber } from "class-validator";
-export class CreateAvailabilityInput {
+export class CreateAvailabilityInput_2024_04_15 {
@IsArray()
@IsNumber({}, { each: true })
@ApiProperty({ example: [1, 2] })
diff --git a/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts
similarity index 53%
rename from apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts
index f90c291adf02a0..fad2d245f06c60 100644
--- a/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input.ts
@@ -1,8 +1,8 @@
-import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
+import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
-export class CreateScheduleInput {
+export class CreateScheduleInput_2024_04_15 {
@IsString()
name!: string;
@@ -11,9 +11,9 @@ export class CreateScheduleInput {
@IsArray()
@ValidateNested({ each: true })
- @Type(() => CreateAvailabilityInput)
+ @Type(() => CreateAvailabilityInput_2024_04_15)
@IsOptional()
- availabilities?: CreateAvailabilityInput[];
+ availabilities?: CreateAvailabilityInput_2024_04_15[];
@IsBoolean()
isDefault!: boolean;
diff --git a/apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts
similarity index 80%
rename from apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts
index bbcdd1788700d4..6a1184683e769c 100644
--- a/apps/api/v2/src/ee/schedules/outputs/create-schedule.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output.ts
@@ -1,11 +1,11 @@
-import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
+import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
+export class CreateScheduleOutput_2024_04_15 {
@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/delete-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts
similarity index 88%
rename from apps/api/v2/src/ee/schedules/outputs/delete-schedule.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts
index 023c013170ffea..d6427fb7045788 100644
--- a/apps/api/v2/src/ee/schedules/outputs/delete-schedule.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output.ts
@@ -3,7 +3,7 @@ import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
-export class DeleteScheduleOutput {
+export class DeleteScheduleOutput_2024_04_15 {
@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/schedules_2024_04_15/outputs/get-default-schedule.output.ts
similarity index 79%
rename from apps/api/v2/src/ee/schedules/outputs/get-default-schedule.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output.ts
index cf8369fa21cbcc..0d14fb73268fac 100644
--- a/apps/api/v2/src/ee/schedules/outputs/get-default-schedule.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output.ts
@@ -1,11 +1,11 @@
-import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
+import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
+export class GetDefaultScheduleOutput_2024_04_15 {
@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-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts
similarity index 80%
rename from apps/api/v2/src/ee/schedules/outputs/get-schedule.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts
index f49e291e23a8c9..ae7709e0ba3602 100644
--- a/apps/api/v2/src/ee/schedules/outputs/get-schedule.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output.ts
@@ -1,11 +1,11 @@
-import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
+import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
+export class GetScheduleOutput_2024_04_15 {
@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-schedules.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts
similarity index 81%
rename from apps/api/v2/src/ee/schedules/outputs/get-schedules.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts
index 9f7f6ce32f918d..81a9b911bd5b03 100644
--- a/apps/api/v2/src/ee/schedules/outputs/get-schedules.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output.ts
@@ -1,11 +1,11 @@
-import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
+import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
+export class GetSchedulesOutput_2024_04_15 {
@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/schedule-updated.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts
similarity index 77%
rename from apps/api/v2/src/ee/schedules/outputs/schedule-updated.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts
index a16600db982ebe..036e84935ad761 100644
--- a/apps/api/v2/src/ee/schedules/outputs/schedule-updated.output.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output.ts
@@ -1,7 +1,7 @@
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
-class EventTypeModel {
+class EventTypeModel_2024_04_15 {
@IsInt()
id!: number;
@@ -10,7 +10,7 @@ class EventTypeModel {
eventName?: string | null;
}
-class AvailabilityModel {
+class AvailabilityModel_2024_04_15 {
@IsInt()
id!: number;
@@ -46,7 +46,7 @@ class AvailabilityModel {
date?: Date | null;
}
-class ScheduleModel {
+class ScheduleModel_2024_04_15 {
@IsInt()
id!: number;
@@ -62,21 +62,21 @@ class ScheduleModel {
@IsOptional()
@ValidateNested({ each: true })
- @Type(() => EventTypeModel)
+ @Type(() => EventTypeModel_2024_04_15)
@IsArray()
- eventType?: EventTypeModel[];
+ eventType?: EventTypeModel_2024_04_15[];
@IsOptional()
@ValidateNested({ each: true })
- @Type(() => AvailabilityModel)
+ @Type(() => AvailabilityModel_2024_04_15)
@IsArray()
- availability?: AvailabilityModel[];
+ availability?: AvailabilityModel_2024_04_15[];
}
-export class UpdatedScheduleOutput {
+export class UpdatedScheduleOutput_2024_04_15 {
@ValidateNested()
- @Type(() => ScheduleModel)
- schedule!: ScheduleModel;
+ @Type(() => ScheduleModel_2024_04_15)
+ schedule!: ScheduleModel_2024_04_15;
@IsBoolean()
isDefault!: boolean;
diff --git a/apps/api/v2/src/ee/schedules/outputs/schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule.output.ts
similarity index 100%
rename from apps/api/v2/src/ee/schedules/outputs/schedule.output.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/schedule.output.ts
diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts
new file mode 100644
index 00000000000000..8633a77d1205a0
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output.ts
@@ -0,0 +1,20 @@
+import { UpdatedScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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_2024_04_15 {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: UpdatedScheduleOutput_2024_04_15,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => UpdatedScheduleOutput_2024_04_15)
+ data!: UpdatedScheduleOutput_2024_04_15;
+}
diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts
new file mode 100644
index 00000000000000..c6564123a58006
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.module.ts
@@ -0,0 +1,15 @@
+import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
+import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
+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, UsersModule, TokensModule],
+ providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
+ controllers: [SchedulesController_2024_04_15],
+ exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
+})
+export class SchedulesModule_2024_04_15 {}
diff --git a/apps/api/v2/src/ee/schedules/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts
similarity index 84%
rename from apps/api/v2/src/ee/schedules/schedules.repository.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts
index b447b6b4691413..a17f1ae6c4dc8a 100644
--- a/apps/api/v2/src/ee/schedules/schedules.repository.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/schedules.repository.ts
@@ -1,18 +1,18 @@
-import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
-import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
+import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
+import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.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 {
+export class SchedulesRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createScheduleWithAvailabilities(
userId: number,
- schedule: CreateScheduleInput,
- availabilities: CreateAvailabilityInput[]
+ schedule: CreateScheduleInput_2024_04_15,
+ availabilities: CreateAvailabilityInput_2024_04_15[]
) {
const createScheduleData: Prisma.ScheduleCreateInput = {
user: {
diff --git a/apps/api/v2/src/ee/schedules/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts
similarity index 77%
rename from apps/api/v2/src/ee/schedules/services/schedules.service.ts
rename to apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts
index d74abd6cebf0c4..3d5d1d4a9015dd 100644
--- a/apps/api/v2/src/ee/schedules/services/schedules.service.ts
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts
@@ -1,33 +1,42 @@
-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 { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
+import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
+import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
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 type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
+import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
-} from "@calcom/platform-libraries";
-import { UpdateScheduleInput } from "@calcom/platform-types";
+} from "@calcom/platform-libraries-0.0.2";
+import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
@Injectable()
-export class SchedulesService {
+export class SchedulesService_2024_04_15 {
constructor(
- private readonly schedulesRepository: SchedulesRepository,
- private readonly availabilitiesService: AvailabilitiesService,
+ private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository
) {}
- async createUserSchedule(userId: number, schedule: CreateScheduleInput) {
+ async createUserDefaultSchedule(userId: number, timeZone: string) {
+ const schedule = {
+ isDefault: true,
+ name: "Default schedule",
+ timeZone,
+ };
+
+ return this.createUserSchedule(userId, schedule);
+ }
+
+ async createUserSchedule(userId: number, schedule: CreateScheduleInput_2024_04_15) {
const availabilities = schedule.availabilities?.length
? schedule.availabilities
- : [this.availabilitiesService.getDefaultAvailabilityInput()];
+ : [this.getDefaultAvailabilityInput()];
const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities(
userId,
@@ -66,7 +75,11 @@ export class SchedulesService {
return this.schedulesRepository.getSchedulesByUserId(userId);
}
- async updateUserSchedule(user: UserWithProfile, scheduleId: number, bodySchedule: UpdateScheduleInput) {
+ async updateUserSchedule(
+ user: UserWithProfile,
+ scheduleId: number,
+ bodySchedule: UpdateScheduleInput_2024_04_15
+ ) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
@@ -148,4 +161,15 @@ export class SchedulesService {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
}
}
+
+ getDefaultAvailabilityInput(): CreateAvailabilityInput_2024_04_15 {
+ 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/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..4c40fab807d3c3
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts
@@ -0,0 +1,238 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+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 { withApiAuth } from "test/utils/withApiAuth";
+
+import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants";
+import {
+ CreateScheduleInput_2024_06_11,
+ CreateScheduleOutput_2024_06_11,
+ GetScheduleOutput_2024_06_11,
+ GetSchedulesOutput_2024_06_11,
+ ScheduleOutput_2024_06_11,
+ UpdateScheduleOutput_2024_06_11,
+} from "@calcom/platform-types";
+import { UpdateScheduleInput_2024_06_11 } 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;
+
+ const createScheduleInput: CreateScheduleInput_2024_06_11 = {
+ name: "work",
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+
+ const defaultAvailability: CreateScheduleInput_2024_06_11["availability"] = [
+ {
+ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ startTime: "09:00",
+ endTime: "17:00",
+ },
+ ];
+
+ let createdSchedule: CreateScheduleOutput_2024_06_11["data"];
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11],
+ })
+ )
+ .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 () => {
+ return request(app.getHttpServer())
+ .post("/api/v2/schedules")
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ .send(createScheduleInput)
+ .expect(201)
+ .then(async (response) => {
+ const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ createdSchedule = response.body.data;
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+ outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
+
+ const scheduleOwner = createdSchedule.ownerId
+ ? await userRepositoryFixture.get(createdSchedule.ownerId)
+ : null;
+ expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
+ });
+ });
+
+ function outputScheduleMatchesExpected(
+ outputSchedule: ScheduleOutput_2024_06_11 | null,
+ expected: CreateScheduleInput_2024_06_11 & {
+ availability: CreateScheduleInput_2024_06_11["availability"];
+ } & {
+ overrides: CreateScheduleInput_2024_06_11["overrides"];
+ },
+ expectedAvailabilityLength: number
+ ) {
+ expect(outputSchedule).toBeTruthy();
+ expect(outputSchedule?.name).toEqual(expected.name);
+ expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
+ expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
+ expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
+
+ const outputScheduleAvailability = outputSchedule?.availability[0];
+ expect(outputScheduleAvailability).toBeDefined();
+ expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
+ expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
+ expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
+
+ expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
+ }
+
+ it("should get default schedule", async () => {
+ return request(app.getHttpServer())
+ .get("/api/v2/schedules/default")
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: GetScheduleOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ const outputSchedule = responseBody.data;
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+ outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
+ });
+ });
+
+ it("should get schedules", async () => {
+ return request(app.getHttpServer())
+ .get(`/api/v2/schedules`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ .expect(200)
+ .then((response) => {
+ const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ const outputSchedule = responseBody.data[0];
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+ outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
+ });
+ });
+
+ it("should update schedule name", async () => {
+ const newScheduleName = "updated-schedule-name";
+
+ const body: UpdateScheduleInput_2024_06_11 = {
+ name: newScheduleName,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/api/v2/schedules/${createdSchedule.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ .send(body)
+ .expect(200)
+ .then((response: any) => {
+ const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
+ expect(responseData.status).toEqual(SUCCESS_STATUS);
+ const responseSchedule = responseData.data;
+
+ const expectedSchedule = { ...createdSchedule, name: newScheduleName };
+ outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
+
+ createdSchedule = responseSchedule;
+ });
+ });
+
+ it("should add overrides", async () => {
+ const overrides = [
+ {
+ date: "2026-05-05",
+ startTime: "10:00",
+ endTime: "12:00",
+ },
+ ];
+
+ const body: UpdateScheduleInput_2024_06_11 = {
+ overrides,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/api/v2/schedules/${createdSchedule.id}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
+ .send(body)
+ .expect(200)
+ .then((response: any) => {
+ const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
+ expect(responseData.status).toEqual(SUCCESS_STATUS);
+ const responseSchedule = responseData.data;
+
+ const expectedSchedule = { ...createdSchedule, overrides };
+ outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
+
+ createdSchedule = responseSchedule;
+ });
+ });
+
+ 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/schedules_2024_06_11/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts
new file mode 100644
index 00000000000000..bb60283f078c8f
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts
@@ -0,0 +1,132 @@
+import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
+import { VERSION_2024_06_14, VERSION_2024_06_11 } from "@/lib/api-versions";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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 { Throttle } from "@nestjs/throttler";
+
+import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ CreateScheduleOutput_2024_06_11,
+ CreateScheduleInput_2024_06_11,
+ UpdateScheduleInput_2024_06_11,
+ GetScheduleOutput_2024_06_11,
+ UpdateScheduleOutput_2024_06_11,
+ GetDefaultScheduleOutput_2024_06_11,
+ DeleteScheduleOutput_2024_06_11,
+ GetSchedulesOutput_2024_06_11,
+} from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/schedules",
+ version: [VERSION_2024_06_14, VERSION_2024_06_11],
+})
+@UseGuards(ApiAuthGuard, PermissionsGuard)
+@DocsTags("Schedules")
+export class SchedulesController_2024_06_11 {
+ constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
+
+ @Post("/")
+ @Permissions([SCHEDULE_WRITE])
+ async createSchedule(
+ @GetUser() user: UserWithProfile,
+ @Body() bodySchedule: CreateScheduleInput_2024_06_11
+ ): Promise {
+ const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedule,
+ };
+ }
+
+ @Get("/default")
+ @Permissions([SCHEDULE_READ])
+ @ApiResponse({
+ status: 200,
+ description: "Returns the default schedule",
+ type: GetDefaultScheduleOutput_2024_06_11,
+ })
+ async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise {
+ const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedule,
+ };
+ }
+
+ @Get("/:scheduleId")
+ @Permissions([SCHEDULE_READ])
+ @Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
+ async getSchedule(
+ @GetUser() user: UserWithProfile,
+ @Param("scheduleId") scheduleId: number
+ ): Promise {
+ const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedule,
+ };
+ }
+
+ @Get("/")
+ @Permissions([SCHEDULE_READ])
+ async getSchedules(@GetUser() user: UserWithProfile): Promise {
+ const schedules = await this.schedulesService.getUserSchedules(user.id);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedules,
+ };
+ }
+
+ @Patch("/:scheduleId")
+ @Permissions([SCHEDULE_WRITE])
+ async updateSchedule(
+ @GetUser() user: UserWithProfile,
+ @Body() bodySchedule: UpdateScheduleInput_2024_06_11,
+ @Param("scheduleId") scheduleId: string
+ ): Promise {
+ const updatedSchedule = await this.schedulesService.updateUserSchedule(
+ user.id,
+ 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/schedules_2024_06_11/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.module.ts
new file mode 100644
index 00000000000000..abd2f7cf985a9f
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.module.ts
@@ -0,0 +1,22 @@
+import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller";
+import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
+import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
+import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
+import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
+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, UsersModule, TokensModule],
+ providers: [
+ SchedulesRepository_2024_06_11,
+ SchedulesService_2024_06_11,
+ InputSchedulesService_2024_06_11,
+ OutputSchedulesService_2024_06_11,
+ ],
+ controllers: [SchedulesController_2024_06_11],
+ exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11],
+})
+export class SchedulesModule_2024_06_11 {}
diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts
new file mode 100644
index 00000000000000..ad9cecc3a137e8
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/schedules.repository.ts
@@ -0,0 +1,208 @@
+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 type { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
+
+type InputScheduleAvailabilityTransformed = {
+ days: number[];
+ startTime: Date;
+ endTime: Date;
+};
+
+type InputScheduleOverrideTransformed = {
+ date: Date;
+ startTime: Date;
+ endTime: Date;
+};
+
+type InputScheduleTransformed = Omit & {
+ availability: InputScheduleAvailabilityTransformed[];
+ overrides: InputScheduleOverrideTransformed[];
+};
+
+@Injectable()
+export class SchedulesRepository_2024_06_11 {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async createSchedule(userId: number, schedule: Omit) {
+ const { availability, overrides } = schedule;
+
+ const createScheduleData: Prisma.ScheduleCreateInput = {
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ name: schedule.name,
+ timeZone: schedule.timeZone,
+ };
+
+ const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
+
+ if (availability && availability.length > 0) {
+ availability.forEach((availability) => {
+ availabilitiesAndOverrides.push({
+ days: availability.days,
+ startTime: availability.startTime,
+ endTime: availability.endTime,
+ userId,
+ });
+ });
+ }
+
+ if (overrides && overrides.length > 0) {
+ overrides.forEach((override) => {
+ availabilitiesAndOverrides.push({
+ date: override.date,
+ startTime: override.startTime,
+ endTime: override.endTime,
+ userId,
+ });
+ });
+ }
+
+ if (availabilitiesAndOverrides.length > 0) {
+ createScheduleData.availability = {
+ createMany: {
+ data: availabilitiesAndOverrides,
+ },
+ };
+ }
+
+ 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 updateSchedule(
+ userId: number,
+ scheduleId: number,
+ schedule: Partial>
+ ) {
+ const { availability, overrides } = schedule;
+
+ const updateScheduleData: Prisma.ScheduleUpdateInput = {
+ name: schedule.name,
+ timeZone: schedule.timeZone,
+ };
+
+ const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
+
+ const deleteConditions = [];
+ if (availability) {
+ // note(Lauris): availabilities and overrides are stored in the same "Availability" table,
+ // but availabilities have "date" field as null, while overrides have it as not null, so delete
+ // condition below results in deleting only rows from Availability table that are availabilities.
+ deleteConditions.push({
+ scheduleId: { equals: scheduleId },
+ date: null,
+ });
+ }
+
+ if (overrides) {
+ // note(Lauris): availabilities and overrides are stored in the same "Availability" table,
+ // but overrides have "date" field as not-null, while availabilities have it as null, so delete
+ // condition below results in deleting only rows from Availability table that are overrides.
+ deleteConditions.push({
+ scheduleId: { equals: scheduleId },
+ NOT: { date: null },
+ });
+ }
+
+ if (availability && availability.length > 0) {
+ availability.forEach((availability) => {
+ availabilitiesAndOverrides.push({
+ days: availability.days,
+ startTime: availability.startTime,
+ endTime: availability.endTime,
+ userId,
+ });
+ });
+ }
+
+ if (overrides && overrides.length > 0) {
+ overrides.forEach((override) => {
+ availabilitiesAndOverrides.push({
+ date: override.date,
+ startTime: override.startTime,
+ endTime: override.endTime,
+ userId,
+ });
+ });
+ }
+
+ if (availabilitiesAndOverrides.length > 0) {
+ updateScheduleData.availability = {
+ deleteMany: deleteConditions,
+ createMany: {
+ data: availabilitiesAndOverrides,
+ },
+ };
+ }
+
+ const updatedSchedule = await this.dbWrite.prisma.schedule.update({
+ where: {
+ id: scheduleId,
+ },
+ data: {
+ ...updateScheduleData,
+ },
+ include: {
+ availability: true,
+ },
+ });
+
+ return updatedSchedule;
+ }
+
+ 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/schedules_2024_06_11/services/input-schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts
new file mode 100644
index 00000000000000..fe8adc8b4c5eea
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/input-schedules.service.ts
@@ -0,0 +1,43 @@
+import { Injectable } from "@nestjs/common";
+
+import {
+ transformApiScheduleOverrides,
+ transformApiScheduleAvailability,
+} from "@calcom/platform-libraries-0.0.19";
+import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
+import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
+
+@Injectable()
+export class InputSchedulesService_2024_06_11 {
+ transformInputCreateSchedule(inputSchedule: CreateScheduleInput_2024_06_11) {
+ const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
+ {
+ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ startTime: "09:00",
+ endTime: "17:00",
+ },
+ ];
+ const defaultOverrides: ScheduleOverrideInput_2024_06_11[] = [];
+
+ const availability = this.transformInputScheduleAvailability(
+ inputSchedule.availability || defaultAvailability
+ );
+ const overrides = this.transformInputOverrides(inputSchedule.overrides || defaultOverrides);
+
+ const internalCreateSchedule = {
+ ...inputSchedule,
+ availability,
+ overrides,
+ };
+
+ return internalCreateSchedule;
+ }
+
+ transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) {
+ return transformApiScheduleAvailability(inputAvailability);
+ }
+
+ transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) {
+ return transformApiScheduleOverrides(inputOverrides);
+ }
+}
diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts
new file mode 100644
index 00000000000000..222f423e7f6a87
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/output-schedules.service.ts
@@ -0,0 +1,78 @@
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Injectable } from "@nestjs/common";
+import type { Availability, Schedule } from "@prisma/client";
+
+import { WeekDay } from "@calcom/platform-types";
+
+@Injectable()
+export class OutputSchedulesService_2024_06_11 {
+ constructor(private readonly usersRepository: UsersRepository) {}
+
+ async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
+ if (!databaseSchedule.timeZone) {
+ throw new Error("Failed to create schedule because its timezone is not set.");
+ }
+
+ const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
+ databaseSchedule.userId
+ );
+
+ const createdScheduleAvailabilities = databaseSchedule.availability.filter(
+ (availability) => !!availability.days.length
+ );
+ const createdScheduleOverrides = databaseSchedule.availability.filter((override) => !!override.date);
+
+ return {
+ id: databaseSchedule.id,
+ ownerId: databaseSchedule.userId,
+ name: databaseSchedule.name,
+ timeZone: databaseSchedule.timeZone,
+ availability: createdScheduleAvailabilities.map((availability) => ({
+ days: availability.days.map(transformNumberToDay),
+ startTime: this.padHoursMinutesWithZeros(
+ availability.startTime.getUTCHours() + ":" + availability.startTime.getUTCMinutes()
+ ),
+ endTime: this.padHoursMinutesWithZeros(
+ availability.endTime.getUTCHours() + ":" + availability.endTime.getUTCMinutes()
+ ),
+ })),
+ isDefault: databaseSchedule.id === ownerDefaultScheduleId,
+ overrides: createdScheduleOverrides.map((override) => ({
+ date:
+ override.date?.getUTCFullYear() +
+ "-" +
+ (override.date ? override.date.getUTCMonth() + 1 : "").toString().padStart(2, "0") +
+ "-" +
+ override.date?.getUTCDate().toString().padStart(2, "0"),
+ startTime: this.padHoursMinutesWithZeros(
+ override.startTime.getUTCHours() + ":" + override.startTime.getUTCMinutes()
+ ),
+ endTime: this.padHoursMinutesWithZeros(
+ override.endTime.getUTCHours() + ":" + override.endTime.getUTCMinutes()
+ ),
+ })),
+ };
+ }
+
+ padHoursMinutesWithZeros(hhMM: string) {
+ const [hours, minutes] = hhMM.split(":");
+
+ const formattedHours = hours.padStart(2, "0");
+ const formattedMinutes = minutes.padStart(2, "0");
+
+ return `${formattedHours}:${formattedMinutes}`;
+ }
+}
+
+function transformNumberToDay(day: number): WeekDay {
+ const weekMap: { [key: number]: WeekDay } = {
+ 0: "Sunday",
+ 1: "Monday",
+ 2: "Tuesday",
+ 3: "Wednesday",
+ 4: "Thursday",
+ 5: "Friday",
+ 6: "Saturday",
+ };
+ return weekMap[day];
+}
diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts
new file mode 100644
index 00000000000000..d68400a5721d41
--- /dev/null
+++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/services/schedules.service.ts
@@ -0,0 +1,123 @@
+import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
+import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
+import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
+import { Schedule } from "@prisma/client";
+
+import { CreateScheduleInput_2024_06_11, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
+import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
+
+@Injectable()
+export class SchedulesService_2024_06_11 {
+ constructor(
+ private readonly schedulesRepository: SchedulesRepository_2024_06_11,
+ private readonly inputSchedulesService: InputSchedulesService_2024_06_11,
+ private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
+ private readonly usersRepository: UsersRepository
+ ) {}
+
+ async createUserDefaultSchedule(userId: number, timeZone: string) {
+ const defaultSchedule = {
+ isDefault: true,
+ name: "Default schedule",
+ timeZone,
+ };
+
+ return this.createUserSchedule(userId, defaultSchedule);
+ }
+
+ async createUserSchedule(
+ userId: number,
+ scheduleInput: CreateScheduleInput_2024_06_11
+ ): Promise {
+ const schedule = this.inputSchedulesService.transformInputCreateSchedule(scheduleInput);
+
+ const createdSchedule = await this.schedulesRepository.createSchedule(userId, schedule);
+
+ if (schedule.isDefault) {
+ await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
+ }
+
+ return this.outputSchedulesService.getResponseSchedule(createdSchedule);
+ }
+
+ async getUserScheduleDefault(userId: number) {
+ const user = await this.usersRepository.findById(userId);
+
+ if (!user?.defaultScheduleId) return null;
+
+ const defaultSchedule = await this.schedulesRepository.getScheduleById(user.defaultScheduleId);
+
+ if (!defaultSchedule) return null;
+ return this.outputSchedulesService.getResponseSchedule(defaultSchedule);
+ }
+
+ 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 this.outputSchedulesService.getResponseSchedule(existingSchedule);
+ }
+
+ async getUserSchedules(userId: number) {
+ const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
+ return Promise.all(
+ schedules.map(async (schedule) => {
+ return this.outputSchedulesService.getResponseSchedule(schedule);
+ })
+ );
+ }
+
+ async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) {
+ const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
+
+ if (!existingSchedule) {
+ throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
+ }
+
+ this.checkUserOwnsSchedule(userId, existingSchedule);
+
+ const availability = bodySchedule.availability
+ ? this.inputSchedulesService.transformInputScheduleAvailability(bodySchedule.availability)
+ : undefined;
+ const overrides = bodySchedule.overrides
+ ? this.inputSchedulesService.transformInputOverrides(bodySchedule.overrides)
+ : undefined;
+
+ if (bodySchedule.isDefault) {
+ await this.usersRepository.setDefaultSchedule(userId, scheduleId);
+ }
+
+ const updatedSchedule = await this.schedulesRepository.updateSchedule(userId, scheduleId, {
+ ...bodySchedule,
+ availability,
+ overrides,
+ });
+
+ return this.outputSchedulesService.getResponseSchedule(updatedSchedule);
+ }
+
+ 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);
+ }
+
+ 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
index d1840bf6a52b7c..85bd516582d0be 100644
--- a/apps/api/v2/src/env.ts
+++ b/apps/api/v2/src/env.ts
@@ -12,6 +12,13 @@ export type Environment = {
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
+ STRIPE_API_KEY: string;
+ STRIPE_WEBHOOK_SECRET: string;
+ WEB_APP_URL: string;
+ IS_E2E: boolean;
+ CALCOM_LICENSE_KEY: string;
+ GET_LICENSE_KEY_URL: string;
+ API_KEY_PREFIX: string;
};
export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => {
diff --git a/apps/api/v2/src/filters/http-exception.filter.ts b/apps/api/v2/src/filters/http-exception.filter.ts
index c47e1ff936c9df..2aa35ba002c67a 100644
--- a/apps/api/v2/src/filters/http-exception.filter.ts
+++ b/apps/api/v2/src/filters/http-exception.filter.ts
@@ -13,18 +13,22 @@ export class HttpExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse();
const request = ctx.getRequest();
const statusCode = exception.getStatus();
+ const requestId = request.headers["X-Request-Id"];
+
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
+ requestId,
});
+
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
- error: { code: exception.name, message: exception.message },
+ error: { code: exception.name, message: exception.message, details: exception.getResponse() },
});
}
}
diff --git a/apps/api/v2/src/filters/prisma-exception.filter.ts b/apps/api/v2/src/filters/prisma-exception.filter.ts
index 4490a0de8c8ac1..9bd1f82d10b23a 100644
--- a/apps/api/v2/src/filters/prisma-exception.filter.ts
+++ b/apps/api/v2/src/filters/prisma-exception.filter.ts
@@ -33,12 +33,15 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
+ const requestId = request.headers["X-Request-Id"];
+
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
+ requestId,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
diff --git a/apps/api/v2/src/filters/sentry-exception.filter.ts b/apps/api/v2/src/filters/sentry-exception.filter.ts
deleted file mode 100644
index 479525ecf47c20..00000000000000
--- a/apps/api/v2/src/filters/sentry-exception.filter.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-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
index e65e94af1628c7..07360463eb46a5 100644
--- a/apps/api/v2/src/filters/trpc-exception.filter.ts
+++ b/apps/api/v2/src/filters/trpc-exception.filter.ts
@@ -2,7 +2,7 @@ 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 { TRPCError } from "@calcom/platform-libraries-0.0.19";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -41,12 +41,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
break;
}
+ const requestId = request.headers["X-Request-Id"];
+
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
+ requestId,
});
response.status(statusCode).json({
diff --git a/apps/api/v2/src/filters/zod-exception.filter.ts b/apps/api/v2/src/filters/zod-exception.filter.ts
index eb962ff5fa001a..7a61b5c71b39ac 100644
--- a/apps/api/v2/src/filters/zod-exception.filter.ts
+++ b/apps/api/v2/src/filters/zod-exception.filter.ts
@@ -14,6 +14,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
+ const requestId = request.headers["X-Request-Id"];
this.logger.error(`ZodError: ${error.message}`, {
error,
@@ -21,6 +22,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
headers: request.headers,
url: request.url,
method: request.method,
+ requestId,
});
response.status(HttpStatus.BAD_REQUEST).json({
diff --git a/apps/api/v2/src/instrument.ts b/apps/api/v2/src/instrument.ts
new file mode 100644
index 00000000000000..77c172854e2057
--- /dev/null
+++ b/apps/api/v2/src/instrument.ts
@@ -0,0 +1,12 @@
+import { getEnv } from "@/env";
+import * as Sentry from "@sentry/node";
+
+if (process.env.SENTRY_DSN) {
+ // Ensure to call this before requiring any other modules!
+ Sentry.init({
+ dsn: getEnv("SENTRY_DSN"),
+ // Add Performance Monitoring by setting tracesSampleRate
+ // We recommend adjusting this value in production
+ // todo: Evaluate? tracesSampleRate: 1.0
+ });
+}
diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts
index d292bd06e8bdce..ad7ec0d2a9f3e7 100644
--- a/apps/api/v2/src/lib/api-key/index.ts
+++ b/apps/api/v2/src/lib/api-key/index.ts
@@ -1,3 +1,8 @@
import { createHash } from "crypto";
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
+
+export const isApiKey = (authString: string, prefix: string): boolean =>
+ authString?.startsWith(prefix ?? "cal_");
+
+export const stripApiKey = (apiKey: string, prefix?: string): string => apiKey.replace(prefix ?? "cal_", "");
diff --git a/apps/api/v2/src/lib/api-versions.ts b/apps/api/v2/src/lib/api-versions.ts
new file mode 100644
index 00000000000000..62a70a4b83a372
--- /dev/null
+++ b/apps/api/v2/src/lib/api-versions.ts
@@ -0,0 +1,17 @@
+import { VersionValue } from "@nestjs/common/interfaces";
+
+import {
+ API_VERSIONS,
+ VERSION_2024_04_15,
+ VERSION_2024_06_11,
+ VERSION_2024_06_14,
+} from "@calcom/platform-constants";
+
+export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
+export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unknown as VersionValue;
+export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
+export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
+
+export { VERSION_2024_04_15 };
+export { VERSION_2024_06_11 };
+export { VERSION_2024_06_14 };
diff --git a/apps/api/v2/src/lib/enums/locales.ts b/apps/api/v2/src/lib/enums/locales.ts
new file mode 100644
index 00000000000000..afa8b800731acc
--- /dev/null
+++ b/apps/api/v2/src/lib/enums/locales.ts
@@ -0,0 +1,44 @@
+export enum Locales {
+ AR = "ar",
+ CA = "ca",
+ DE = "de",
+ ES = "es",
+ EU = "eu",
+ HE = "he",
+ ID = "id",
+ JA = "ja",
+ LV = "lv",
+ PL = "pl",
+ RO = "ro",
+ SR = "sr",
+ TH = "th",
+ VI = "vi",
+ AZ = "az",
+ CS = "cs",
+ EL = "el",
+ ES_419 = "es-419",
+ FI = "fi",
+ HR = "hr",
+ IT = "it",
+ KM = "km",
+ NL = "nl",
+ PT = "pt",
+ RU = "ru",
+ SV = "sv",
+ TR = "tr",
+ ZH_CN = "zh-CN",
+ BG = "bg",
+ DA = "da",
+ EN = "en",
+ ET = "et",
+ FR = "fr",
+ HU = "hu",
+ IW = "iw",
+ KO = "ko",
+ NO = "no",
+ PT_BR = "pt-BR",
+ SK = "sk",
+ TA = "ta",
+ UK = "uk",
+ ZH_TW = "zh-TW",
+}
diff --git a/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts b/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts
new file mode 100644
index 00000000000000..4c4ce6b90f56b0
--- /dev/null
+++ b/apps/api/v2/src/lib/inputs/capitalize-timezone.spec.ts
@@ -0,0 +1,61 @@
+import { plainToClass } from "class-transformer";
+import { IsOptional, IsString } from "class-validator";
+
+import { CapitalizeTimeZone } from "./capitalize-timezone";
+
+class TestDto {
+ @IsOptional()
+ @IsString()
+ @CapitalizeTimeZone()
+ timeZone?: string;
+}
+
+describe("CapitalizeTimeZone", () => {
+ it("should capitalize single part time zone correctly", () => {
+ const input = { timeZone: "egypt" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("Egypt");
+ });
+
+ it("should capitalize one-part time zone correctly", () => {
+ const input = { timeZone: "europe/rome" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("Europe/Rome");
+ });
+
+ it("should capitalize multi-part time zone correctly", () => {
+ const input = { timeZone: "america/new_york" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("America/New_York");
+ });
+
+ it("should capitalize complex time zone correctly", () => {
+ const input = { timeZone: "europe/isle_of_man" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("Europe/Isle_Of_Man");
+ });
+
+ it("should handle already capitalized time zones correctly", () => {
+ const input = { timeZone: "Asia/Tokyo" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("Asia/Tokyo");
+ });
+
+ it("should handle missing time zone correctly", () => {
+ const input = {};
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBeUndefined();
+ });
+
+ it("should capitalize EST at the end of the string", () => {
+ const input = { email: "test@example.com", timeZone: "utc/est" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("UTC/EST");
+ });
+
+ it("should capitalize UTC when surrounded by non-alphabetical characters", () => {
+ const input = { email: "test@example.com", timeZone: "utc/gmt+3_est" };
+ const output = plainToClass(TestDto, input);
+ expect(output.timeZone).toBe("UTC/GMT+3_EST");
+ });
+});
diff --git a/apps/api/v2/src/lib/inputs/capitalize-timezone.ts b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts
new file mode 100644
index 00000000000000..c94a9dc4c4c9d3
--- /dev/null
+++ b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts
@@ -0,0 +1,28 @@
+import { Transform } from "class-transformer";
+
+export function CapitalizeTimeZone(): PropertyDecorator {
+ return Transform(({ value }) => {
+ if (typeof value === "string") {
+ const parts = value.split("/");
+ const normalizedParts = parts.map((part) =>
+ part
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join("_")
+ );
+ let normalizedTimeZone = normalizedParts.join("/");
+
+ // note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them
+ const specialCases = ["GMT", "EST", "UTC"];
+ specialCases.forEach((specialCase) => {
+ const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi");
+ normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => {
+ return `${p1}${specialCase}${p3}`;
+ });
+ });
+
+ return normalizedTimeZone;
+ }
+ return value;
+ });
+}
diff --git a/apps/api/v2/src/lib/roles/constants.ts b/apps/api/v2/src/lib/roles/constants.ts
new file mode 100644
index 00000000000000..03a9db1840fea3
--- /dev/null
+++ b/apps/api/v2/src/lib/roles/constants.ts
@@ -0,0 +1,13 @@
+import { MembershipRole } from "@prisma/client";
+
+export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
+export const ORG_ROLES = [
+ `ORG_${MembershipRole.OWNER}`,
+ `ORG_${MembershipRole.ADMIN}`,
+ `ORG_${MembershipRole.MEMBER}`,
+] as const;
+export const TEAM_ROLES = [
+ `TEAM_${MembershipRole.OWNER}`,
+ `TEAM_${MembershipRole.ADMIN}`,
+ `TEAM_${MembershipRole.MEMBER}`,
+] as const;
diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts
index e149793395326c..a1aace491983fc 100644
--- a/apps/api/v2/src/main.ts
+++ b/apps/api/v2/src/main.ts
@@ -16,6 +16,7 @@ import { loggerConfig } from "./lib/logger";
const run = async () => {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
+ bodyParser: false,
});
const logger = new Logger("App");
@@ -23,10 +24,11 @@ const run = async () => {
try {
bootstrap(app);
const port = app.get(ConfigService).get("api.port", { infer: true });
- generateSwagger(app);
+ void generateSwagger(app);
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
+ console.error(error);
logger.error("Application crashed", {
error,
});
diff --git a/apps/api/v2/src/app.logger.middleware.ts b/apps/api/v2/src/middleware/app.logger.middleware.ts
similarity index 100%
rename from apps/api/v2/src/app.logger.middleware.ts
rename to apps/api/v2/src/middleware/app.logger.middleware.ts
diff --git a/apps/api/v2/src/app.rewrites.middleware.ts b/apps/api/v2/src/middleware/app.rewrites.middleware.ts
similarity index 100%
rename from apps/api/v2/src/app.rewrites.middleware.ts
rename to apps/api/v2/src/middleware/app.rewrites.middleware.ts
diff --git a/apps/api/v2/src/middleware/body/json.body.middleware.ts b/apps/api/v2/src/middleware/body/json.body.middleware.ts
new file mode 100644
index 00000000000000..8f454090cf46c5
--- /dev/null
+++ b/apps/api/v2/src/middleware/body/json.body.middleware.ts
@@ -0,0 +1,10 @@
+import { Injectable, NestMiddleware } from "@nestjs/common";
+import * as bodyParser from "body-parser";
+import type { Request, Response } from "express";
+
+@Injectable()
+export class JsonBodyMiddleware implements NestMiddleware {
+ use(req: Request, res: Response, next: () => any) {
+ bodyParser.json()(req, res, next);
+ }
+}
diff --git a/apps/api/v2/src/middleware/body/raw.body.middleware.ts b/apps/api/v2/src/middleware/body/raw.body.middleware.ts
new file mode 100644
index 00000000000000..6cdae2f61766a8
--- /dev/null
+++ b/apps/api/v2/src/middleware/body/raw.body.middleware.ts
@@ -0,0 +1,10 @@
+import { Injectable, NestMiddleware } from "@nestjs/common";
+import * as bodyParser from "body-parser";
+import type { Request, Response } from "express";
+
+@Injectable()
+export class RawBodyMiddleware implements NestMiddleware {
+ use(req: Request, res: Response, next: () => any) {
+ bodyParser.raw({ type: "*/*" })(req, res, next);
+ }
+}
diff --git a/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts b/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts
new file mode 100644
index 00000000000000..b49d75d4564e88
--- /dev/null
+++ b/apps/api/v2/src/middleware/request-ids/request-id.interceptor.ts
@@ -0,0 +1,16 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
+import { Request, Response } from "express";
+
+@Injectable()
+export class ResponseInterceptor implements NestInterceptor {
+ intercept(context: ExecutionContext, next: CallHandler) {
+ const ctx = context.switchToHttp();
+ const request = ctx.getRequest();
+ const response = ctx.getResponse();
+
+ const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
+ response.setHeader("X-Request-Id", requestId.toString());
+
+ return next.handle();
+ }
+}
diff --git a/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts b/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts
new file mode 100644
index 00000000000000..2902dd8fb2b6fa
--- /dev/null
+++ b/apps/api/v2/src/middleware/request-ids/request-id.middleware.ts
@@ -0,0 +1,11 @@
+import { Injectable, NestMiddleware } from "@nestjs/common";
+import { Request, Response, NextFunction } from "express";
+import { v4 as uuid } from "uuid";
+
+@Injectable()
+export class RequestIdMiddleware implements NestMiddleware {
+ use(req: Request, res: Response, next: NextFunction) {
+ req.headers["X-Request-Id"] = uuid();
+ next();
+ }
+}
diff --git a/apps/api/v2/src/modules/api-key/api-key-repository.ts b/apps/api/v2/src/modules/api-key/api-key-repository.ts
new file mode 100644
index 00000000000000..66af5f68c45905
--- /dev/null
+++ b/apps/api/v2/src/modules/api-key/api-key-repository.ts
@@ -0,0 +1,16 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class ApiKeyRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async getApiKeyFromHash(hashedKey: string) {
+ return this.dbRead.prisma.apiKey.findUnique({
+ where: {
+ hashedKey,
+ },
+ });
+ }
+}
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
index 6c3d86ba058c7c..993210e07af22d 100644
--- a/apps/api/v2/src/modules/api-key/api-key.module.ts
+++ b/apps/api/v2/src/modules/api-key/api-key.module.ts
@@ -1,10 +1,10 @@
-import { ApiKeyService } from "@/modules/api-key/api-key.service";
+import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
- providers: [ApiKeyService],
- exports: [ApiKeyService],
+ providers: [ApiKeyRepository],
+ exports: [ApiKeyRepository],
})
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
deleted file mode 100644
index c381fa99be0dc6..00000000000000
--- a/apps/api/v2/src/modules/api-key/api-key.service.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-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/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts
index aae58abb100e8a..b97a5aceb54978 100644
--- a/apps/api/v2/src/modules/auth/auth.module.ts
+++ b/apps/api/v2/src/modules/auth/auth.module.ts
@@ -1,26 +1,28 @@
import { ApiKeyModule } from "@/modules/api-key/api-key.module";
-import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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 { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
+import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
+import { RedisModule } from "@/modules/redis/redis.module";
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,
+ imports: [
+ PassportModule,
+ RedisModule,
+ ApiKeyModule,
+ UsersModule,
+ MembershipsModule,
+ TokensModule,
+ DeploymentsModule,
],
- exports: [NextAuthGuard, AccessTokenGuard],
+ providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
+ exports: [NextAuthGuard, ApiAuthGuard],
})
export class AuthModule {}
diff --git a/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts
new file mode 100644
index 00000000000000..6dc57ed261fa2a
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/decorators/get-membership/get-membership.decorator.ts
@@ -0,0 +1,33 @@
+import { ExecutionContext } from "@nestjs/common";
+import { createParamDecorator } from "@nestjs/common";
+
+import { Membership } from "@calcom/prisma/client";
+
+export type GetMembershipReturnType = Membership;
+
+export const GetMembership = createParamDecorator<
+ keyof GetMembershipReturnType | (keyof GetMembershipReturnType)[],
+ ExecutionContext
+>((data, ctx) => {
+ const request = ctx.switchToHttp().getRequest();
+ const membership = request.membership as GetMembershipReturnType;
+
+ if (!membership) {
+ throw new Error("GetMembership decorator : Membership not found");
+ }
+
+ if (Array.isArray(data)) {
+ return data.reduce((prev, curr) => {
+ return {
+ ...prev,
+ [curr]: membership[curr],
+ };
+ }, {});
+ }
+
+ if (data) {
+ return membership[data];
+ }
+
+ return membership;
+});
diff --git a/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts
new file mode 100644
index 00000000000000..50d3bb543d4c31
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/decorators/get-org/get-org.decorator.ts
@@ -0,0 +1,33 @@
+import { ExecutionContext } from "@nestjs/common";
+import { createParamDecorator } from "@nestjs/common";
+
+import { Team } from "@calcom/prisma/client";
+
+export type GetOrgReturnType = Team;
+
+export const GetOrg = createParamDecorator<
+ keyof GetOrgReturnType | (keyof GetOrgReturnType)[],
+ ExecutionContext
+>((data, ctx) => {
+ const request = ctx.switchToHttp().getRequest();
+ const organization = request.organization as GetOrgReturnType;
+
+ if (!organization) {
+ throw new Error("GetOrg decorator : Org not found");
+ }
+
+ if (Array.isArray(data)) {
+ return data.reduce((prev, curr) => {
+ return {
+ ...prev,
+ [curr]: organization[curr],
+ };
+ }, {});
+ }
+
+ if (data) {
+ return organization[data];
+ }
+
+ return organization;
+});
diff --git a/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts
new file mode 100644
index 00000000000000..80560f8aa7a95b
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/decorators/get-team/get-team.decorator.ts
@@ -0,0 +1,33 @@
+import { ExecutionContext } from "@nestjs/common";
+import { createParamDecorator } from "@nestjs/common";
+
+import { Team } from "@calcom/prisma/client";
+
+export type GetTeamReturnType = Team;
+
+export const GetTeam = createParamDecorator<
+ keyof GetTeamReturnType | (keyof GetTeamReturnType)[],
+ ExecutionContext
+>((data, ctx) => {
+ const request = ctx.switchToHttp().getRequest();
+ const team = request.team as GetTeamReturnType;
+
+ if (!team) {
+ throw new Error("GetTeam decorator : Team not found");
+ }
+
+ if (Array.isArray(data)) {
+ return data.reduce((prev, curr) => {
+ return {
+ ...prev,
+ [curr]: team[curr],
+ };
+ }, {});
+ }
+
+ if (data) {
+ return team[data];
+ }
+
+ return team;
+});
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
index 28ec9d5e3a34ed..ebdd75e3952059 100644
--- 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
@@ -1,26 +1,33 @@
+import { UserWithProfile } from "@/modules/users/users.repository";
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
-import { User } from "@prisma/client";
-export const GetUser = createParamDecorator((data, ctx) => {
+export type GetUserReturnType = UserWithProfile & { isSystemAdmin: boolean };
+
+export const GetUser = createParamDecorator<
+ keyof GetUserReturnType | (keyof GetUserReturnType)[],
+ ExecutionContext
+>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
- const user = request.user as User;
+ const user = request.user as GetUserReturnType;
if (!user) {
throw new Error("GetUser decorator : User not found");
}
+ user.isSystemAdmin = user.role === "ADMIN";
+
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
- [curr]: request.user[curr],
+ [curr]: user[curr],
};
}, {});
}
if (data) {
- return request.user[data];
+ return user[data];
}
return user;
diff --git a/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts b/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts
new file mode 100644
index 00000000000000..1165605374b347
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/decorators/roles/membership-roles.decorator.ts
@@ -0,0 +1,4 @@
+import { Reflector } from "@nestjs/core";
+import { MembershipRole } from "@prisma/client";
+
+export const MembershipRoles = Reflector.createDecorator();
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
index d1a4511770d1f0..1a0256b0803dce 100644
--- a/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts
+++ b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts
@@ -1,4 +1,6 @@
+import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
import { Reflector } from "@nestjs/core";
-import { MembershipRole } from "@prisma/client";
-export const Roles = Reflector.createDecorator();
+export const Roles = Reflector.createDecorator<
+ (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
+>();
diff --git a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts
similarity index 56%
rename from apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts
rename to apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts
index 2543c644549ae1..bfc6e240df1e34 100644
--- a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts
+++ b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts
@@ -1,6 +1,6 @@
import { AuthGuard } from "@nestjs/passport";
-export class AccessTokenGuard extends AuthGuard("access-token") {
+export class ApiAuthGuard extends AuthGuard("api-auth") {
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/api-auth/token-expired.exception.ts
similarity index 100%
rename from apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts
rename to apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts
diff --git a/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts
new file mode 100644
index 00000000000000..56a3f3c48cde46
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/guards/memberships/is-membership-in-org.guard.ts
@@ -0,0 +1,42 @@
+import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
+import {
+ Injectable,
+ CanActivate,
+ ExecutionContext,
+ ForbiddenException,
+ NotFoundException,
+} from "@nestjs/common";
+import { Request } from "express";
+
+import { Membership } from "@calcom/prisma/client";
+
+@Injectable()
+export class IsMembershipInOrg implements CanActivate {
+ constructor(private organizationsMembershipRepository: OrganizationsMembershipRepository) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const membershipId: string = request.params.membershipId;
+ const orgId: string = request.params.orgId;
+
+ if (!orgId) {
+ throw new ForbiddenException("No org id found in request params.");
+ }
+
+ if (!membershipId) {
+ throw new ForbiddenException("No membership id found in request params.");
+ }
+
+ const membership = await this.organizationsMembershipRepository.findOrgMembership(
+ Number(orgId),
+ Number(membershipId)
+ );
+
+ if (!membership) {
+ throw new NotFoundException(`Membership (${membershipId}) not found.`);
+ }
+
+ request.membership = membership;
+ return true;
+ }
+}
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
index c55702be8816e1..bb6eeffe5820a9 100644
--- 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
@@ -1,4 +1,4 @@
-import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
await this.isPlatform(organizationId);
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
- const allowedRoles = this.reflector.get(Roles, context.getHandler());
+ const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
this.isMembershipAccepted(membership.accepted);
this.isRoleAllowed(membership.role, allowedRoles);
diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts
new file mode 100644
index 00000000000000..ce9143d36f1fa2
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/guards/organizations/is-org.guard.ts
@@ -0,0 +1,58 @@
+import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
+import { RedisService } from "@/modules/redis/redis.service";
+import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
+import { Request } from "express";
+
+import { Team } from "@calcom/prisma/client";
+
+type CachedData = {
+ org?: Team;
+ canAccess?: boolean;
+};
+
+@Injectable()
+export class IsOrgGuard implements CanActivate {
+ constructor(
+ private organizationsRepository: OrganizationsRepository,
+ private readonly redisService: RedisService
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ let canAccess = false;
+ const request = context.switchToHttp().getRequest();
+ const organizationId: string = request.params.orgId;
+
+ if (!organizationId) {
+ throw new ForbiddenException("No organization id found in request params.");
+ }
+
+ const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
+ const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
+
+ if (cachedData) {
+ const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
+ if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
+ request.organization = cachedOrg;
+ return cachedCanAccess;
+ }
+ }
+
+ const org = await this.organizationsRepository.findById(Number(organizationId));
+
+ if (org?.isOrganization) {
+ request.organization = org;
+ canAccess = true;
+ }
+
+ if (org) {
+ await this.redisService.redis.set(
+ REDIS_CACHE_KEY,
+ JSON.stringify({ org: org, canAccess } satisfies CachedData),
+ "EX",
+ 300
+ );
+ }
+
+ return canAccess;
+ }
+}
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
index 0bd07651deeb6f..a8869436ceccb5 100644
--- 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
@@ -1,6 +1,7 @@
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants";
@@ -13,7 +14,20 @@ describe("PermissionsGuard", () => {
beforeEach(async () => {
reflector = new Reflector();
- guard = new PermissionsGuard(reflector, createMock());
+ guard = new PermissionsGuard(
+ reflector,
+ createMock(),
+ createMock({
+ get: jest.fn().mockImplementation((key: string) => {
+ switch (key) {
+ case "api.apiKeyPrefix":
+ return "cal_";
+ default:
+ return null;
+ }
+ }),
+ })
+ );
});
it("should be defined", () => {
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
index 4fec7f87009d03..ee213059fca83c 100644
--- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts
+++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts
@@ -1,13 +1,19 @@
+import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { hasPermissions } from "@calcom/platform-utils";
@Injectable()
export class PermissionsGuard implements CanActivate {
- constructor(private reflector: Reflector, private tokensRepository: TokensRepository) {}
+ constructor(
+ private reflector: Reflector,
+ private tokensRepository: TokensRepository,
+ private readonly config: ConfigService
+ ) {}
async canActivate(context: ExecutionContext): Promise {
const requiredPermissions = this.reflector.get(Permissions, context.getHandler());
@@ -17,13 +23,18 @@ export class PermissionsGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest();
- const accessToken = request.get("Authorization")?.replace("Bearer ", "");
+ const authString = request.get("Authorization")?.replace("Bearer ", "");
- if (!accessToken) {
+ if (!authString) {
return false;
}
- const oAuthClientPermissions = await this.getOAuthClientPermissions(accessToken);
+ // only check permissions for accessTokens attached to an oAuth Client
+ if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
+ return true;
+ }
+
+ const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
if (!oAuthClientPermissions) {
return false;
diff --git a/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts
new file mode 100644
index 00000000000000..4cf1c91a584314
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/guards/roles/roles.guard.ts
@@ -0,0 +1,163 @@
+import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
+import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
+import { RedisService } from "@/modules/redis/redis.service";
+import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
+import { Reflector } from "@nestjs/core";
+import { Request } from "express";
+
+import { Team } from "@calcom/prisma/client";
+
+@Injectable()
+export class RolesGuard implements CanActivate {
+ private readonly logger = new Logger("RolesGuard Logger");
+ constructor(
+ private reflector: Reflector,
+ private membershipRepository: MembershipsRepository,
+ private readonly redisService: RedisService
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const teamId = request.params.teamId as string;
+ const orgId = request.params.orgId as string;
+ const user = request.user as GetUserReturnType;
+ const allowedRole = this.reflector.get(Roles, context.getHandler());
+ const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
+ teamId ?? "none"
+ }:guard:roles:${allowedRole}`;
+ const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
+
+ if (cachedAccess) {
+ return cachedAccess;
+ }
+
+ let canAccess = false;
+
+ // User is not authenticated
+ if (!user) {
+ this.logger.log("User is not authenticated, denying access.");
+ canAccess = false;
+ }
+
+ // System admin can access everything
+ else if (user.isSystemAdmin) {
+ this.logger.log(`User (${user.id}) is system admin, allowing access.`);
+ canAccess = true;
+ }
+
+ // if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
+ else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
+ this.logger.log(`User (${user.id}) is not system admin, denying access.`);
+ canAccess = false;
+ }
+
+ // Checking the role of the user within the organization
+ else if (Boolean(orgId) && !Boolean(teamId)) {
+ const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
+ if (!membership) {
+ this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
+ throw new ForbiddenException(`User is not a member of the organization.`);
+ }
+
+ if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
+ canAccess = hasMinimumRole({
+ checkRole: `ORG_${membership.role}`,
+ minimumRole: allowedRole,
+ roles: ORG_ROLES,
+ });
+ }
+ }
+
+ // Checking the role of the user within the team
+ else if (Boolean(teamId) && !Boolean(orgId)) {
+ const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
+ if (!membership) {
+ this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
+ throw new ForbiddenException(`User is not a member of the team.`);
+ }
+ if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
+ canAccess = hasMinimumRole({
+ checkRole: `TEAM_${membership.role}`,
+ minimumRole: allowedRole,
+ roles: TEAM_ROLES,
+ });
+ }
+ }
+
+ // Checking the role for team and org, org is above team in term of permissions
+ else if (Boolean(teamId) && Boolean(orgId)) {
+ const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
+ const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
+
+ if (!orgMembership) {
+ this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
+ throw new ForbiddenException(`User is not part of the organization.`);
+ }
+
+ // if the role checked is a TEAM role
+ if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
+ // if the user is admin or owner of org, allow request because org > team
+ if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
+ canAccess = true;
+ } else {
+ if (!teamMembership) {
+ this.logger.log(
+ `User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
+ );
+ throw new ForbiddenException(
+ "User is not part of the team and/or, is not an admin nor an owner of the organization."
+ );
+ }
+
+ // if user is not admin nor an owner of org, and is part of the team, then check user team membership role
+ canAccess = hasMinimumRole({
+ checkRole: `TEAM_${teamMembership.role}`,
+ minimumRole: allowedRole,
+ roles: TEAM_ROLES,
+ });
+ }
+ }
+
+ // if allowed role is a ORG ROLE, check org membersip role
+ else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
+ canAccess = hasMinimumRole({
+ checkRole: `ORG_${orgMembership.role}`,
+ minimumRole: allowedRole,
+ roles: ORG_ROLES,
+ });
+ }
+ }
+ await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
+ return canAccess;
+ }
+}
+
+type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
+
+type HasMinimumTeamRoleProp = {
+ checkRole: (typeof TEAM_ROLES)[number];
+ minimumRole: string;
+ roles: typeof TEAM_ROLES;
+};
+
+type HasMinimumOrgRoleProp = {
+ checkRole: (typeof ORG_ROLES)[number];
+ minimumRole: string;
+ roles: typeof ORG_ROLES;
+};
+
+type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
+
+export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
+ const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
+ const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
+
+ // minimum role given does not exist
+ if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
+ throw new Error("Invalid role");
+ }
+
+ return checkedRoleIndex <= requiredRoleIndex;
+}
diff --git a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts
new file mode 100644
index 00000000000000..eb90203845450f
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts
@@ -0,0 +1,43 @@
+import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
+import {
+ Injectable,
+ CanActivate,
+ ExecutionContext,
+ ForbiddenException,
+ NotFoundException,
+} from "@nestjs/common";
+import { Request } from "express";
+
+import { Team } from "@calcom/prisma/client";
+
+@Injectable()
+export class IsTeamInOrg implements CanActivate {
+ constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const teamId: string = request.params.teamId;
+ const orgId: string = request.params.orgId;
+
+ if (!orgId) {
+ throw new ForbiddenException("No org id found in request params.");
+ }
+
+ if (!teamId) {
+ throw new ForbiddenException("No team id found in request params.");
+ }
+
+ const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
+
+ if (team && !team.isOrganization && team.parentId === Number(orgId)) {
+ request.team = team;
+ return true;
+ }
+
+ if (!team) {
+ throw new NotFoundException(`Team (${teamId}) not found.`);
+ }
+
+ return false;
+ }
+}
diff --git a/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts
new file mode 100644
index 00000000000000..e35bac45d5bb87
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org.guard.ts
@@ -0,0 +1,33 @@
+import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
+import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
+import { Request } from "express";
+
+import { Team } from "@calcom/prisma/client";
+
+@Injectable()
+export class IsUserInOrg implements CanActivate {
+ constructor(private organizationsRepository: OrganizationsRepository) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const orgId: string = request.params.orgId;
+ const userId: string = request.params.userId;
+
+ if (!userId) {
+ throw new ForbiddenException("No user id found in request params.");
+ }
+
+ if (!orgId) {
+ throw new ForbiddenException("No org id found in request params.");
+ }
+
+ const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
+
+ if (user) {
+ request.user = user;
+ return true;
+ }
+
+ return false;
+ }
+}
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
deleted file mode 100644
index 53e055d2d29fc3..00000000000000
--- a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-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-auth/api-auth.strategy.e2e-spec.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts
new file mode 100644
index 00000000000000..354c9e665cd510
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts
@@ -0,0 +1,180 @@
+import appConfig from "@/config/app";
+import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
+import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
+import { DeploymentsService } from "@/modules/deployments/deployments.service";
+import { JwtService } from "@/modules/jwt/jwt.service";
+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 { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { TokensRepository } from "@/modules/tokens/tokens.repository";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { ExecutionContext, HttpException } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { ConfigModule } from "@nestjs/config";
+import { JwtService as NestJwtService } from "@nestjs/jwt";
+import { Test, TestingModule } from "@nestjs/testing";
+import { PlatformOAuthClient, Team, User } from "@prisma/client";
+import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.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";
+import { MockedRedisService } from "test/mocks/mock-redis-service";
+
+import { ApiAuthStrategy } from "./api-auth.strategy";
+
+describe("ApiAuthStrategy", () => {
+ let strategy: ApiAuthStrategy;
+ let userRepositoryFixture: UserRepositoryFixture;
+ let tokensRepositoryFixture: TokensRepositoryFixture;
+ let teamRepositoryFixture: TeamRepositoryFixture;
+ let organization: Team;
+ let oAuthClient: PlatformOAuthClient;
+ let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
+ let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
+ const validApiKeyEmail = "api-key-user-email@example.com";
+ const validAccessTokenEmail = "access-token-user-email@example.com";
+ let validApiKeyUser: User;
+ let validAccessTokenUser: User;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ imports: [
+ ConfigModule.forRoot({
+ ignoreEnvFile: true,
+ isGlobal: true,
+ load: [appConfig],
+ }),
+ ],
+ providers: [
+ MockedRedisService,
+ ApiAuthStrategy,
+ ConfigService,
+ OAuthFlowService,
+ UsersRepository,
+ ApiKeyRepository,
+ DeploymentsService,
+ OAuthClientRepository,
+ PrismaReadService,
+ PrismaWriteService,
+ TokensRepository,
+ JwtService,
+ DeploymentsRepository,
+ NestJwtService,
+ ],
+ }).compile();
+
+ strategy = module.get(ApiAuthStrategy);
+ userRepositoryFixture = new UserRepositoryFixture(module);
+ tokensRepositoryFixture = new TokensRepositoryFixture(module);
+ apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
+ teamRepositoryFixture = new TeamRepositoryFixture(module);
+ oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
+ organization = await teamRepositoryFixture.create({ name: "organization" });
+ validApiKeyUser = await userRepositoryFixture.create({
+ email: validApiKeyEmail,
+ });
+ validAccessTokenUser = await userRepositoryFixture.create({
+ email: validAccessTokenEmail,
+ });
+ const data = {
+ logo: "logo-url",
+ name: "name",
+ redirectUris: ["http://localhost:3000"],
+ permissions: 32,
+ };
+ oAuthClient = await oAuthClientRepositoryFixture.create(organization.id, data, "secret");
+ });
+
+ describe("authenticate with strategy", () => {
+ it("should return user associated with valid access token", async () => {
+ const { accessToken } = await tokensRepositoryFixture.createTokens(
+ validAccessTokenUser.id,
+ oAuthClient.id
+ );
+
+ const context: ExecutionContext = {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ },
+ get: (key: string) =>
+ ({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
+ }),
+ }),
+ } as ExecutionContext;
+ const request = context.switchToHttp().getRequest();
+
+ const user = await strategy.accessTokenStrategy(accessToken);
+ await expect(user).toBeDefined();
+ if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
+ });
+
+ it("should return user associated with valid api key", async () => {
+ const now = new Date();
+ now.setDate(now.getDate() + 1);
+ const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
+
+ const context: ExecutionContext = {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ headers: {
+ authorization: `Bearer cal_test_${keyString}`,
+ },
+ get: (key: string) =>
+ ({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
+ }),
+ }),
+ } as ExecutionContext;
+ const request = context.switchToHttp().getRequest();
+
+ const user = await strategy.apiKeyStrategy(keyString);
+ await expect(user).toBeDefined();
+ if (user) expect(user.id).toEqual(validApiKeyUser.id);
+ });
+
+ it("should throw 401 if api key is invalid", async () => {
+ const context: ExecutionContext = {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ headers: {
+ authorization: `Bearer cal_test_}`,
+ },
+ get: (key: string) =>
+ ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
+ }),
+ }),
+ } as ExecutionContext;
+ const request = context.switchToHttp().getRequest();
+
+ try {
+ await strategy.authenticate(request);
+ } catch (error) {
+ if (error instanceof HttpException) {
+ expect(error.getStatus()).toEqual(401);
+ }
+ }
+ });
+
+ it("should throw 401 if Authorization header does not contain auth token", async () => {
+ const context: ExecutionContext = {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ get: (key: string) => ({ Authorization: ``, origin: "http://localhost:3000" }[key]),
+ }),
+ }),
+ } as ExecutionContext;
+ const request = context.switchToHttp().getRequest();
+
+ try {
+ await strategy.authenticate(request);
+ } catch (error) {
+ if (error instanceof HttpException) {
+ expect(error.getStatus()).toEqual(401);
+ }
+ }
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts
new file mode 100644
index 00000000000000..d05824382c25fa
--- /dev/null
+++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts
@@ -0,0 +1,109 @@
+import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
+import { BaseStrategy } from "@/lib/passport/strategies/types";
+import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
+import { DeploymentsService } from "@/modules/deployments/deployments.service";
+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, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { PassportStrategy } from "@nestjs/passport";
+import type { Request } from "express";
+
+import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
+
+@Injectable()
+export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
+ constructor(
+ private readonly deploymentsService: DeploymentsService,
+ private readonly config: ConfigService,
+ private readonly oauthFlowService: OAuthFlowService,
+ private readonly tokensRepository: TokensRepository,
+ private readonly userRepository: UsersRepository,
+ private readonly apiKeyRepository: ApiKeyRepository
+ ) {
+ super();
+ }
+
+ async authenticate(request: Request) {
+ const authString = request.get("Authorization")?.replace("Bearer ", "");
+ if (!authString) {
+ return this.error(new UnauthorizedException("No Authorization header provided"));
+ }
+
+ const requestOrigin = request.get("Origin");
+
+ try {
+ const user = isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")
+ ? await this.apiKeyStrategy(authString)
+ : await this.accessTokenStrategy(authString, requestOrigin);
+
+ if (!user) {
+ return this.error(new UnauthorizedException("No user associated with the provided token"));
+ }
+
+ return this.success(user);
+ } catch (err) {
+ if (err instanceof Error) {
+ return this.error(err);
+ }
+ return this.error(
+ new InternalServerErrorException("An error occurred while authenticating the request")
+ );
+ }
+ }
+
+ async apiKeyStrategy(apiKey: string) {
+ const isLicenseValid = await this.deploymentsService.checkLicense();
+ if (!isLicenseValid) {
+ throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable");
+ }
+ const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix"));
+ const apiKeyHash = hashAPIKey(strippedApiKey);
+ const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
+ if (!keyData) {
+ throw new UnauthorizedException("Your api key is not valid");
+ }
+
+ const isKeyExpired =
+ keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
+ if (isKeyExpired) {
+ throw new UnauthorizedException("Your api key is expired");
+ }
+
+ const apiKeyOwnerId = keyData.userId;
+ if (!apiKeyOwnerId) {
+ throw new UnauthorizedException("No user tied to this apiKey");
+ }
+
+ const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
+ return user;
+ }
+
+ async accessTokenStrategy(accessToken: string, origin?: string) {
+ const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
+ if (!accessTokenValid) {
+ throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
+ }
+
+ const client = await this.tokensRepository.getAccessTokenClient(accessToken);
+ if (!client) {
+ throw new UnauthorizedException("OAuth client not found given the access token");
+ }
+
+ if (origin && !client.redirectUris.some((uri) => uri.startsWith(origin))) {
+ throw new UnauthorizedException(
+ `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.`
+ );
+ }
+
+ const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
+
+ if (!ownerId) {
+ throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
+ }
+
+ const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
+ return user;
+ }
+}
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
deleted file mode 100644
index 40c7f1c970ca00..00000000000000
--- a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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
index e6f7b572656865..a34b0608822af0 100644
--- 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
@@ -1,6 +1,6 @@
import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types";
import { UsersRepository } from "@/modules/users/users.repository";
-import { Injectable, UnauthorizedException } from "@nestjs/common";
+import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
@@ -33,6 +33,9 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy,
return this.success(user);
} catch (error) {
if (error instanceof Error) return this.error(error);
+ return this.error(
+ new InternalServerErrorException("An error occurred while authenticating the request")
+ );
}
}
}
diff --git a/apps/api/v2/src/modules/availabilities/availabilities.module.ts b/apps/api/v2/src/modules/availabilities/availabilities.module.ts
deleted file mode 100644
index f3fe35bf6a7314..00000000000000
--- a/apps/api/v2/src/modules/availabilities/availabilities.module.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index fc7dc14ec494c3..00000000000000
--- a/apps/api/v2/src/modules/availabilities/availabilities.service.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-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/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts
new file mode 100644
index 00000000000000..e9dc616eb7d5f7
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/billing.module.ts
@@ -0,0 +1,17 @@
+import { BillingRepository } from "@/modules/billing/billing.repository";
+import { BillingController } from "@/modules/billing/controllers/billing.controller";
+import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
+import { BillingService } from "@/modules/billing/services/billing.service";
+import { MembershipsModule } from "@/modules/memberships/memberships.module";
+import { OrganizationsModule } from "@/modules/organizations/organizations.module";
+import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { StripeModule } from "@/modules/stripe/stripe.module";
+import { Module } from "@nestjs/common";
+
+@Module({
+ imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
+ providers: [BillingConfigService, BillingService, BillingRepository],
+ exports: [BillingService, BillingRepository],
+ controllers: [BillingController],
+})
+export class BillingModule {}
diff --git a/apps/api/v2/src/modules/billing/billing.repository.ts b/apps/api/v2/src/modules/billing/billing.repository.ts
new file mode 100644
index 00000000000000..54b8cf0b2a6129
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/billing.repository.ts
@@ -0,0 +1,36 @@
+import { PlatformPlan } from "@/modules/billing/types";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class BillingRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ getBillingForTeam = (teamId: number) =>
+ this.dbRead.prisma.platformBilling.findUnique({
+ where: {
+ id: teamId,
+ },
+ });
+
+ async updateTeamBilling(
+ teamId: number,
+ billingStart: number,
+ billingEnd: number,
+ plan: PlatformPlan,
+ subscription?: string
+ ) {
+ return this.dbWrite.prisma.platformBilling.update({
+ where: {
+ id: teamId,
+ },
+ data: {
+ billingCycleStart: billingStart,
+ billingCycleEnd: billingEnd,
+ subscriptionId: subscription,
+ plan: plan.toString(),
+ },
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts
new file mode 100644
index 00000000000000..3317d0d1e31d16
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts
@@ -0,0 +1,138 @@
+import { AppConfig } from "@/config/type";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-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 { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
+import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
+import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
+import { BillingService } from "@/modules/billing/services/billing.service";
+import { PlatformPlan } from "@/modules/billing/types";
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Get,
+ Param,
+ Post,
+ Req,
+ UseGuards,
+ Headers,
+ HttpCode,
+ HttpStatus,
+ Logger,
+} from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { ApiExcludeController } from "@nestjs/swagger";
+import { Request } from "express";
+import { Stripe } from "stripe";
+
+import { ApiResponse } from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/billing",
+ version: API_VERSIONS_VALUES,
+})
+@ApiExcludeController(true)
+export class BillingController {
+ private readonly stripeWhSecret: string;
+ private logger = new Logger("Billing Controller");
+
+ constructor(
+ private readonly billingService: BillingService,
+ private readonly configService: ConfigService
+ ) {
+ this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
+ }
+
+ @Get("/:teamId/check")
+ @UseGuards(NextAuthGuard, OrganizationRolesGuard)
+ @MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
+ async checkTeamBilling(
+ @Param("teamId") teamId: number
+ ): Promise> {
+ const { status, plan } = await this.billingService.getBillingData(teamId);
+
+ return {
+ status: "success",
+ data: {
+ valid: status === "valid",
+ plan,
+ },
+ };
+ }
+
+ @Post("/:teamId/subscribe")
+ @UseGuards(NextAuthGuard, OrganizationRolesGuard)
+ @MembershipRoles(["OWNER", "ADMIN"])
+ async subscribeTeamToStripe(
+ @Param("teamId") teamId: number,
+ @Body() input: SubscribeToPlanInput
+ ): Promise> {
+ const { status } = await this.billingService.getBillingData(teamId);
+
+ if (status === "valid") {
+ throw new BadRequestException("This team is already subscribed to a plan.");
+ }
+
+ const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
+ if (action === "redirect") {
+ return {
+ status: "success",
+ data: {
+ action: "redirect",
+ url,
+ },
+ };
+ }
+
+ return {
+ status: "success",
+ };
+ }
+
+ @Post("/webhook")
+ @HttpCode(HttpStatus.OK)
+ async stripeWebhook(
+ @Req() request: Request,
+ @Headers("stripe-signature") stripeSignature: string
+ ): Promise {
+ const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
+ request.body,
+ stripeSignature,
+ this.stripeWhSecret
+ );
+
+ if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
+ const subscription = event.data.object as Stripe.Subscription;
+ if (!subscription.metadata?.teamId) {
+ return {
+ status: "success",
+ };
+ }
+
+ const teamId = Number.parseInt(subscription.metadata.teamId);
+ const plan = subscription.metadata.plan;
+ if (!plan || !teamId) {
+ this.logger.log("Webhook received but not pertaining to Platform, discarding.");
+ return {
+ status: "success",
+ };
+ }
+
+ await this.billingService.setSubscriptionForTeam(
+ teamId,
+ subscription,
+ PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
+ );
+
+ return {
+ status: "success",
+ };
+ }
+
+ return {
+ status: "success",
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts
new file mode 100644
index 00000000000000..0a4ee10ee2feef
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/controllers/inputs/subscribe-to-plan.input.ts
@@ -0,0 +1,7 @@
+import { PlatformPlan } from "@/modules/billing/types";
+import { IsEnum } from "class-validator";
+
+export class SubscribeToPlanInput {
+ @IsEnum(PlatformPlan)
+ plan!: PlatformPlan;
+}
diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts
new file mode 100644
index 00000000000000..c510fe8c6bee17
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto.ts
@@ -0,0 +1,5 @@
+export class CheckPlatformBillingResponseDto {
+ valid!: boolean;
+
+ plan?: string;
+}
diff --git a/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts
new file mode 100644
index 00000000000000..9bae0150dc8f79
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto.ts
@@ -0,0 +1,4 @@
+export class SubscribeTeamToBillingResponseDto {
+ url?: string;
+ action?: "redirect";
+}
diff --git a/apps/api/v2/src/modules/billing/services/billing.config.service.ts b/apps/api/v2/src/modules/billing/services/billing.config.service.ts
new file mode 100644
index 00000000000000..a9a25e35b14101
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/services/billing.config.service.ts
@@ -0,0 +1,23 @@
+import { PlatformPlan } from "@/modules/billing/types";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class BillingConfigService {
+ private readonly config: Map;
+
+ constructor() {
+ this.config = new Map();
+
+ const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
+ for (const key of planKeys) {
+ this.config.set(
+ PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
+ process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
+ );
+ }
+ }
+
+ get(plan: PlatformPlan): string | undefined {
+ return this.config.get(plan);
+ }
+}
diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts
new file mode 100644
index 00000000000000..d196cb00ec2919
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/services/billing.service.ts
@@ -0,0 +1,144 @@
+import { AppConfig } from "@/config/type";
+import { BillingRepository } from "@/modules/billing/billing.repository";
+import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
+import { PlatformPlan } from "@/modules/billing/types";
+import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
+import { StripeService } from "@/modules/stripe/stripe.service";
+import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { DateTime } from "luxon";
+import Stripe from "stripe";
+
+@Injectable()
+export class BillingService {
+ private logger = new Logger("BillingService");
+ private readonly webAppUrl: string;
+
+ constructor(
+ private readonly teamsRepository: OrganizationsRepository,
+ public readonly stripeService: StripeService,
+ private readonly billingRepository: BillingRepository,
+ private readonly configService: ConfigService,
+ private readonly billingConfigService: BillingConfigService
+ ) {
+ this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
+ }
+
+ async getBillingData(teamId: number) {
+ const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
+ if (teamWithBilling?.platformBilling) {
+ if (!teamWithBilling?.platformBilling.subscriptionId) {
+ return { team: teamWithBilling, status: "no_subscription", plan: "none" };
+ }
+
+ return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
+ } else {
+ return { team: teamWithBilling, status: "no_billing", plan: "none" };
+ }
+ }
+
+ async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
+ const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
+ let brandNewBilling = false;
+
+ let customerId = teamWithBilling?.platformBilling?.customerId;
+
+ if (!teamWithBilling?.platformBilling) {
+ brandNewBilling = true;
+ customerId = await this.teamsRepository.createNewBillingRelation(teamId);
+
+ this.logger.log("Team had no Stripe Customer ID, created one for them.", {
+ id: teamId,
+ stripeId: customerId,
+ });
+ }
+
+ if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
+ const { url } = await this.stripeService.stripe.checkout.sessions.create({
+ customer: customerId,
+ line_items: [
+ {
+ price: this.billingConfigService.get(plan),
+ quantity: 1,
+ },
+ ],
+ success_url: `${this.webAppUrl}/settings/platform/`,
+ cancel_url: `${this.webAppUrl}/settings/platform/`,
+ mode: "subscription",
+ metadata: {
+ teamId: teamId.toString(),
+ plan: plan.toString(),
+ },
+ subscription_data: {
+ metadata: {
+ teamId: teamId.toString(),
+ plan: plan.toString(),
+ },
+ },
+ allow_promotion_codes: true,
+ });
+
+ if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
+
+ return { action: "redirect", url };
+ }
+
+ return { action: "none" };
+ }
+
+ async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
+ const billingCycleStart = DateTime.now().get("day");
+ const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
+
+ return this.billingRepository.updateTeamBilling(
+ teamId,
+ billingCycleStart,
+ billingCycleEnd,
+ plan,
+ subscription.id
+ );
+ }
+
+ async increaseUsageForTeam(teamId: number) {
+ try {
+ const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
+ if (!billingSubscription || !billingSubscription?.subscriptionId) {
+ this.logger.error("Team did not have stripe subscription associated to it", {
+ teamId,
+ });
+ return void 0;
+ }
+
+ const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
+ billingSubscription.subscriptionId
+ );
+ const item = stripeSubscription.items.data[0];
+ // legacy plans are licensed, we cannot create usage records against them
+ if (item.price?.recurring?.usage_type === "licensed") {
+ return void 0;
+ }
+
+ await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
+ action: "increment",
+ quantity: 1,
+ timestamp: "now",
+ });
+ } catch (error) {
+ // don't fail the request, log it.
+ this.logger.error("Failed to increase usage for team", {
+ teamId: teamId,
+ error,
+ });
+ }
+ }
+
+ async increaseUsageByClientId(clientId: string) {
+ if (this.configService.get("e2e")) {
+ return void 0;
+ }
+ const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
+ if (!team.id) return Promise.resolve(); // noop resolution.
+
+ return this.increaseUsageForTeam(team?.id);
+ }
+}
diff --git a/apps/api/v2/src/modules/billing/types.ts b/apps/api/v2/src/modules/billing/types.ts
new file mode 100644
index 00000000000000..3cce957fa84bad
--- /dev/null
+++ b/apps/api/v2/src/modules/billing/types.ts
@@ -0,0 +1,6 @@
+export enum PlatformPlan {
+ STARTER = "STARTER",
+ ESSENTIALS = "ESSENTIALS",
+ SCALE = "SCALE",
+ ENTERPRISE = "ENTERPRISE",
+}
diff --git a/apps/api/v2/src/modules/deployments/deployments.module.ts b/apps/api/v2/src/modules/deployments/deployments.module.ts
new file mode 100644
index 00000000000000..1017f72c7eb81e
--- /dev/null
+++ b/apps/api/v2/src/modules/deployments/deployments.module.ts
@@ -0,0 +1,13 @@
+import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { RedisService } from "@/modules/redis/redis.service";
+import { Module } from "@nestjs/common";
+
+import { DeploymentsRepository } from "./deployments.repository";
+import { DeploymentsService } from "./deployments.service";
+
+@Module({
+ imports: [PrismaModule],
+ providers: [DeploymentsRepository, DeploymentsService, RedisService],
+ exports: [DeploymentsRepository, DeploymentsService],
+})
+export class DeploymentsModule {}
diff --git a/apps/api/v2/src/modules/deployments/deployments.repository.ts b/apps/api/v2/src/modules/deployments/deployments.repository.ts
new file mode 100644
index 00000000000000..ec5168e3e8136a
--- /dev/null
+++ b/apps/api/v2/src/modules/deployments/deployments.repository.ts
@@ -0,0 +1,12 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class DeploymentsRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async getDeployment() {
+ return this.dbRead.prisma.deployment.findFirst({ where: { id: 1 } });
+ }
+}
diff --git a/apps/api/v2/src/modules/deployments/deployments.service.ts b/apps/api/v2/src/modules/deployments/deployments.service.ts
new file mode 100644
index 00000000000000..361f901c9390d0
--- /dev/null
+++ b/apps/api/v2/src/modules/deployments/deployments.service.ts
@@ -0,0 +1,47 @@
+import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
+import { RedisService } from "@/modules/redis/redis.service";
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+
+const CACHING_TIME = 86400000; // 24 hours in milliseconds
+
+const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`;
+
+type LicenseCheckResponse = {
+ valid: boolean;
+};
+@Injectable()
+export class DeploymentsService {
+ constructor(
+ private readonly deploymentsRepository: DeploymentsRepository,
+ private readonly configService: ConfigService,
+ private readonly redisService: RedisService
+ ) {}
+
+ async checkLicense() {
+ if (this.configService.get("e2e")) {
+ return true;
+ }
+ let licenseKey = this.configService.get("api.licenseKey");
+
+ if (!licenseKey) {
+ /** We try to check on DB only if env is undefined */
+ const deployment = await this.deploymentsRepository.getDeployment();
+ licenseKey = deployment?.licenseKey ?? undefined;
+ }
+
+ if (!licenseKey) {
+ return false;
+ }
+ const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `?key=${licenseKey}`;
+ const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
+ if (cachedData) {
+ return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
+ }
+ const response = await fetch(licenseKeyUrl, { mode: "cors" });
+ const data = (await response.json()) as LicenseCheckResponse;
+ const cacheKey = getLicenseCacheKey(licenseKey);
+ this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
+ return data.valid;
+ }
+}
diff --git a/apps/api/v2/src/modules/email/email.module.ts b/apps/api/v2/src/modules/email/email.module.ts
new file mode 100644
index 00000000000000..f8e63b2b93e125
--- /dev/null
+++ b/apps/api/v2/src/modules/email/email.module.ts
@@ -0,0 +1,11 @@
+import { Global, Module } from "@nestjs/common";
+
+import { EmailService } from "./email.service";
+
+@Global()
+@Module({
+ imports: [],
+ providers: [EmailService],
+ exports: [EmailService],
+})
+export class EmailModule {}
diff --git a/apps/api/v2/src/modules/email/email.service.ts b/apps/api/v2/src/modules/email/email.service.ts
new file mode 100644
index 00000000000000..0e004974ad3970
--- /dev/null
+++ b/apps/api/v2/src/modules/email/email.service.ts
@@ -0,0 +1,31 @@
+import { Injectable } from "@nestjs/common";
+
+import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.19";
+
+@Injectable()
+export class EmailService {
+ public async sendSignupToOrganizationEmail({
+ usernameOrEmail,
+ orgName,
+ orgId,
+ locale,
+ inviterName,
+ }: {
+ usernameOrEmail: string;
+ orgName: string;
+ orgId: number;
+ locale: string | null;
+ inviterName: string;
+ }) {
+ const translation = await getTranslation(locale || "en", "common");
+
+ await sendSignupToOrganizationEmail({
+ usernameOrEmail,
+ team: { name: orgName, parent: null },
+ inviterName: inviterName,
+ isOrg: true,
+ teamId: orgId,
+ translation,
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts
index de9be4b261ee4b..3134bd2bd50abb 100644
--- a/apps/api/v2/src/modules/endpoints.module.ts
+++ b/apps/api/v2/src/modules/endpoints.module.ts
@@ -1,11 +1,14 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
+import { BillingModule } from "@/modules/billing/billing.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";
+import { UsersModule } from "./users/users.module";
+
@Module({
- imports: [OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
+ imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule, UsersModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts
index 2a8e7d1a47598e..98d495f07c2445 100644
--- a/apps/api/v2/src/modules/memberships/memberships.repository.ts
+++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts
@@ -1,6 +1,8 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
+import { MembershipRole } from "@calcom/prisma/client";
+
@Injectable()
export class MembershipsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
@@ -18,6 +20,23 @@ export class MembershipsRepository {
return membership;
}
+ async findMembershipByTeamId(teamId: number, userId: number) {
+ const membership = await this.dbRead.prisma.membership.findUnique({
+ where: {
+ userId_teamId: {
+ userId: userId,
+ teamId: teamId,
+ },
+ },
+ });
+
+ return membership;
+ }
+
+ async findMembershipByOrgId(orgId: number, userId: number) {
+ return this.findMembershipByTeamId(orgId, userId);
+ }
+
async isUserOrganizationAdmin(userId: number, organizationId: number) {
const adminMembership = await this.dbRead.prisma.membership.findFirst({
where: {
@@ -30,4 +49,16 @@ export class MembershipsRepository {
return !!adminMembership;
}
+
+ async createMembership(teamId: number, userId: number, role: MembershipRole) {
+ const membership = await this.dbRead.prisma.membership.create({
+ data: {
+ role,
+ teamId,
+ userId,
+ },
+ });
+
+ return membership;
+ }
}
diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
index 5af5207662d6a7..392a11db999b36 100644
--- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
+++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts
@@ -1,8 +1,9 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
-import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
+import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
+import { Locales } from "@/lib/enums/locales";
import {
CreateUserResponse,
UserReturned,
@@ -17,6 +18,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
+import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -75,10 +77,12 @@ describe("OAuth Client Users Endpoints", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
+ let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let postResponseData: CreateUserResponse;
const userEmail = "oauth-client-user@gmail.com";
+ const userTimeZone = "Europe/Rome";
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -93,6 +97,7 @@ describe("OAuth Client Users Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
+ schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -134,6 +139,10 @@ describe("OAuth Client Users Endpoints", () => {
it(`/POST`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
+ timeZone: userTimeZone,
+ weekStart: "Monday",
+ timeFormat: 24,
+ locale: Locales.FR,
};
const response = await request(app.getHttpServer())
@@ -153,11 +162,16 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
+ expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
+ expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
+ expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
+ expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
await userConnectedToOAuth(responseBody.data.user.email);
await userHasDefaultEventTypes(responseBody.data.user.id);
+ await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
});
async function userConnectedToOAuth(userEmail: string) {
@@ -181,6 +195,17 @@ describe("OAuth Client Users Endpoints", () => {
).toBeTruthy();
}
+ async function userHasDefaultSchedule(userId: number, scheduleId: number | null) {
+ expect(scheduleId).toBeDefined();
+ expect(scheduleId).not.toBeNull();
+
+ const user = await userRepositoryFixture.get(userId);
+ expect(user?.defaultScheduleId).toEqual(scheduleId);
+
+ const schedule = scheduleId ? await schedulesRepositoryFixture.getById(scheduleId) : null;
+ expect(schedule?.userId).toEqual(userId);
+ }
+
it(`/GET: return list of managed users`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
@@ -212,7 +237,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/PUT/:id`, async () => {
const userUpdatedEmail = "pineapple-pizza@gmail.com";
- const body: UpdateManagedUserInput = { email: userUpdatedEmail };
+ const body: UpdateManagedUserInput = { email: userUpdatedEmail, locale: Locales.PT_BR };
const response = await request(app.getHttpServer())
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
@@ -226,6 +251,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail));
+ expect(responseBody.data.locale).toEqual(Locales.PT_BR);
});
it(`/DELETE/:id`, () => {
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
index 12345efa5fcbc9..f6968b831af766 100644
--- 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
@@ -1,3 +1,5 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { Locales } from "@/lib/enums/locales";
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";
@@ -21,7 +23,6 @@ import {
HttpStatus,
Param,
Patch,
- BadRequestException,
Delete,
Query,
NotFoundException,
@@ -33,8 +34,8 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Pagination } from "@calcom/platform-types";
@Controller({
- path: "oauth-clients/:clientId/users",
- version: "2",
+ path: "/v2/oauth-clients/:clientId/users",
+ version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@DocsTags("Managed users")
@@ -91,6 +92,7 @@ export class OAuthClientUsersController {
data: {
user: this.getResponseUser(user),
accessToken: tokens.accessToken,
+ accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
refreshToken: tokens.refreshToken,
},
};
@@ -155,7 +157,7 @@ export class OAuthClientUsersController {
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
- const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
+ const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
oAuthClientId,
id,
true
@@ -166,6 +168,7 @@ export class OAuthClientUsersController {
data: {
accessToken,
refreshToken,
+ accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
},
};
}
@@ -189,6 +192,7 @@ export class OAuthClientUsersController {
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
+ locale: user.locale as Locales,
};
}
}
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
index 4528068fc31d7a..a34bab773cff58 100644
--- 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
@@ -1,7 +1,7 @@
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 { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -18,6 +18,9 @@ class CreateManagedUserData {
@IsString()
refreshToken!: string;
+
+ @IsNumber()
+ accessTokenExpiresAt!: number;
}
export class CreateManagedUserOutput {
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
index 3f7ee80738273d..5dc46fd4a43c28 100644
--- 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
@@ -1,5 +1,7 @@
+import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
+import { IsEnum, IsOptional } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -26,4 +28,9 @@ export class ManagedUserOutput {
@ApiProperty({ example: null })
defaultScheduleId!: number | null;
+
+ @IsEnum(Locales)
+ @IsOptional()
+ @ApiProperty({ example: Locales.EN, enum: Locales })
+ locale?: Locales;
}
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
index c94e0328fba4e1..e291156c475daa 100644
--- 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
@@ -13,6 +13,7 @@ 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 { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
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";
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
+ let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
+
let user: User;
let org: Team;
let app: INestApplication;
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
+ platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
+
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
+ isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
isPlatform: false,
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
+ await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
+ let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
+
let user: User;
let org: Team;
let app: INestApplication;
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
+ platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
+
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
+ isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -145,6 +157,7 @@ describe("OAuth Clients Endpoints", () => {
},
isPlatform: true,
});
+ await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
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
index a0dd99e1227f59..f80dccf14a949b 100644
--- 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
@@ -1,17 +1,23 @@
import { getEnv } from "@/env";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
-import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-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 { 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 { 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 { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
+import { UsersRepository } from "@/modules/users/users.repository";
import {
Body,
Controller,
+ Query,
Get,
Post,
Patch,
@@ -22,6 +28,7 @@ import {
Logger,
UseGuards,
NotFoundException,
+ BadRequestException,
} from "@nestjs/common";
import {
ApiTags as DocsTags,
@@ -30,16 +37,18 @@ import {
ApiCreatedResponse as DocsCreatedResponse,
} from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
+import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
+import { Pagination } 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",
+ path: "/v2/oauth-clients",
+ version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@@ -47,11 +56,15 @@ Second, make sure that the logged in user has organizationId set to pass the Org
export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
- constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
+ constructor(
+ private readonly oauthClientRepository: OAuthClientRepository,
+ private readonly userRepository: UsersRepository,
+ private readonly teamsRepository: OrganizationsRepository
+ ) {}
@Post("/")
@HttpCode(HttpStatus.CREATED)
- @Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
+ @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@DocsCreatedResponse({
description: "Create an OAuth client",
@@ -65,6 +78,12 @@ export class OAuthClientsController {
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
+
+ const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId);
+ if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) {
+ throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
+ }
+
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
return {
status: SUCCESS_STATUS,
@@ -77,7 +96,7 @@ export class OAuthClientsController {
@Get("/")
@HttpCode(HttpStatus.OK)
- @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
+ @MembershipRoles([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;
@@ -88,7 +107,7 @@ export class OAuthClientsController {
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
- @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
+ @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientById(@Param("clientId") clientId: string): Promise {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
@@ -98,9 +117,27 @@ export class OAuthClientsController {
return { status: SUCCESS_STATUS, data: client };
}
+ @Get("/:clientId/managed-users")
+ @HttpCode(HttpStatus.OK)
+ @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
+ @DocsOperation({ description: AUTH_DOCUMENTATION })
+ async getOAuthClientManagedUsersById(
+ @Param("clientId") clientId: string,
+ @Query() queryParams: Pagination
+ ): Promise {
+ const { offset, limit } = queryParams;
+ const existingManagedUsers = await this.userRepository.findManagedUsersByOAuthClientId(
+ clientId,
+ offset ?? 0,
+ limit ?? 50
+ );
+
+ return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
+ }
+
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
- @Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
+ @MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async updateOAuthClient(
@Param("clientId") clientId: string,
@@ -113,11 +150,24 @@ export class OAuthClientsController {
@Delete("/:clientId")
@HttpCode(HttpStatus.OK)
- @Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
+ @MembershipRoles([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 };
}
+
+ 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,
+ };
+ }
}
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
index fcee72c195ceea..c7eaee25846ae6 100644
--- 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
@@ -2,7 +2,7 @@ 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";
+import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants";
class DataDto {
@ApiProperty({
@@ -34,3 +34,9 @@ export class CreateOAuthClientResponseDto {
@Type(() => DataDto)
data!: DataDto;
}
+
+export class CreateOauthClientRedirect {
+ status!: typeof REDIRECT_STATUS;
+
+ url!: string;
+}
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
index 2081240bbe0c5e..128d31b6182382 100644
--- 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
@@ -113,7 +113,7 @@ describe("OAuthFlow Endpoints", () => {
const REDIRECT_STATUS = 302;
const response = await request(app.getHttpServer())
- .post(`/oauth/${oAuthClient.id}/authorize`)
+ .post(`/v2/oauth/${oAuthClient.id}/authorize`)
.send(body)
.expect(REDIRECT_STATUS);
@@ -133,7 +133,7 @@ describe("OAuthFlow Endpoints", () => {
};
const response = await request(app.getHttpServer())
- .post(`/oauth/${oAuthClient.id}/exchange`)
+ .post(`/v2/oauth/${oAuthClient.id}/exchange`)
.set("Authorization", authorizationToken)
.send(body)
.expect(200);
@@ -153,7 +153,7 @@ describe("OAuthFlow Endpoints", () => {
};
return request(app.getHttpServer())
- .post(`/oauth/${oAuthClient.id}/refresh`)
+ .post(`/v2/oauth/${oAuthClient.id}/refresh`)
.set("x-cal-secret-key", secretKey)
.send(body)
.expect(200)
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
index c7d82b69be6d61..96be69b9a8c025 100644
--- 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
@@ -1,4 +1,5 @@
import { getEnv } from "@/env";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -33,8 +34,8 @@ import { Response as ExpressResponse } from "express";
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Controller({
- path: "oauth/:clientId",
- version: "2",
+ path: "/v2/oauth/:clientId",
+ version: API_VERSIONS_VALUES,
})
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@DocsTags("OAuth - development only")
@@ -117,16 +118,18 @@ export class OAuthFlowController {
throw new BadRequestException("Missing 'Bearer' Authorization header.");
}
- const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken(
- authorizeEndpointCode,
- clientId,
- body.clientSecret
- );
+ const { accessToken, refreshToken, accessTokenExpiresAt } =
+ await this.oAuthFlowService.exchangeAuthorizationToken(
+ authorizeEndpointCode,
+ clientId,
+ body.clientSecret
+ );
return {
status: SUCCESS_STATUS,
data: {
accessToken,
+ accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
refreshToken,
},
};
@@ -140,7 +143,7 @@ export class OAuthFlowController {
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@Body() body: RefreshTokenInput
): Promise {
- const { accessToken, refreshToken } = await this.oAuthFlowService.refreshToken(
+ const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
clientId,
secretKey,
body.refreshToken
@@ -150,6 +153,7 @@ export class OAuthFlowController {
status: SUCCESS_STATUS,
data: {
accessToken: accessToken,
+ accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
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
index ba5c85b51e5a9b..9a5f5f4bcbc05e 100644
--- 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
@@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
-import { ValidateNested, IsEnum, IsString, IsNotEmptyObject } from "class-validator";
+import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -16,6 +16,9 @@ class KeysDto {
})
@IsString()
refreshToken!: string;
+
+ @IsNumber()
+ accessTokenExpiresAt!: number;
}
export class KeysResponseDto {
diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts
index 74a1b554ae48ce..8ec2c656ecf2b9 100644
--- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts
+++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts
@@ -1,5 +1,7 @@
-import { EventTypesModule } from "@/ee/event-types/event-types.module";
+import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
+import { BillingModule } from "@/modules/billing/billing.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";
@@ -10,6 +12,8 @@ import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-
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 { RedisModule } from "@/modules/redis/redis.module";
+import { StripeModule } from "@/modules/stripe/stripe.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -19,12 +23,16 @@ import { Global, Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
+ RedisModule,
AuthModule,
UsersModule,
TokensModule,
MembershipsModule,
- EventTypesModule,
+ EventTypesModule_2024_04_15,
OrganizationsModule,
+ StripeModule,
+ BillingModule,
+ SchedulesModule_2024_04_15,
],
providers: [
OAuthClientRepository,
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
index 6dc399e2e4aead..ed356afaf1108f 100644
--- a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts
+++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts
@@ -59,7 +59,7 @@ export class OAuthClientRepository {
}
async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) {
- return await this.dbRead.prisma.platformOAuthClient.findFirst({
+ return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
id: clientId,
secret: clientSecret,
diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts
index d4fd46fb5ea940..0577fe3e36e10f 100644
--- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts
+++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts
@@ -1,4 +1,5 @@
-import { EventTypesService } from "@/ee/event-types/services/event-types.service";
+import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -6,15 +7,15 @@ 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";
+import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.19";
@Injectable()
export class OAuthClientUsersService {
constructor(
private readonly userRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
- private readonly eventTypesService: EventTypesService
+ private readonly eventTypesService: EventTypesService_2024_04_15,
+ private readonly schedulesService: SchedulesService_2024_04_15
) {}
async createOauthClientUser(
@@ -35,39 +36,53 @@ export class OAuthClientUsersService {
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
user = (
await createNewUsersConnectToOrgIfExists({
- usernamesOrEmails: [email],
- input: {
- teamId: organizationId,
- role: "MEMBER",
- usernameOrEmail: [email],
- isOrg: true,
- language: "en",
- },
+ invitations: [
+ {
+ usernameOrEmail: email,
+ role: "MEMBER",
+ },
+ ],
+ teamId: organizationId,
+ isOrg: true,
parentId: null,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
- connectionInfoMap: {
+ orgConnectInfoByUsernameOrEmail: {
[email]: {
orgId: organizationId,
autoAccept: true,
},
},
isPlatformManaged,
+ timeFormat: body.timeFormat,
+ weekStart: body.weekStart,
+ timeZone: body.timeZone,
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
- await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined });
+ const updatedUser = await this.userRepository.update(user.id, {
+ name: body.name ?? user.username ?? undefined,
+ locale: body.locale,
+ });
+ user.locale = updatedUser.locale;
}
- const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
+ const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
oAuthClientId,
user.id
);
+
await this.eventTypesService.createUserDefaultEventTypes(user.id);
+ if (body.timeZone) {
+ const defaultSchedule = await this.schedulesService.createUserDefaultSchedule(user.id, body.timeZone);
+ user.defaultScheduleId = defaultSchedule.id;
+ }
+
return {
user,
tokens: {
accessToken,
+ accessTokenExpiresAt,
refreshToken,
},
};
@@ -92,8 +107,6 @@ export class OAuthClientUsersService {
getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
const [username, emailDomain] = userEmail.split("@");
- const email = `${username}+${oAuthClientId}@${emailDomain}`;
-
- return email;
+ return `${username}+${oAuthClientId}@${emailDomain}`;
}
}
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
index 24490dbfb7b207..ac0791ea2b590a 100644
--- 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
@@ -1,33 +1,70 @@
-import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
+import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
+import { RedisService } from "@/modules/redis/redis.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
-import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
+import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
+import { DateTime } from "luxon";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
@Injectable()
export class OAuthFlowService {
+ private logger = new Logger("OAuthFlowService");
+
constructor(
private readonly tokensRepository: TokensRepository,
- private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
+ private readonly oAuthClientRepository: OAuthClientRepository,
+ private readonly redisService: RedisService
) {}
- // 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;
+ try {
+ const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
+ let expiry = await this.tokensRepository.getAccessTokenExpiryDate(accessToken);
+
+ if (!expiry) {
+ this.logger.warn(`Token for ${ownerId} had no expiry time, assuming it's new.`);
+ expiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
+ }
+
+ const cacheKey = this._generateActKey(accessToken);
+ await this.redisService.redis.hmset(cacheKey, {
+ ownerId: ownerId,
+ expiresAt: expiry?.toJSON(),
+ });
+
+ await this.redisService.redis.expireat(cacheKey, Math.floor(expiry.getTime() / 1000));
+ } catch (err) {
+ this.logger.error("Access Token Propagation Failed, falling back to DB...", err);
+ }
}
async getOwnerId(accessToken: string) {
- return this.tokensRepository.getAccessTokenOwnerId(accessToken);
+ const cacheKey = this._generateOwnerIdKey(accessToken);
+
+ try {
+ const ownerId = await this.redisService.redis.get(cacheKey);
+ if (ownerId) {
+ return Number.parseInt(ownerId);
+ }
+ } catch (err) {
+ this.logger.warn("Cache#getOwnerId fetch failed, falling back to DB...");
+ }
+
+ const ownerIdFromDb = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
+
+ if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
+
+ // await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
+ void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
+
+ return ownerIdFromDb;
}
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);
+ const { status, cacheKey } = await this.readFromCache(secret);
if (status === "CACHE_HIT") {
return true;
@@ -43,19 +80,30 @@ export class OAuthFlowService {
throw new TokenExpiredException();
}
+ // we can't use a Promise#all or similar here because we care about execution order
+ // however we can't allow caches to fail a validation hence the results are voided.
+ void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
+ void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
+
return true;
}
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
private async readFromCache(secret: string) {
- return { status: "CACHE_MISS" };
+ const cacheKey = this._generateActKey(secret);
+ const tokenData = await this.redisService.redis.hgetall(cacheKey);
+
+ if (tokenData && new Date() < new Date(tokenData.expiresAt)) {
+ return { status: "CACHE_HIT", cacheKey };
+ }
+
+ return { status: "CACHE_MISS", cacheKey };
}
async exchangeAuthorizationToken(
tokenId: string,
clientId: string,
clientSecret: string
- ): Promise<{ accessToken: string; refreshToken: string }> {
+ ): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
tokenId,
clientId,
@@ -72,15 +120,16 @@ export class OAuthFlowService {
throw new BadRequestException("Invalid Authorization Token.");
}
- const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
+ const { accessToken, refreshToken, accessTokenExpiresAt } = 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
+ void this.propagateAccessToken(accessToken); // void result, ignored.
return {
accessToken,
+ accessTokenExpiresAt,
refreshToken,
};
}
@@ -110,7 +159,16 @@ export class OAuthFlowService {
return {
accessToken: accessToken.secret,
+ accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
};
}
+
+ private _generateActKey(accessToken: string) {
+ return `act_${accessToken}`;
+ }
+
+ private _generateOwnerIdKey(accessToken: string) {
+ return `owner_${accessToken}`;
+ }
}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts
new file mode 100644
index 00000000000000..f2008a31a11ce6
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts
@@ -0,0 +1,156 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
+import { CreateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output";
+import { DeleteTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output";
+import { GetTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output";
+import { GetTeamEventTypesOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output";
+import { UpdateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output";
+import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Post,
+ Param,
+ ParseIntPipe,
+ Body,
+ Patch,
+ Delete,
+ HttpCode,
+ HttpStatus,
+ NotFoundException,
+ Query,
+} from "@nestjs/common";
+import { ApiTags as DocsTags } from "@nestjs/swagger";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ CreateTeamEventTypeInput_2024_06_14,
+ SkipTakePagination,
+ UpdateTeamEventTypeInput_2024_06_14,
+} from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/organizations/:orgId",
+ version: API_VERSIONS_VALUES,
+})
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
+@DocsTags("Organizations Event Types")
+export class OrganizationsEventTypesController {
+ constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
+
+ @Roles("TEAM_ADMIN")
+ @UseGuards(IsTeamInOrg)
+ @Post("/teams/:teamId/event-types")
+ async createTeamEventType(
+ @GetUser() user: UserWithProfile,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14
+ ): Promise {
+ const eventType = await this.organizationsEventTypesService.createTeamEventType(
+ user,
+ teamId,
+ orgId,
+ bodyEventType
+ );
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventType,
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @UseGuards(IsTeamInOrg)
+ @Get("/teams/:teamId/event-types/:eventTypeId")
+ async getTeamEventType(
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("eventTypeId") eventTypeId: number
+ ): Promise {
+ const eventType = await this.organizationsEventTypesService.getTeamEventType(teamId, eventTypeId);
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventType,
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @UseGuards(IsTeamInOrg)
+ @Get("/teams/:teamId/event-types")
+ async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise {
+ const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventTypes,
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @Get("/teams/event-types")
+ async getTeamsEventTypes(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Query() queryParams: SkipTakePagination
+ ): Promise {
+ const { skip, take } = queryParams;
+ const eventTypes = await this.organizationsEventTypesService.getTeamsEventTypes(orgId, skip, take);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventTypes,
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @UseGuards(IsTeamInOrg)
+ @Patch("/teams/:teamId/event-types/:eventTypeId")
+ async updateTeamEventType(
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("eventTypeId") eventTypeId: number,
+ @GetUser() user: UserWithProfile,
+ @Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
+ ): Promise {
+ const eventType = await this.organizationsEventTypesService.updateTeamEventType(
+ eventTypeId,
+ teamId,
+ bodyEventType,
+ user
+ );
+
+ return {
+ status: SUCCESS_STATUS,
+ data: eventType,
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @UseGuards(IsTeamInOrg)
+ @Delete("/teams/:teamId/event-types/:eventTypeId")
+ @HttpCode(HttpStatus.OK)
+ async deleteTeamEventType(
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("eventTypeId") eventTypeId: number
+ ): Promise {
+ const eventType = await this.organizationsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: {
+ id: eventTypeId,
+ title: eventType.title,
+ },
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts
new file mode 100644
index 00000000000000..0f18a9b4a6f6ef
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts
@@ -0,0 +1,595 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
+import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
+import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ ApiSuccessResponse,
+ CreateTeamEventTypeInput_2024_06_14,
+ Host,
+ TeamEventTypeOutput_2024_06_14,
+ UpdateTeamEventTypeInput_2024_06_14,
+} from "@calcom/platform-types";
+import { Team } from "@calcom/prisma/client";
+
+describe("Organizations Event Types Endpoints", () => {
+ describe("User Authentication - User is Org Admin", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let teamsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+ let profileRepositoryFixture: ProfileRepositoryFixture;
+ let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
+
+ let org: Team;
+ let team: Team;
+ let falseTestOrg: Team;
+ let falseTestTeam: Team;
+
+ const userEmail = "org-admin-event-types-controller-e222e@api.com";
+ let userAdmin: User;
+
+ const teammate1Email = "teammate111@team.com";
+ const teammate2Email = "teammate221@team.com";
+ const falseTestUserEmail = "false-user@false-team.com";
+ let teammate1: User;
+ let teammate2: User;
+ let falseTestUser: User;
+
+ let collectiveEventType: TeamEventTypeOutput_2024_06_14;
+ let managedEventType: TeamEventTypeOutput_2024_06_14;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+ profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
+ eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
+
+ userAdmin = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ role: "ADMIN",
+ });
+
+ teammate1 = await userRepositoryFixture.create({
+ email: teammate1Email,
+ username: teammate1Email,
+ });
+
+ teammate2 = await userRepositoryFixture.create({
+ email: teammate2Email,
+ username: teammate2Email,
+ });
+
+ falseTestUser = await userRepositoryFixture.create({
+ email: falseTestUserEmail,
+ username: falseTestUserEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ falseTestOrg = await organizationsRepositoryFixture.create({
+ name: "False test org",
+ isOrganization: true,
+ });
+
+ team = await teamsRepositoryFixture.create({
+ name: "Test org team",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ falseTestTeam = await teamsRepositoryFixture.create({
+ name: "Outside org team",
+ isOrganization: false,
+ parent: { connect: { id: falseTestOrg.id } },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${userAdmin.id}`,
+ username: userEmail,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: userAdmin.id,
+ },
+ },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "ADMIN",
+ user: { connect: { id: userAdmin.id } },
+ team: { connect: { id: org.id } },
+ accepted: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: teammate1.id } },
+ team: { connect: { id: team.id } },
+ accepted: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: teammate2.id } },
+ team: { connect: { id: team.id } },
+ accepted: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: falseTestUser.id } },
+ team: { connect: { id: falseTestTeam.id } },
+ accepted: true,
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(userAdmin).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should not be able to create event-type for team outside org", async () => {
+ const body: CreateTeamEventTypeInput_2024_06_14 = {
+ title: "Coding consultation",
+ slug: "coding-consultation",
+ description: "Our team will review your codebase.",
+ lengthInMinutes: 60,
+ locations: [
+ {
+ type: "integration",
+ integration: "cal-video",
+ },
+ ],
+ schedulingType: "COLLECTIVE",
+ hosts: [
+ {
+ userId: teammate1.id,
+ mandatory: true,
+ priority: "high",
+ },
+ ],
+ };
+
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
+ .send(body)
+ .expect(404);
+ });
+
+ it("should not be able to create event-type for user outside org", async () => {
+ const userId = falseTestUser.id;
+
+ const body: CreateTeamEventTypeInput_2024_06_14 = {
+ title: "Coding consultation",
+ slug: "coding-consultation",
+ description: "Our team will review your codebase.",
+ lengthInMinutes: 60,
+ locations: [
+ {
+ type: "integration",
+ integration: "cal-video",
+ },
+ ],
+ schedulingType: "COLLECTIVE",
+ hosts: [
+ {
+ userId,
+ mandatory: true,
+ priority: "high",
+ },
+ ],
+ };
+
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
+ .send(body)
+ .expect(404);
+ });
+
+ it("should create a collective team event-type", async () => {
+ const body: CreateTeamEventTypeInput_2024_06_14 = {
+ title: "Coding consultation collective",
+ slug: "coding-consultation collective",
+ description: "Our team will review your codebase.",
+ lengthInMinutes: 60,
+ locations: [
+ {
+ type: "integration",
+ integration: "cal-video",
+ },
+ ],
+ bookingFields: [
+ {
+ type: "select",
+ label: "select which language is your codebase in",
+ slug: "select-language",
+ required: true,
+ placeholder: "select language",
+ options: ["javascript", "python", "cobol"],
+ },
+ ],
+ schedulingType: "COLLECTIVE",
+ hosts: [
+ {
+ userId: teammate1.id,
+ mandatory: true,
+ priority: "high",
+ },
+ {
+ userId: teammate2.id,
+ mandatory: false,
+ priority: "low",
+ },
+ ],
+ };
+
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
+ .send(body)
+ .expect(201)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ expect(data.title).toEqual(body.title);
+ expect(data.hosts.length).toEqual(2);
+ evaluateHost(body.hosts[0], data.hosts[0]);
+ evaluateHost(body.hosts[1], data.hosts[1]);
+
+ collectiveEventType = responseBody.data;
+ });
+ });
+
+ it("should create a managed team event-type", async () => {
+ const body: CreateTeamEventTypeInput_2024_06_14 = {
+ title: "Coding consultation managed",
+ slug: "coding-consultation-managed",
+ description: "Our team will review your codebase.",
+ lengthInMinutes: 60,
+ locations: [
+ {
+ type: "integration",
+ integration: "cal-video",
+ },
+ ],
+ schedulingType: "MANAGED",
+ hosts: [
+ {
+ userId: teammate1.id,
+ mandatory: true,
+ priority: "high",
+ },
+ {
+ userId: teammate2.id,
+ mandatory: false,
+ priority: "low",
+ },
+ ],
+ };
+
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
+ .send(body)
+ .expect(201)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ expect(data.length).toEqual(3);
+
+ const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
+ const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
+ const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
+
+ expect(teammate1EventTypes.length).toEqual(1);
+ expect(teammate2EventTypes.length).toEqual(1);
+ expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
+ 1
+ );
+
+ const responseTeamEvent = responseBody.data[0];
+ expect(responseTeamEvent?.teamId).toEqual(team.id);
+
+ const responseTeammate1Event = responseBody.data[1];
+ expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
+ expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
+
+ const responseTeammate2Event = responseBody.data[2];
+ expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
+ expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
+
+ managedEventType = responseTeamEvent;
+ });
+ });
+
+ it("should not get an event-type of team outside org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
+ .expect(404);
+ });
+
+ it("should not get a non existing event-type", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
+ .expect(404);
+ });
+
+ it("should get a team event-type", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ expect(data.title).toEqual(collectiveEventType.title);
+ expect(data.hosts.length).toEqual(2);
+ evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
+ evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
+
+ collectiveEventType = responseBody.data;
+ });
+ });
+
+ it("should not get event-types of team outside org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
+ .expect(404);
+ });
+
+ it("should get team event-types", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
+ expect(data.length).toEqual(2);
+
+ const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
+ const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED");
+
+ expect(eventTypeCollective?.title).toEqual(collectiveEventType.title);
+ expect(eventTypeCollective?.hosts.length).toEqual(2);
+
+ expect(eventTypeManaged?.title).toEqual(managedEventType.title);
+ expect(eventTypeManaged?.hosts.length).toEqual(2);
+ evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]);
+ evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]);
+ });
+ });
+
+ it("should not be able to update event-type for incorrect team", async () => {
+ const body: UpdateTeamEventTypeInput_2024_06_14 = {
+ title: "Clean code consultation",
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
+ .send(body)
+ .expect(404);
+ });
+
+ it("should not be able to update non existing event-type", async () => {
+ const body: UpdateTeamEventTypeInput_2024_06_14 = {
+ title: "Clean code consultation",
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
+ .send(body)
+ .expect(404);
+ });
+
+ it("should update collective event-type", async () => {
+ const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
+ {
+ userId: teammate1.id,
+ mandatory: true,
+ priority: "medium",
+ },
+ ];
+
+ const body: UpdateTeamEventTypeInput_2024_06_14 = {
+ hosts: newHosts,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
+ .send(body)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const eventType = responseBody.data;
+ expect(eventType.title).toEqual(collectiveEventType.title);
+ expect(eventType.hosts.length).toEqual(1);
+ evaluateHost(eventType.hosts[0], newHosts[0]);
+ });
+ });
+
+ it("should update managed event-type", async () => {
+ const newTitle = "Coding consultation managed updated";
+ const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
+ {
+ userId: teammate1.id,
+ mandatory: true,
+ priority: "medium",
+ },
+ ];
+
+ const body: UpdateTeamEventTypeInput_2024_06_14 = {
+ title: newTitle,
+ hosts: newHosts,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
+ .send(body)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ expect(data.length).toEqual(2);
+
+ const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
+ const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
+ const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
+ const managedTeamEventTypes = teamEventTypes.filter(
+ (eventType) => eventType.schedulingType === "MANAGED"
+ );
+
+ expect(teammate1EventTypes.length).toEqual(1);
+ expect(teammate2EventTypes.length).toEqual(0);
+ expect(managedTeamEventTypes.length).toEqual(1);
+ expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
+
+ const responseTeamEvent = responseBody.data[0];
+ expect(responseTeamEvent.title).toEqual(newTitle);
+ expect(responseTeamEvent.assignAllTeamMembers).toEqual(false);
+
+ const responseTeammate1Event = responseBody.data[1];
+ expect(responseTeammate1Event.title).toEqual(newTitle);
+
+ expect(teammate1EventTypes[0].title).toEqual(newTitle);
+ expect(
+ teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title
+ ).toEqual(newTitle);
+
+ managedEventType = responseBody.data[0];
+ });
+ });
+
+ it("should assign all members to managed event-type", async () => {
+ const body: UpdateTeamEventTypeInput_2024_06_14 = {
+ assignAllTeamMembers: true,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
+ .send(body)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+
+ const data = responseBody.data;
+ expect(data.length).toEqual(3);
+
+ const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
+ const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
+ const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
+ const managedTeamEventTypes = teamEventTypes.filter(
+ (eventType) => eventType.schedulingType === "MANAGED"
+ );
+
+ expect(teammate1EventTypes.length).toEqual(1);
+ expect(teammate2EventTypes.length).toEqual(1);
+ expect(managedTeamEventTypes.length).toEqual(1);
+ expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
+
+ const responseTeamEvent = responseBody.data[0];
+ expect(responseTeamEvent?.teamId).toEqual(team.id);
+ expect(responseTeamEvent.assignAllTeamMembers).toEqual(true);
+
+ const responseTeammate1Event = responseBody.data[1];
+ expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
+ expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
+
+ const responseTeammate2Event = responseBody.data[2];
+ expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
+ expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
+
+ managedEventType = responseTeamEvent;
+ });
+ });
+
+ it("should not delete event-type of team outside org", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
+ .expect(404);
+ });
+
+ it("should delete event-type not part of the team", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/99999`)
+ .expect(404);
+ });
+
+ it("should delete collective event-type", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
+ .expect(200);
+ });
+
+ it("should delete managed event-type", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
+ .expect(200);
+ });
+
+ function evaluateHost(expected: Host, received: Host | undefined) {
+ expect(expected.userId).toEqual(received?.userId);
+ expect(expected.mandatory).toEqual(received?.mandatory);
+ expect(expected.priority).toEqual(received?.priority);
+ }
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(userAdmin.email);
+ await userRepositoryFixture.deleteByEmail(teammate1.email);
+ await userRepositoryFixture.deleteByEmail(teammate2.email);
+ await teamsRepositoryFixture.delete(team.id);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output.ts
new file mode 100644
index 00000000000000..960a7d84660ea7
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output.ts
@@ -0,0 +1,21 @@
+import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsNotEmptyObject, ValidateNested } from "class-validator";
+
+import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @ApiProperty({
+ oneOf: [
+ { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
+ {
+ type: "array",
+ items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
+ },
+ ],
+ })
+ @Type(() => TeamEventTypeOutput_2024_06_14)
+ data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output.ts
new file mode 100644
index 00000000000000..300728c55fb03a
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output.ts
@@ -0,0 +1,10 @@
+import { Type } from "class-transformer";
+import { ValidateNested } from "class-validator";
+
+import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class DeleteTeamEventTypeOutput extends ApiResponseWithoutData {
+ @ValidateNested()
+ @Type(() => TeamEventTypeOutput_2024_06_14)
+ data!: Pick;
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output.ts
new file mode 100644
index 00000000000000..3206c66a584f7b
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output.ts
@@ -0,0 +1,10 @@
+import { Type } from "class-transformer";
+import { ValidateNested } from "class-validator";
+
+import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class GetTeamEventTypeOutput extends ApiResponseWithoutData {
+ @ValidateNested()
+ @Type(() => TeamEventTypeOutput_2024_06_14)
+ data!: TeamEventTypeOutput_2024_06_14;
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output.ts
new file mode 100644
index 00000000000000..939c74c369f11d
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output.ts
@@ -0,0 +1,10 @@
+import { Type } from "class-transformer";
+import { ValidateNested } from "class-validator";
+
+import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class GetTeamEventTypesOutput extends ApiResponseWithoutData {
+ @ValidateNested({ each: true })
+ @Type(() => TeamEventTypeOutput_2024_06_14)
+ data!: TeamEventTypeOutput_2024_06_14[];
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output.ts
new file mode 100644
index 00000000000000..403280b7a51aec
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output.ts
@@ -0,0 +1,21 @@
+import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsNotEmptyObject, ValidateNested } from "class-validator";
+
+import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
+
+export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @ApiProperty({
+ oneOf: [
+ { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
+ {
+ type: "array",
+ items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
+ },
+ ],
+ })
+ @Type(() => TeamEventTypeOutput_2024_06_14)
+ data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..f11c54f112804d
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts
@@ -0,0 +1,311 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
+import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
+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 { 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 { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { ApiSuccessResponse } from "@calcom/platform-types";
+import { Membership, Team } from "@calcom/prisma/client";
+
+describe("Organizations Memberships Endpoints", () => {
+ describe("User Authentication - User is Org Admin", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let membership: Membership;
+ let membership2: Membership;
+ let membershipCreatedViaApi: Membership;
+
+ const userEmail = "org-admin-membership-controller-e2e@api.com";
+ const userEmail2 = "org-member-membership-controller-e2e@api.com";
+
+ const invitedUserEmail = "org-member-invited-membership-controller-e2e@api.com";
+
+ let user: User;
+ let user2: User;
+
+ let userToInviteViaApi: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+ user2 = await userRepositoryFixture.create({
+ email: userEmail2,
+ username: userEmail2,
+ });
+
+ userToInviteViaApi = await userRepositoryFixture.create({
+ email: invitedUserEmail,
+ username: invitedUserEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ membership = await membershipsRepositoryFixture.create({
+ role: "ADMIN",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ membership2 = await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user2.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should get all the memberships of the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(membership.id);
+ expect(responseBody.data[1].id).toEqual(membership2.id);
+ });
+ });
+
+ it("should get all the memberships of the org paginated", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(membership2.id);
+ expect(responseBody.data[0].userId).toEqual(user2.id);
+ });
+ });
+
+ it("should fail if org does not exist", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/120494059/memberships`).expect(403);
+ });
+
+ it("should get the membership of the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(membership.id);
+ expect(responseBody.data.userId).toEqual(user.id);
+ });
+ });
+
+ it("should create the membership of the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/memberships`)
+ .send({
+ userId: userToInviteViaApi.id,
+ accepted: true,
+ role: "MEMBER",
+ } satisfies CreateOrgMembershipDto)
+ .expect(201)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ membershipCreatedViaApi = responseBody.data;
+ expect(membershipCreatedViaApi.teamId).toEqual(org.id);
+ expect(membershipCreatedViaApi.role).toEqual("MEMBER");
+ expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
+ });
+ });
+
+ it("should update the membership of the org", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
+ .send({
+ role: "OWNER",
+ } satisfies UpdateOrgMembershipDto)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ membershipCreatedViaApi = responseBody.data;
+ expect(membershipCreatedViaApi.role).toEqual("OWNER");
+ });
+ });
+
+ it("should delete the membership of the org we created via api", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
+ });
+ });
+
+ it("should fail to get the membership of the org we just deleted", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
+ .expect(404);
+ });
+
+ it("should fail if the membership does not exist", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships/123132145`)
+ .expect(404);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await userRepositoryFixture.deleteByEmail(user2.email);
+ await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
+
+describe("Organizations Memberships Endpoints", () => {
+ describe("User Authentication - User is Org Member", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let membership: Membership;
+
+ const userEmail = "org-member-memberships-controller-e2e@api.com";
+ let user: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ membership = await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should deny get all the memberships of the org", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/memberships`).expect(403);
+ });
+
+ it("should deny get all the memberships of the org paginated", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
+ .expect(403);
+ });
+
+ it("should deny get the membership of the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
+ .expect(403);
+ });
+
+ it("should deny create the membership for the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/memberships`)
+ .send({
+ role: "OWNER",
+ userId: user.id,
+ accepted: true,
+ } satisfies CreateOrgMembershipDto)
+ .expect(403);
+ });
+
+ it("should deny update the membership of the org", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/memberships/${membership.id}`)
+ .send({
+ role: "MEMBER",
+ } satisfies Partial)
+ .expect(403);
+ });
+
+ it("should deny delete the membership of the org we created via api", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/memberships/${membership.id}`)
+ .expect(403);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts
new file mode 100644
index 00000000000000..fdf6bc0ec25275
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts
@@ -0,0 +1,127 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
+import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
+import { CreateOrgMembershipOutput } from "@/modules/organizations/outputs/organization-membership/create-membership.output";
+import { DeleteOrgMembership } from "@/modules/organizations/outputs/organization-membership/delete-membership.output";
+import { GetAllOrgMemberships } from "@/modules/organizations/outputs/organization-membership/get-all-memberships.output";
+import { GetOrgMembership } from "@/modules/organizations/outputs/organization-membership/get-membership.output";
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
+import { UpdateOrgMembership } from "@/modules/organizations/outputs/organization-membership/update-membership.output";
+import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Param,
+ ParseIntPipe,
+ Query,
+ Delete,
+ Patch,
+ Post,
+ Body,
+ HttpCode,
+ HttpStatus,
+} from "@nestjs/common";
+import { ApiTags as DocsTags } from "@nestjs/swagger";
+import { plainToClass } from "class-transformer";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { SkipTakePagination } from "@calcom/platform-types";
+import { Membership } from "@calcom/prisma/client";
+
+@Controller({
+ path: "/v2/organizations/:orgId/memberships",
+ version: API_VERSIONS_VALUES,
+})
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
+@DocsTags("Organizations Memberships")
+export class OrganizationsMembershipsController {
+ constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
+
+ @Roles("ORG_ADMIN")
+ @Get("/")
+ @HttpCode(HttpStatus.OK)
+ async getAllMemberships(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Query() queryParams: SkipTakePagination
+ ): Promise {
+ const { skip, take } = queryParams;
+ const memberships = await this.organizationsMembershipService.getPaginatedOrgMemberships(
+ orgId,
+ skip ?? 0,
+ take ?? 250
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: memberships.map((membership) =>
+ plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
+ ),
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @Post("/")
+ @HttpCode(HttpStatus.CREATED)
+ async createMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Body() body: CreateOrgMembershipDto
+ ): Promise {
+ const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsMembershipInOrg)
+ @Get("/:membershipId")
+ @HttpCode(HttpStatus.OK)
+ async getUserSchedule(@GetMembership() membership: Membership): Promise {
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsMembershipInOrg)
+ @Delete("/:membershipId")
+ @HttpCode(HttpStatus.OK)
+ async deleteMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("membershipId", ParseIntPipe) membershipId: number
+ ): Promise {
+ const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @UseGuards(IsMembershipInOrg)
+ @Roles("ORG_ADMIN")
+ @Patch("/:membershipId")
+ @HttpCode(HttpStatus.OK)
+ async updateMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("membershipId", ParseIntPipe) membershipId: number,
+ @Body() body: UpdateOrgMembershipDto
+ ): Promise {
+ const membership = await this.organizationsMembershipService.updateOrgMembership(
+ orgId,
+ membershipId,
+ body
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts
new file mode 100644
index 00000000000000..9eb076e8a8c5c5
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts
@@ -0,0 +1,139 @@
+import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
+import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Post,
+ Param,
+ ParseIntPipe,
+ Body,
+ Patch,
+ Delete,
+ HttpCode,
+ HttpStatus,
+ Query,
+} from "@nestjs/common";
+import { ApiTags as DocsTags } from "@nestjs/swagger";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ CreateScheduleInput_2024_06_11,
+ CreateScheduleOutput_2024_06_11,
+ DeleteScheduleOutput_2024_06_11,
+ GetScheduleOutput_2024_06_11,
+ GetSchedulesOutput_2024_06_11,
+ UpdateScheduleInput_2024_06_11,
+ UpdateScheduleOutput_2024_06_11,
+} from "@calcom/platform-types";
+import { SkipTakePagination } from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/organizations/:orgId",
+ version: API_VERSIONS_VALUES,
+})
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
+@DocsTags("Organizations Schedules")
+export class OrganizationsSchedulesController {
+ constructor(
+ private schedulesService: SchedulesService_2024_06_11,
+ private organizationScheduleService: OrganizationsSchedulesService
+ ) {}
+
+ @Roles("ORG_ADMIN")
+ @Get("/schedules")
+ async getOrganizationSchedules(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Query() queryParams: SkipTakePagination
+ ): Promise {
+ const { skip, take } = queryParams;
+
+ const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedules,
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ @Post("/users/:userId/schedules")
+ async createUserSchedule(
+ @Param("userId", ParseIntPipe) userId: number,
+ @Body() bodySchedule: CreateScheduleInput_2024_06_11
+ ): Promise {
+ const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedule,
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ @Get("/users/:userId/schedules/:scheduleId")
+ async getUserSchedule(
+ @Param("userId", ParseIntPipe) userId: number,
+ @Param("scheduleId") scheduleId: number
+ ): Promise {
+ const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedule,
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ @Get("/users/:userId/schedules")
+ async getUserSchedules(
+ @Param("userId", ParseIntPipe) userId: number
+ ): Promise {
+ const schedules = await this.schedulesService.getUserSchedules(userId);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: schedules,
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ @Patch("/users/:userId/schedules/:scheduleId")
+ async updateUserSchedule(
+ @Param("userId", ParseIntPipe) userId: number,
+ @Param("scheduleId", ParseIntPipe) scheduleId: number,
+ @Body() bodySchedule: UpdateScheduleInput_2024_06_11
+ ): Promise {
+ const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: updatedSchedule,
+ };
+ }
+
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ @Delete("/users/:userId/schedules/:scheduleId")
+ @HttpCode(HttpStatus.OK)
+ async deleteUserSchedule(
+ @Param("userId", ParseIntPipe) userId: number,
+ @Param("scheduleId", ParseIntPipe) scheduleId: number
+ ): Promise {
+ await this.schedulesService.deleteUserSchedule(userId, scheduleId);
+
+ return {
+ status: SUCCESS_STATUS,
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts
new file mode 100644
index 00000000000000..9b972baaa2e76c
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts
@@ -0,0 +1,338 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.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 * as request from "supertest";
+import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
+import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import {
+ CreateScheduleInput_2024_06_11,
+ CreateScheduleOutput_2024_06_11,
+ GetScheduleOutput_2024_06_11,
+ GetSchedulesOutput_2024_06_11,
+ ScheduleAvailabilityInput_2024_06_11,
+ ScheduleOutput_2024_06_11,
+ UpdateScheduleInput_2024_06_11,
+ UpdateScheduleOutput_2024_06_11,
+} from "@calcom/platform-types";
+import { User, Team, Membership, Profile } from "@calcom/prisma/client";
+
+describe("Organizations Schedules Endpoints", () => {
+ describe("User lacks required role", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipFixtures: MembershipRepositoryFixture;
+
+ const userEmail = "mr-robot@schedules-api.com";
+ let user: User;
+ let org: Team;
+ let membership: Membership;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipFixtures = new MembershipRepositoryFixture(moduleRef);
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Ecorp",
+ isOrganization: true,
+ });
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ organization: { connect: { id: org.id } },
+ });
+
+ membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should not be able to create schedule for org user", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
+ .send({
+ name: "work",
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ })
+ .expect(403);
+ });
+
+ it("should not be able to get org schedules", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403);
+ });
+
+ it("should mot be able to get user schedules", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
+ .expect(403);
+ });
+
+ afterAll(async () => {
+ await membershipFixtures.delete(membership.id);
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+
+ describe("User has required role", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipFixtures: MembershipRepositoryFixture;
+ let profileRepositoryFixture: ProfileRepositoryFixture;
+
+ const userEmail = "mr-robot@schedules-api.com";
+ const username = "mr-robot";
+ let user: User;
+ let org: Team;
+ let profile: Profile;
+ let membership: Membership;
+
+ let createdSchedule: ScheduleOutput_2024_06_11;
+
+ const createScheduleInput: CreateScheduleInput_2024_06_11 = {
+ name: "work",
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+
+ const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
+ {
+ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ startTime: "09:00",
+ endTime: "17:00",
+ },
+ ];
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipFixtures = new MembershipRepositoryFixture(moduleRef);
+ profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Ecorp",
+ isOrganization: true,
+ });
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ organization: { connect: { id: org.id } },
+ });
+
+ profile = await profileRepositoryFixture.create({
+ uid: `usr-${user.id}`,
+ username: username,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ });
+
+ membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should create schedule for org user", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
+ .send(createScheduleInput)
+ .expect(201)
+ .then(async (response) => {
+ const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ createdSchedule = response.body.data;
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+ outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
+
+ const scheduleOwner = createdSchedule.ownerId
+ ? await userRepositoryFixture.get(createdSchedule.ownerId)
+ : null;
+ expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
+ });
+ });
+
+ function outputScheduleMatchesExpected(
+ outputSchedule: ScheduleOutput_2024_06_11 | null,
+ expected: CreateScheduleInput_2024_06_11 & {
+ availability: CreateScheduleInput_2024_06_11["availability"];
+ } & {
+ overrides: CreateScheduleInput_2024_06_11["overrides"];
+ },
+ expectedAvailabilityLength: number
+ ) {
+ expect(outputSchedule).toBeTruthy();
+ expect(outputSchedule?.name).toEqual(expected.name);
+ expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
+ expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
+ expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
+
+ const outputScheduleAvailability = outputSchedule?.availability[0];
+ expect(outputScheduleAvailability).toBeDefined();
+ expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
+ expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
+ expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
+
+ expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
+ }
+
+ it("should get org schedules", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/schedules`)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ const schedules = response.body.data;
+ expect(schedules.length).toEqual(1);
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+
+ outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
+ });
+ });
+
+ it("should get org user schedule", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: GetScheduleOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ const schedule = response.body.data;
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+
+ outputScheduleMatchesExpected(schedule, expectedSchedule, 1);
+ });
+ });
+
+ it("should get user schedules", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
+ .expect(200)
+ .then(async (response) => {
+ const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ const schedules = response.body.data;
+ expect(schedules.length).toEqual(1);
+
+ const expectedSchedule = {
+ ...createScheduleInput,
+ availability: defaultAvailability,
+ overrides: [],
+ };
+
+ outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
+ });
+ });
+
+ it("should update user schedule name", async () => {
+ const newScheduleName = "updated-schedule-name";
+
+ const body: UpdateScheduleInput_2024_06_11 = {
+ name: newScheduleName,
+ };
+
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
+ .send(body)
+ .expect(200)
+ .then((response: any) => {
+ const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
+ expect(responseData.status).toEqual(SUCCESS_STATUS);
+ const responseSchedule = responseData.data;
+
+ const expectedSchedule = { ...createdSchedule, name: newScheduleName };
+ outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
+
+ createdSchedule = responseSchedule;
+ });
+ });
+
+ it("should delete user schedule", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
+ .expect(200);
+ });
+
+ afterAll(async () => {
+ await profileRepositoryFixture.delete(profile.id);
+ await membershipFixtures.delete(membership.id);
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..01c774fb6abfe3
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts
@@ -0,0 +1,342 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
+import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
+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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
+import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { ApiSuccessResponse } from "@calcom/platform-types";
+import { Membership, Team } from "@calcom/prisma/client";
+
+describe("Organizations Teams Memberships Endpoints", () => {
+ describe("User Authentication - User is Org Admin", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let teamsRepositoryFixture: TeamRepositoryFixture;
+ let profileRepositoryFixture: ProfileRepositoryFixture;
+
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let orgTeam: Team;
+ let nonOrgTeam: Team;
+ let membership: Membership;
+ let membership2: Membership;
+ let membershipCreatedViaApi: Membership;
+
+ const userEmail = "org-admin-membership-teams-controller-e2e@api.com";
+ const userEmail2 = "org-member-membership-teams-controller-e2e@api.com";
+ const nonOrgUserEmail = "non-org-member-membership-teams-controller-e2e@api.com";
+
+ const invitedUserEmail = "org-member-invited-membership-teams-controller-e2e@api.com";
+
+ let user: User;
+ let user2: User;
+ let nonOrgUser: User;
+
+ let userToInviteViaApi: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
+
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+ user2 = await userRepositoryFixture.create({
+ email: userEmail2,
+ username: userEmail2,
+ });
+
+ nonOrgUser = await userRepositoryFixture.create({
+ email: nonOrgUserEmail,
+ username: nonOrgUserEmail,
+ });
+
+ userToInviteViaApi = await userRepositoryFixture.create({
+ email: invitedUserEmail,
+ username: invitedUserEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ orgTeam = await teamsRepositoryFixture.create({
+ name: "Org Team",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ nonOrgTeam = await teamsRepositoryFixture.create({
+ name: "Non Org Team",
+ isOrganization: false,
+ });
+
+ membership = await membershipsRepositoryFixture.create({
+ role: "ADMIN",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: orgTeam.id } },
+ });
+
+ membership2 = await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user2.id } },
+ team: { connect: { id: orgTeam.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "ADMIN",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user2.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: userToInviteViaApi.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: nonOrgUser.id } },
+ team: { connect: { id: nonOrgTeam.id } },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${user.id}`,
+ username: userEmail,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${user2.id}`,
+ username: userEmail2,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: user2.id,
+ },
+ },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${userToInviteViaApi.id}`,
+ username: invitedUserEmail,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: userToInviteViaApi.id,
+ },
+ },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should get all the memberships of the org's team", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(membership.id);
+ expect(responseBody.data[1].id).toEqual(membership2.id);
+ expect(responseBody.data.length).toEqual(2);
+ });
+ });
+
+ it("should fail to get all the memberships of team which is not in the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
+ .expect(404);
+ });
+
+ it("should get all the memberships of the org's team paginated", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(membership2.id);
+ expect(responseBody.data[0].userId).toEqual(user2.id);
+ expect(responseBody.data.length).toEqual(1);
+ });
+ });
+
+ it("should fail if org does not exist", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/120494059/teams/${orgTeam.id}/memberships`)
+ .expect(403);
+ });
+
+ it("should get the membership of the org's team", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(membership.id);
+ expect(responseBody.data.userId).toEqual(user.id);
+ });
+ });
+
+ it("should fail to get the membership of a team not in the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships/${membership.id}`)
+ .expect(404);
+ });
+
+ it("should fail to create the membership of a team not in the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
+ .send({
+ userId: userToInviteViaApi.id,
+ accepted: true,
+ role: "MEMBER",
+ } satisfies CreateOrgTeamMembershipDto)
+ .expect(404);
+ });
+
+ it("should create the membership of the org's team", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
+ .send({
+ userId: userToInviteViaApi.id,
+ accepted: true,
+ role: "MEMBER",
+ } satisfies CreateOrgTeamMembershipDto)
+ .expect(201)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ membershipCreatedViaApi = responseBody.data;
+ expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
+ expect(membershipCreatedViaApi.role).toEqual("MEMBER");
+ expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
+ });
+ });
+
+ it("should fail to create the membership of the org's team for a non org user", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
+ .send({
+ userId: nonOrgUser.id,
+ accepted: true,
+ role: "MEMBER",
+ } satisfies CreateOrgTeamMembershipDto)
+ .expect(422);
+ });
+
+ it("should update the membership of the org's team", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
+ .send({
+ role: "OWNER",
+ } satisfies UpdateOrgTeamMembershipDto)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ membershipCreatedViaApi = responseBody.data;
+ expect(membershipCreatedViaApi.role).toEqual("OWNER");
+ });
+ });
+
+ it("should delete the membership of the org's team we created via api", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
+ });
+ });
+
+ it("should fail to get the membership of the org's team we just deleted", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
+ .expect(404);
+ });
+
+ it("should fail if the membership does not exist", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/123132145`)
+ .expect(404);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
+ await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
+ await userRepositoryFixture.deleteByEmail(user2.email);
+ await organizationsRepositoryFixture.delete(org.id);
+ await organizationsRepositoryFixture.delete(nonOrgTeam.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts
new file mode 100644
index 00000000000000..7a8a5e90b13a82
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts
@@ -0,0 +1,158 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
+import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
+import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
+import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
+import {
+ OrgTeamMembershipOutputDto,
+ OrgTeamMembershipsOutputResponseDto,
+ OrgTeamMembershipOutputResponseDto,
+} from "@/modules/organizations/outputs/organization-teams-memberships.output";
+import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Param,
+ ParseIntPipe,
+ Query,
+ Delete,
+ Patch,
+ Post,
+ Body,
+ HttpStatus,
+ HttpCode,
+ UnprocessableEntityException,
+} from "@nestjs/common";
+import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
+import { plainToClass } from "class-transformer";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { SkipTakePagination } from "@calcom/platform-types";
+
+@Controller({
+ path: "/v2/organizations/:orgId/teams/:teamId/memberships",
+ version: API_VERSIONS_VALUES,
+})
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
+@DocsTags("Organizations Teams")
+export class OrganizationsTeamsMembershipsController {
+ constructor(
+ private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
+ private readonly organizationsRepository: OrganizationsRepository
+ ) {}
+
+ @Get("/")
+ @ApiOperation({ summary: "Get all the memberships of a team of an organization." })
+ @UseGuards()
+ @Roles("TEAM_ADMIN")
+ @HttpCode(HttpStatus.OK)
+ async getAllOrgTeamMemberships(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Query() queryParams: SkipTakePagination
+ ): Promise {
+ const { skip, take } = queryParams;
+ const orgTeamMemberships = await this.organizationsTeamsMembershipsService.getPaginatedOrgTeamMemberships(
+ orgId,
+ teamId,
+ skip ?? 0,
+ take ?? 250
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: orgTeamMemberships.map((membership) =>
+ plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
+ ),
+ };
+ }
+
+ @Get("/:membershipId")
+ @ApiOperation({ summary: "Get the membership of an organization's team by ID" })
+ @UseGuards()
+ @Roles("TEAM_ADMIN")
+ @HttpCode(HttpStatus.OK)
+ async getOrgTeamMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("membershipId", ParseIntPipe) membershipId: number
+ ): Promise {
+ const orgTeamMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
+ orgId,
+ teamId,
+ membershipId
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @Delete("/:membershipId")
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
+ async deleteOrgTeamMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("membershipId", ParseIntPipe) membershipId: number
+ ): Promise {
+ const membership = await this.organizationsTeamsMembershipsService.deleteOrgTeamMembership(
+ orgId,
+ teamId,
+ membershipId
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @Patch("/:membershipId")
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Update the membership of an organization's team by ID" })
+ async updateOrgTeamMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Param("membershipId", ParseIntPipe) membershipId: number,
+ @Body() data: UpdateOrgTeamMembershipDto
+ ): Promise {
+ const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
+ orgId,
+ teamId,
+ membershipId,
+ data
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Roles("TEAM_ADMIN")
+ @Post("/")
+ @HttpCode(HttpStatus.CREATED)
+ @ApiOperation({ summary: "Create a membership of an organization's team" })
+ async createOrgTeamMembership(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Body() data: CreateOrgTeamMembershipDto
+ ): Promise {
+ const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(data.userId));
+
+ if (!user) {
+ throw new UnprocessableEntityException("User is not part of the Organization");
+ }
+
+ const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts
new file mode 100644
index 00000000000000..6cc9713ab9a98a
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts
@@ -0,0 +1,438 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
+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 { 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 { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { ApiSuccessResponse } from "@calcom/platform-types";
+import { Team } from "@calcom/prisma/client";
+
+describe("Organizations Team Endpoints", () => {
+ describe("User Authentication - User is Org Admin", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let teamsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let team: Team;
+ let team2: Team;
+ let teamCreatedViaApi: Team;
+
+ const userEmail = "org-admin-teams-controller-e2e@api.com";
+ let user: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "ADMIN",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ team = await teamsRepositoryFixture.create({
+ name: "Test org team",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ team2 = await teamsRepositoryFixture.create({
+ name: "Test org team 2",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should get all the teams of the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(team.id);
+ expect(responseBody.data[1].id).toEqual(team2.id);
+ });
+ });
+
+ it("should get all the teams of the org paginated", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams?skip=1&take=1`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data[0].id).toEqual(team2.id);
+ });
+ });
+
+ it("should fail if org does not exist", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403);
+ });
+
+ it("should get the team of the org", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${team.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(team.id);
+ expect(responseBody.data.parentId).toEqual(team.parentId);
+ });
+ });
+
+ it("should create the team of the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams`)
+ .send({
+ name: "Team created via API",
+ } satisfies CreateOrgTeamDto)
+ .expect(201)
+ .then(async (response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ teamCreatedViaApi = responseBody.data;
+ expect(teamCreatedViaApi.name).toEqual("Team created via API");
+ expect(teamCreatedViaApi.parentId).toEqual(org.id);
+ const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
+ user.id,
+ teamCreatedViaApi.id
+ );
+ expect(membership?.role ?? "").toEqual("OWNER");
+ });
+ });
+
+ it("should update the team of the org", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
+ .send({
+ name: "Team created via API Updated",
+ } satisfies CreateOrgTeamDto)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ teamCreatedViaApi = responseBody.data;
+ expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
+ expect(teamCreatedViaApi.parentId).toEqual(org.id);
+ });
+ });
+
+ it("should delete the team of the org we created via api", async () => {
+ return request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
+ .expect(200)
+ .then((response) => {
+ const responseBody: ApiSuccessResponse = response.body;
+ expect(responseBody.status).toEqual(SUCCESS_STATUS);
+ expect(responseBody.data.id).toEqual(teamCreatedViaApi.id);
+ expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId);
+ });
+ });
+
+ it("should fail to get the team of the org we just deleted", async () => {
+ return request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
+ .expect(404);
+ });
+
+ it("should fail if the team does not exist", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await teamsRepositoryFixture.delete(team.id);
+ await teamsRepositoryFixture.delete(team2.id);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
+
+describe("Organizations Team Endpoints", () => {
+ describe("User Authentication - User is Org Member", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let teamsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let team: Team;
+ let team2: Team;
+
+ const userEmail = "org-member-teams-controller-e2e@api.com";
+ let user: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ team = await teamsRepositoryFixture.create({
+ name: "Test org team",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ team2 = await teamsRepositoryFixture.create({
+ name: "Test org team 2",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should deny get all the teams of the org", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
+ });
+
+ it("should deny get all the teams of the org paginated", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
+ });
+
+ it("should deny get the team of the org", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403);
+ });
+
+ it("should deny create the team of the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams`)
+ .send({
+ name: "Team created via API",
+ } satisfies CreateOrgTeamDto)
+ .expect(403);
+ });
+
+ it("should deny update the team of the org", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}`)
+ .send({
+ name: "Team created via API Updated",
+ } satisfies CreateOrgTeamDto)
+ .expect(403);
+ });
+
+ it("should deny delete the team of the org we created via api", async () => {
+ return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await teamsRepositoryFixture.delete(team.id);
+ await teamsRepositoryFixture.delete(team2.id);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
+
+describe("Organizations Team Endpoints", () => {
+ describe("User Authentication - User is Team Owner", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let teamsRepositoryFixture: TeamRepositoryFixture;
+ let membershipsRepositoryFixture: MembershipRepositoryFixture;
+
+ let org: Team;
+ let team: Team;
+ let team2: Team;
+
+ const userEmail = "org-member-teams-owner-controller-e2e@api.com";
+ let user: User;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ });
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test Organization",
+ isOrganization: true,
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "MEMBER",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: org.id } },
+ });
+
+ team = await teamsRepositoryFixture.create({
+ name: "Test org team",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ team2 = await teamsRepositoryFixture.create({
+ name: "Test org team 2",
+ isOrganization: false,
+ parent: { connect: { id: org.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "OWNER",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: team.id } },
+ });
+
+ await membershipsRepositoryFixture.create({
+ role: "OWNER",
+ user: { connect: { id: user.id } },
+ team: { connect: { id: team2.id } },
+ });
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should deny get all the teams of the org", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
+ });
+
+ it("should deny get all the teams of the org paginated", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
+ });
+
+ it("should get the team of the org for which the user is team owner", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200);
+ });
+
+ it("should deny create the team of the org", async () => {
+ return request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/teams`)
+ .send({
+ name: "Team created via API",
+ } satisfies CreateOrgTeamDto)
+ .expect(403);
+ });
+
+ it("should deny update the team of the org", async () => {
+ return request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/teams/${team.id}`)
+ .send({
+ name: "Team created via API Updated",
+ } satisfies CreateOrgTeamDto)
+ .expect(403);
+ });
+
+ it("should deny delete the team of the org we created via api", async () => {
+ return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
+ });
+
+ afterAll(async () => {
+ await userRepositoryFixture.deleteByEmail(user.email);
+ await teamsRepositoryFixture.delete(team.id);
+ await teamsRepositoryFixture.delete(team2.id);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts
new file mode 100644
index 00000000000000..8da15a4b4da6d8
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts
@@ -0,0 +1,117 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
+import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
+import {
+ OrgTeamOutputDto,
+ OrgTeamOutputResponseDto,
+ OrgTeamsOutputResponseDto,
+} from "@/modules/organizations/outputs/organization-team.output";
+import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Param,
+ ParseIntPipe,
+ Query,
+ Delete,
+ Patch,
+ Post,
+ Body,
+} from "@nestjs/common";
+import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
+import { plainToClass } from "class-transformer";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { SkipTakePagination } from "@calcom/platform-types";
+import { Team } from "@calcom/prisma/client";
+
+@Controller({
+ path: "/v2/organizations/:orgId/teams",
+ version: API_VERSIONS_VALUES,
+})
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
+@DocsTags("Organizations Teams")
+export class OrganizationsTeamsController {
+ constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
+
+ @Get()
+ @ApiOperation({ summary: "Get all the teams of an organization." })
+ @UseGuards()
+ @Roles("ORG_ADMIN")
+ async getAllTeams(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Query() queryParams: SkipTakePagination
+ ): Promise {
+ const { skip, take } = queryParams;
+ const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
+ return {
+ status: SUCCESS_STATUS,
+ data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })),
+ };
+ }
+
+ @UseGuards(IsTeamInOrg)
+ @Roles("TEAM_ADMIN")
+ @Get("/:teamId")
+ @ApiOperation({ summary: "Get a team of the organization by ID." })
+ async getTeam(@GetTeam() team: Team): Promise {
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
+ };
+ }
+
+ @UseGuards(IsTeamInOrg)
+ @Roles("ORG_ADMIN")
+ @Delete("/:teamId")
+ @ApiOperation({ summary: "Delete a team of the organization by ID." })
+ async deleteTeam(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number
+ ): Promise {
+ const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
+ };
+ }
+
+ @UseGuards(IsTeamInOrg)
+ @Roles("ORG_ADMIN")
+ @Patch("/:teamId")
+ @ApiOperation({ summary: "Update a team of the organization by ID." })
+ async updateTeam(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("teamId", ParseIntPipe) teamId: number,
+ @Body() body: CreateOrgTeamDto
+ ): Promise {
+ const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Post()
+ @Roles("ORG_ADMIN")
+ @ApiOperation({ summary: "Create a team for an organization." })
+ async createTeam(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Body() body: CreateOrgTeamDto,
+ @GetUser() user: UserWithProfile
+ ): Promise {
+ const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts
new file mode 100644
index 00000000000000..d3dcd572f099f7
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts
@@ -0,0 +1,115 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
+import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
+import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
+import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
+import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
+import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
+import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
+import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
+import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input";
+import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
+import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output";
+import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output";
+import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
+import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import {
+ Controller,
+ UseGuards,
+ Get,
+ Post,
+ Patch,
+ Delete,
+ Param,
+ ParseIntPipe,
+ Body,
+ UseInterceptors,
+ Query,
+} from "@nestjs/common";
+import { ClassSerializerInterceptor } from "@nestjs/common";
+import { ApiTags as DocsTags } from "@nestjs/swagger";
+import { plainToInstance } from "class-transformer";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { Team } from "@calcom/prisma/client";
+
+@Controller({
+ path: "/v2/organizations/:orgId/users",
+ version: API_VERSIONS_VALUES,
+})
+@UseInterceptors(ClassSerializerInterceptor)
+@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
+@UseGuards(IsOrgGuard)
+@DocsTags("Organizations Users")
+export class OrganizationsUsersController {
+ constructor(private readonly organizationsUsersService: OrganizationsUsersService) {}
+
+ @Get()
+ @Roles("ORG_ADMIN")
+ async getOrganizationsUsers(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Query() query: GetOrganizationsUsersInput
+ ): Promise {
+ const users = await this.organizationsUsersService.getUsers(
+ orgId,
+ query.emails,
+ query.skip ?? 0,
+ query.take ?? 250
+ );
+
+ return {
+ status: SUCCESS_STATUS,
+ data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
+ };
+ }
+
+ @Post()
+ @Roles("ORG_ADMIN")
+ async createOrganizationUser(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @GetOrg() org: Team,
+ @Body() input: CreateOrganizationUserInput,
+ @GetUser() inviter: UserWithProfile
+ ): Promise {
+ const user = await this.organizationsUsersService.createUser(
+ org,
+ input,
+ inviter.name ?? inviter.username ?? inviter.email
+ );
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Patch("/:userId")
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ async updateOrganizationUser(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("userId", ParseIntPipe) userId: number,
+ @GetOrg() org: Team,
+ @Body() input: UpdateOrganizationUserInput
+ ): Promise {
+ const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
+ };
+ }
+
+ @Delete("/:userId")
+ @Roles("ORG_ADMIN")
+ @UseGuards(IsUserInOrg)
+ async deleteOrganizationUser(
+ @Param("orgId", ParseIntPipe) orgId: number,
+ @Param("userId", ParseIntPipe) userId: number
+ ): Promise {
+ const user = await this.organizationsUsersService.deleteUser(orgId, userId);
+ return {
+ status: SUCCESS_STATUS,
+ data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
+ };
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts
new file mode 100644
index 00000000000000..2dad8a9e5bcbfa
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts
@@ -0,0 +1,347 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { EmailService } from "@/modules/email/email.service";
+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 * as request from "supertest";
+import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
+import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { withApiAuth } from "test/utils/withApiAuth";
+
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+import { User, Team, Membership } from "@calcom/prisma/client";
+
+describe("Organizations Users Endpoints", () => {
+ describe("Member role", () => {
+ let app: INestApplication;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipFixtures: MembershipRepositoryFixture;
+ let profileRepositoryFixture: ProfileRepositoryFixture;
+
+ const userEmail = "member1@org.com";
+ let user: User;
+ let org: Team;
+ let membership: Membership;
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipFixtures = new MembershipRepositoryFixture(moduleRef);
+ profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test org 3",
+ isOrganization: true,
+ });
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ organization: { connect: { id: org.id } },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${user.id}`,
+ username: userEmail,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ });
+
+ membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should not be able to find org users", async () => {
+ return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`).expect(403);
+ });
+
+ it("should not be able to create a new org user", async () => {
+ return request(app.getHttpServer()).post(`/v2/organizations/${org.id}/users`).expect(403);
+ });
+
+ it("should not be able to update an org user", async () => {
+ return request(app.getHttpServer()).patch(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
+ });
+
+ it("should not be able to delete an org user", async () => {
+ return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
+ });
+
+ afterAll(async () => {
+ // await membershipFixtures.delete(membership.id);
+ await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+
+ await app.close();
+ });
+ });
+ describe("Admin role", () => {
+ let app: INestApplication;
+ let profileRepositoryFixture: ProfileRepositoryFixture;
+ let userRepositoryFixture: UserRepositoryFixture;
+ let organizationsRepositoryFixture: TeamRepositoryFixture;
+ let membershipFixtures: MembershipRepositoryFixture;
+
+ const userEmail = "admin1@org.com";
+ const nonMemberEmail = "non-member@test.com";
+ let user: User;
+ let org: Team;
+ let createdUser: User;
+
+ const orgMembersData = [
+ {
+ email: "member1@org.com",
+ username: "member1@org.com",
+ },
+ {
+ email: "member2@org.com",
+ username: "member2@org.com",
+ },
+ {
+ email: "member3@org.com",
+ username: "member3@org.com",
+ },
+ ];
+
+ beforeAll(async () => {
+ const moduleRef = await withApiAuth(
+ userEmail,
+ Test.createTestingModule({
+ imports: [AppModule, PrismaModule, UsersModule, TokensModule],
+ })
+ ).compile();
+
+ userRepositoryFixture = new UserRepositoryFixture(moduleRef);
+ profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
+
+ organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ membershipFixtures = new MembershipRepositoryFixture(moduleRef);
+
+ org = await organizationsRepositoryFixture.create({
+ name: "Test org 2",
+ isOrganization: true,
+ });
+
+ await userRepositoryFixture.create({
+ email: nonMemberEmail,
+ username: "non-member",
+ });
+
+ const orgMembers = await Promise.all(
+ orgMembersData.map((member) =>
+ userRepositoryFixture.create({
+ email: member.email,
+ username: member.username,
+ organization: { connect: { id: org.id } },
+ })
+ )
+ );
+ // create profiles of orgMember like they would be when being invied to the org
+ await Promise.all(
+ orgMembers.map((member) =>
+ profileRepositoryFixture.create({
+ uid: `usr-${member.id}`,
+ username: member.username ?? `usr-${member.id}`,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: member.id,
+ },
+ },
+ })
+ )
+ );
+
+ user = await userRepositoryFixture.create({
+ email: userEmail,
+ username: userEmail,
+ organization: { connect: { id: org.id } },
+ });
+
+ await profileRepositoryFixture.create({
+ uid: `usr-${user.id}`,
+ username: userEmail,
+ organization: {
+ connect: {
+ id: org.id,
+ },
+ },
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ });
+
+ await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
+ await Promise.all(
+ orgMembers.map((member) => membershipFixtures.addUserToOrg(member, org, "MEMBER", true))
+ );
+
+ app = moduleRef.createNestApplication();
+ bootstrap(app as NestExpressApplication);
+
+ await app.init();
+ });
+
+ it("should be defined", () => {
+ expect(userRepositoryFixture).toBeDefined();
+ expect(organizationsRepositoryFixture).toBeDefined();
+ expect(user).toBeDefined();
+ expect(org).toBeDefined();
+ });
+
+ it("should get all org users", async () => {
+ const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
+
+ const userData = body.data;
+
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.length).toBe(4);
+
+ expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
+ });
+
+ it("should only get users with the specified email", async () => {
+ const { body } = await request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/users`)
+ .query({
+ emails: userEmail,
+ })
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json");
+
+ const userData = body.data;
+
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.length).toBe(1);
+
+ expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
+ });
+
+ it("should get users within the specified emails array", async () => {
+ const orgMemberEmail = orgMembersData[0].email;
+
+ const { body } = await request(app.getHttpServer())
+ .get(`/v2/organizations/${org.id}/users`)
+ .query({
+ emails: [userEmail, orgMemberEmail],
+ })
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json");
+
+ const userData = body.data;
+
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.length).toBe(2);
+
+ expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
+ expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1);
+ });
+
+ it("should update an org user", async () => {
+ const { body } = await request(app.getHttpServer())
+ .patch(`/v2/organizations/${org.id}/users/${user.id}`)
+ .send({
+ theme: "light",
+ })
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json");
+
+ const userData = body.data as User;
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.theme).toBe("light");
+ });
+
+ it("should create a new org user", async () => {
+ const newOrgUser = {
+ email: "new-org-member-b@org.com",
+ organizationRole: "MEMBER",
+ autoAccept: true,
+ };
+
+ const emailSpy = jest
+ .spyOn(EmailService.prototype, "sendSignupToOrganizationEmail")
+ .mockImplementation(() => Promise.resolve());
+ const { body } = await request(app.getHttpServer())
+ .post(`/v2/organizations/${org.id}/users`)
+ .send({
+ email: newOrgUser.email,
+ })
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json");
+
+ const userData = body.data;
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.email).toBe(newOrgUser.email);
+ expect(emailSpy).toHaveBeenCalledWith({
+ usernameOrEmail: newOrgUser.email,
+ orgName: org.name,
+ orgId: org.id,
+ inviterName: "admin1@org.com",
+ locale: null,
+ });
+ createdUser = userData;
+ });
+
+ it("should delete an org user", async () => {
+ const { body } = await request(app.getHttpServer())
+ .delete(`/v2/organizations/${org.id}/users/${createdUser.id}`)
+ .set("Content-Type", "application/json")
+ .set("Accept", "application/json");
+
+ const userData = body.data as User;
+ expect(body.status).toBe(SUCCESS_STATUS);
+ expect(userData.id).toBe(createdUser.id);
+ });
+
+ afterAll(async () => {
+ // await membershipFixtures.delete(membership.id);
+ await Promise.all([
+ userRepositoryFixture.deleteByEmail(user.email),
+ userRepositoryFixture.deleteByEmail(nonMemberEmail),
+ ...orgMembersData.map((member) => userRepositoryFixture.deleteByEmail(member.email)),
+ ]);
+ await organizationsRepositoryFixture.delete(org.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts
new file mode 100644
index 00000000000000..b2e75985a43caa
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-membership.input.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
+
+export class CreateOrgMembershipDto {
+ @IsInt()
+ readonly userId!: number;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly accepted?: boolean = false;
+
+ @IsEnum(MembershipRole)
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ readonly role: MembershipRole = MembershipRole.MEMBER;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly disableImpersonation?: boolean = false;
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts
new file mode 100644
index 00000000000000..f7f1e81dfb988f
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team-membership.input.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
+
+export class CreateOrgTeamMembershipDto {
+ @IsInt()
+ readonly userId!: number;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly accepted?: boolean = false;
+
+ @IsEnum(MembershipRole)
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ readonly role: MembershipRole = MembershipRole.MEMBER;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly disableImpersonation?: boolean = false;
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts
new file mode 100644
index 00000000000000..2167b95c500cff
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts
@@ -0,0 +1,75 @@
+import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
+
+export class CreateOrgTeamDto {
+ @IsString()
+ @Length(1)
+ readonly name!: string;
+
+ @IsOptional()
+ @IsString()
+ readonly slug?: string;
+
+ @IsOptional()
+ @IsUrl()
+ readonly logoUrl?: string;
+
+ @IsOptional()
+ @IsUrl()
+ readonly calVideoLogo?: string;
+
+ @IsOptional()
+ @IsUrl()
+ readonly appLogo?: string;
+
+ @IsOptional()
+ @IsUrl()
+ readonly appIconLogo?: string;
+
+ @IsOptional()
+ @IsString()
+ readonly bio?: string;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly hideBranding?: boolean = false;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly isPrivate?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly hideBookATeamMember?: boolean;
+
+ @IsOptional()
+ @IsString()
+ readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
+
+ @IsOptional()
+ @IsString()
+ readonly theme?: string;
+
+ @IsOptional()
+ @IsString()
+ readonly brandColor?: string;
+
+ @IsOptional()
+ @IsString()
+ readonly darkBrandColor?: string;
+
+ @IsOptional()
+ @IsUrl()
+ readonly bannerUrl?: string;
+
+ @IsOptional()
+ @IsString()
+ readonly timeFormat?: number;
+
+ @IsOptional()
+ @IsString()
+ readonly timeZone?: string = "Europe/London";
+
+ @IsOptional()
+ @IsString()
+ readonly weekStart?: string = "Sunday";
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts
new file mode 100644
index 00000000000000..b2bd5cf3900400
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts
@@ -0,0 +1,17 @@
+import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
+import { MembershipRole } from "@prisma/client";
+import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator";
+
+export class CreateOrganizationUserInput extends CreateUserInput {
+ @IsOptional()
+ @IsString()
+ locale = "en";
+
+ @IsOptional()
+ @IsEnum(MembershipRole)
+ organizationRole: MembershipRole = MembershipRole.MEMBER;
+
+ @IsOptional()
+ @IsBoolean()
+ autoAccept = true;
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts
new file mode 100644
index 00000000000000..6dfc79f57e9710
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts
@@ -0,0 +1,3 @@
+import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
+
+export class GetOrganizationsUsersInput extends GetUsersInput {}
diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts
new file mode 100644
index 00000000000000..ef1e75a1e07a5f
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-membership.input.ts
@@ -0,0 +1,17 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { IsBoolean, IsOptional, IsEnum } from "class-validator";
+
+export class UpdateOrgMembershipDto {
+ @IsOptional()
+ @IsBoolean()
+ readonly accepted?: boolean = false;
+
+ @IsEnum(MembershipRole)
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ readonly role?: MembershipRole = MembershipRole.MEMBER;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly disableImpersonation?: boolean = false;
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts
new file mode 100644
index 00000000000000..2784ca48b28d41
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team-membership.input.ts
@@ -0,0 +1,17 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { IsBoolean, IsOptional, IsEnum } from "class-validator";
+
+export class UpdateOrgTeamMembershipDto {
+ @IsOptional()
+ @IsBoolean()
+ readonly accepted?: boolean = false;
+
+ @IsEnum(MembershipRole)
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ readonly role?: MembershipRole = MembershipRole.MEMBER;
+
+ @IsOptional()
+ @IsBoolean()
+ readonly disableImpersonation?: boolean = false;
+}
diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts
new file mode 100644
index 00000000000000..1cba9543ca7a65
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts
@@ -0,0 +1,3 @@
+import { UpdateUserInput } from "@/modules/users/inputs/update-user.input";
+
+export class UpdateOrganizationUserInput extends UpdateUserInput {}
diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts
index 0ec5c5d73fc36c..a2eed9969a0d75 100644
--- a/apps/api/v2/src/modules/organizations/organizations.module.ts
+++ b/apps/api/v2/src/modules/organizations/organizations.module.ts
@@ -1,11 +1,84 @@
+import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
+import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
+import { EmailModule } from "@/modules/email/email.module";
+import { EmailService } from "@/modules/email/email.service";
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
+import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
+import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
+import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
+import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
+import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
+import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
+import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
+import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
+import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
+import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
+import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
+import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
+import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
+import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
+import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
+import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
+import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
+import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
+import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
+import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { RedisModule } from "@/modules/redis/redis.module";
+import { StripeModule } from "@/modules/stripe/stripe.module";
+import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
- imports: [PrismaModule],
- providers: [OrganizationsRepository, OrganizationsService],
- exports: [OrganizationsService],
+ imports: [
+ PrismaModule,
+ StripeModule,
+ SchedulesModule_2024_06_11,
+ UsersModule,
+ RedisModule,
+ EmailModule,
+ EventTypesModule_2024_06_14,
+ ],
+ providers: [
+ OrganizationsRepository,
+ OrganizationsTeamsRepository,
+ OrganizationsService,
+ OrganizationsTeamsService,
+ MembershipsRepository,
+ OrganizationsSchedulesService,
+ OrganizationSchedulesRepository,
+ OrganizationsUsersRepository,
+ OrganizationsUsersService,
+ EmailService,
+ OrganizationsMembershipRepository,
+ OrganizationsMembershipService,
+ OrganizationsEventTypesService,
+ InputOrganizationsEventTypesService,
+ OutputOrganizationsEventTypesService,
+ OrganizationsEventTypesRepository,
+ OrganizationsTeamsMembershipsRepository,
+ OrganizationsTeamsMembershipsService,
+ ],
+ exports: [
+ OrganizationsService,
+ OrganizationsRepository,
+ OrganizationsTeamsRepository,
+ OrganizationsUsersRepository,
+ OrganizationsUsersService,
+ OrganizationsMembershipRepository,
+ OrganizationsMembershipService,
+ OrganizationsTeamsMembershipsRepository,
+ OrganizationsTeamsMembershipsService,
+ ],
+ controllers: [
+ OrganizationsTeamsController,
+ OrganizationsSchedulesController,
+ OrganizationsUsersController,
+ OrganizationsMembershipsController,
+ OrganizationsEventTypesController,
+ OrganizationsTeamsMembershipsController,
+ ],
})
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
index 98dfa20159a708..0d43c0956095f0 100644
--- a/apps/api/v2/src/modules/organizations/organizations.repository.ts
+++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts
@@ -1,14 +1,84 @@
+import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRepository {
- constructor(private readonly dbRead: PrismaReadService) {}
+ constructor(
+ private readonly dbRead: PrismaReadService,
+ private readonly dbWrite: PrismaWriteService,
+ private readonly stripeService: StripeService
+ ) {}
async findById(organizationId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: organizationId,
+ isOrganization: true,
+ },
+ });
+ }
+
+ async findByIdIncludeBilling(orgId: number) {
+ return this.dbRead.prisma.team.findUnique({
+ where: {
+ id: orgId,
+ },
+ include: {
+ platformBilling: true,
+ },
+ });
+ }
+
+ async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
+ const { id } = await this.stripeService.stripe.customers.create({
+ metadata: {
+ createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
+ },
+ });
+
+ await this.dbWrite.prisma.team.update({
+ where: {
+ id: orgId,
+ },
+ data: {
+ platformBilling: {
+ create: {
+ customerId: id,
+ plan: plan ? plan.toString() : "none",
+ },
+ },
+ },
+ });
+
+ return id;
+ }
+
+ async findTeamIdFromClientId(clientId: string) {
+ return this.dbRead.prisma.team.findFirstOrThrow({
+ where: {
+ platformOAuthClient: {
+ some: {
+ id: clientId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+ }
+ async findOrgUser(organizationId: number, userId: number) {
+ return this.dbRead.prisma.user.findUnique({
+ where: {
+ id: userId,
+ profiles: {
+ some: {
+ organizationId,
+ },
+ },
},
});
}
diff --git a/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts
new file mode 100644
index 00000000000000..0b0b53a3f93773
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts
@@ -0,0 +1,20 @@
+import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsEnum } from "class-validator";
+
+import { ERROR_STATUS } from "@calcom/platform-constants";
+import { SUCCESS_STATUS } from "@calcom/platform-constants";
+
+export class GetOrganizationUsersOutput {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+ data!: GetUserOutput[];
+}
+
+export class GetOrganizationUserOutput {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+ data!: GetUserOutput;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts
new file mode 100644
index 00000000000000..5a129c98246878
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/create-membership.output.ts
@@ -0,0 +1,20 @@
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.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 CreateOrgMembershipOutput {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: OrgMembershipOutputDto,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => OrgMembershipOutputDto)
+ data!: OrgMembershipOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts
new file mode 100644
index 00000000000000..2f3005ba950e28
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/delete-membership.output.ts
@@ -0,0 +1,13 @@
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsEnum } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+
+export class DeleteOrgMembership {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ data!: OrgMembershipOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts
new file mode 100644
index 00000000000000..3114c2c899fa31
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-all-memberships.output.ts
@@ -0,0 +1,21 @@
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+
+export class GetAllOrgMemberships {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: OrgMembershipOutputDto,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested({ each: true })
+ @Type(() => OrgMembershipOutputDto)
+ @IsArray()
+ data!: OrgMembershipOutputDto[];
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts
new file mode 100644
index 00000000000000..416a3a6bb4a95b
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/get-membership.output.ts
@@ -0,0 +1,20 @@
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.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 GetOrgMembership {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: OrgMembershipOutputDto,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => OrgMembershipOutputDto)
+ data!: OrgMembershipOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts
new file mode 100644
index 00000000000000..4557ec9457dad2
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/membership.output.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { Expose } from "class-transformer";
+import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
+
+export class OrgMembershipOutputDto {
+ @IsInt()
+ @Expose()
+ readonly id!: number;
+
+ @IsInt()
+ @Expose()
+ readonly userId!: number;
+
+ @IsInt()
+ @Expose()
+ readonly teamId!: number;
+
+ @IsBoolean()
+ @Expose()
+ readonly accepted!: boolean;
+
+ @IsString()
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ @Expose()
+ readonly role!: MembershipRole;
+
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ readonly disableImpersonation?: boolean;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts
new file mode 100644
index 00000000000000..d9718221699771
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-membership/update-membership.output.ts
@@ -0,0 +1,20 @@
+import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.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 UpdateOrgMembership {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @ApiProperty({
+ type: OrgMembershipOutputDto,
+ })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => OrgMembershipOutputDto)
+ data!: OrgMembershipOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts
new file mode 100644
index 00000000000000..32c2134ba5c5ec
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts
@@ -0,0 +1,141 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Expose, Type } from "class-transformer";
+import {
+ IsBoolean,
+ IsEnum,
+ IsInt,
+ IsOptional,
+ IsString,
+ IsUrl,
+ Length,
+ ValidateNested,
+} from "class-validator";
+
+import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
+
+export class OrgTeamOutputDto {
+ @IsInt()
+ @Expose()
+ readonly id!: number;
+
+ @IsInt()
+ @IsOptional()
+ @Expose()
+ readonly parentId?: number;
+
+ @IsString()
+ @Length(1)
+ @Expose()
+ readonly name!: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly slug?: string;
+
+ @IsOptional()
+ @IsUrl()
+ @Expose()
+ readonly logoUrl?: string;
+
+ @IsOptional()
+ @IsUrl()
+ @Expose()
+ readonly calVideoLogo?: string;
+
+ @IsOptional()
+ @IsUrl()
+ @Expose()
+ readonly appLogo?: string;
+
+ @IsOptional()
+ @IsUrl()
+ @Expose()
+ readonly appIconLogo?: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly bio?: string;
+
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ readonly hideBranding?: boolean;
+
+ @IsBoolean()
+ @Expose()
+ readonly isOrganization?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ readonly isPrivate?: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ readonly hideBookATeamMember?: boolean = false;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly metadata?: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly theme?: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly brandColor?: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly darkBrandColor?: string;
+
+ @IsOptional()
+ @IsUrl()
+ @Expose()
+ readonly bannerUrl?: string;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly timeFormat?: number;
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly timeZone?: string = "Europe/London";
+
+ @IsOptional()
+ @IsString()
+ @Expose()
+ readonly weekStart?: string = "Sunday";
+}
+
+export class OrgTeamsOutputResponseDto {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @Expose()
+ @ValidateNested()
+ @Type(() => OrgTeamOutputDto)
+ data!: OrgTeamOutputDto[];
+}
+
+export class OrgTeamOutputResponseDto {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @Expose()
+ @ValidateNested()
+ @Type(() => OrgTeamOutputDto)
+ data!: OrgTeamOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts
new file mode 100644
index 00000000000000..ceff7066cc7c0c
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts
@@ -0,0 +1,57 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { MembershipRole } from "@prisma/client";
+import { Expose, Type } from "class-transformer";
+import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator";
+
+import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
+
+export class OrgTeamMembershipOutputDto {
+ @IsInt()
+ @Expose()
+ readonly id!: number;
+
+ @IsInt()
+ @Expose()
+ readonly userId!: number;
+
+ @IsInt()
+ @Expose()
+ readonly teamId!: number;
+
+ @IsBoolean()
+ @Expose()
+ readonly accepted!: boolean;
+
+ @IsString()
+ @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
+ @Expose()
+ readonly role!: MembershipRole;
+
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ readonly disableImpersonation?: boolean;
+}
+
+export class OrgTeamMembershipsOutputResponseDto {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @Expose()
+ @ValidateNested()
+ @Type(() => OrgTeamMembershipOutputDto)
+ @IsArray()
+ data!: OrgTeamMembershipOutputDto[];
+}
+
+export class OrgTeamMembershipOutputResponseDto {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ @Expose()
+ @ValidateNested()
+ @Type(() => OrgTeamMembershipOutputDto)
+ data!: OrgTeamMembershipOutputDto;
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts
new file mode 100644
index 00000000000000..b71d77fa215f96
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-event-types.repository.ts
@@ -0,0 +1,61 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsEventTypesRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async getTeamEventType(teamId: number, eventTypeId: number) {
+ return this.dbRead.prisma.eventType.findUnique({
+ where: {
+ id: eventTypeId,
+ teamId,
+ },
+ include: { users: true, schedule: true, hosts: true },
+ });
+ }
+
+ async getTeamEventTypes(teamId: number) {
+ return this.dbRead.prisma.eventType.findMany({
+ where: {
+ teamId,
+ },
+ include: { users: true, schedule: true, hosts: true },
+ });
+ }
+
+ async getEventTypeById(eventTypeId: number) {
+ return this.dbRead.prisma.eventType.findUnique({
+ where: { id: eventTypeId },
+ include: { users: true, schedule: true, hosts: true },
+ });
+ }
+
+ async getEventTypeChildren(eventTypeId: number) {
+ return this.dbRead.prisma.eventType.findMany({
+ where: { parentId: eventTypeId },
+ include: { users: true, schedule: true, hosts: true },
+ });
+ }
+
+ async getTeamsEventTypes(orgId: number, skip: number, take: number) {
+ return this.dbRead.prisma.eventType.findMany({
+ where: {
+ team: {
+ parentId: orgId,
+ },
+ },
+ skip,
+ take,
+ include: { users: true, schedule: true, hosts: true },
+ });
+ }
+
+ async getEventTypeByIdWithChildren(eventTypeId: number) {
+ return this.dbRead.prisma.eventType.findUnique({
+ where: { id: eventTypeId },
+ include: { children: true },
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts
new file mode 100644
index 00000000000000..2f2d3dd9488161
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts
@@ -0,0 +1,52 @@
+import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
+
+@Injectable()
+export class OrganizationsMembershipRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async findOrgMembership(organizationId: number, membershipId: number) {
+ return this.dbRead.prisma.membership.findUnique({
+ where: {
+ id: membershipId,
+ teamId: organizationId,
+ },
+ });
+ }
+
+ async deleteOrgMembership(organizationId: number, membershipId: number) {
+ return this.dbWrite.prisma.membership.delete({
+ where: {
+ id: membershipId,
+ teamId: organizationId,
+ },
+ });
+ }
+
+ async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
+ return this.dbWrite.prisma.membership.create({
+ data: { ...data, teamId: organizationId },
+ });
+ }
+
+ async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
+ return this.dbWrite.prisma.membership.update({
+ data: { ...data },
+ where: { id: membershipId, teamId: organizationId },
+ });
+ }
+
+ async findOrgMembershipsPaginated(organizationId: number, skip: number, take: number) {
+ return this.dbRead.prisma.membership.findMany({
+ where: {
+ teamId: organizationId,
+ },
+ skip,
+ take,
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts
new file mode 100644
index 00000000000000..e652a85df79654
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-schedules.repository.ts
@@ -0,0 +1,23 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationSchedulesRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
+ return this.dbRead.prisma.schedule.findMany({
+ where: {
+ userId: {
+ in: userIds,
+ },
+ },
+ include: {
+ availability: true,
+ },
+ skip,
+ take,
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts
new file mode 100644
index 00000000000000..470bab239f8aae
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts
@@ -0,0 +1,70 @@
+import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
+import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsTeamsMembershipsRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) {
+ return this.dbRead.prisma.membership.findMany({
+ where: {
+ teamId: teamId,
+ team: {
+ parentId: organizationId,
+ },
+ },
+ skip,
+ take,
+ });
+ }
+
+ async findOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
+ return this.dbRead.prisma.membership.findUnique({
+ where: {
+ id: membershipId,
+ teamId: teamId,
+ team: {
+ parentId: organizationId,
+ },
+ },
+ });
+ }
+ async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
+ return this.dbWrite.prisma.membership.delete({
+ where: {
+ id: membershipId,
+ teamId: teamId,
+ team: {
+ parentId: organizationId,
+ },
+ },
+ });
+ }
+
+ async updateOrgTeamMembershipById(
+ organizationId: number,
+ teamId: number,
+ membershipId: number,
+ data: UpdateOrgTeamMembershipDto
+ ) {
+ return this.dbWrite.prisma.membership.update({
+ data: { ...data },
+ where: {
+ id: membershipId,
+ teamId: teamId,
+ team: {
+ parentId: organizationId,
+ },
+ },
+ });
+ }
+
+ async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
+ return this.dbWrite.prisma.membership.create({
+ data: { ...data, teamId: teamId },
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts
new file mode 100644
index 00000000000000..3b130127185bf9
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts
@@ -0,0 +1,67 @@
+import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsTeamsRepository {
+ constructor(private readonly dbRead: PrismaReadService) {}
+
+ async findOrgTeam(organizationId: number, teamId: number) {
+ return this.dbRead.prisma.team.findUnique({
+ where: {
+ id: teamId,
+ isOrganization: false,
+ parentId: organizationId,
+ },
+ });
+ }
+
+ async deleteOrgTeam(organizationId: number, teamId: number) {
+ return this.dbRead.prisma.team.delete({
+ where: {
+ id: teamId,
+ isOrganization: false,
+ parentId: organizationId,
+ },
+ });
+ }
+
+ async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
+ return this.dbRead.prisma.team.create({
+ data: { ...data, parentId: organizationId },
+ });
+ }
+
+ async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
+ return this.dbRead.prisma.team.update({
+ data: { ...data },
+ where: { id: teamId, parentId: organizationId, isOrganization: false },
+ });
+ }
+
+ async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) {
+ return this.dbRead.prisma.team.findMany({
+ where: {
+ parentId: organizationId,
+ },
+ skip,
+ take,
+ });
+ }
+
+ async getTeamMembersIds(teamId: number) {
+ const team = await this.dbRead.prisma.team.findUnique({
+ where: {
+ id: teamId,
+ },
+ include: {
+ members: true,
+ },
+ });
+ if (!team) {
+ return [];
+ }
+
+ return team.members.map((member) => member.userId);
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts
new file mode 100644
index 00000000000000..5612972e5c7f8e
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts
@@ -0,0 +1,76 @@
+import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
+import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsUsersRepository {
+ constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
+
+ private filterOnOrgMembership(orgId: number) {
+ return {
+ profiles: {
+ some: {
+ organizationId: orgId,
+ },
+ },
+ };
+ }
+
+ async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) {
+ return await this.dbRead.prisma.user.findMany({
+ where: {
+ ...this.filterOnOrgMembership(orgId),
+ ...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
+ },
+ skip,
+ take,
+ });
+ }
+
+ async getOrganizationUserByUsername(orgId: number, username: string) {
+ return await this.dbRead.prisma.user.findFirst({
+ where: {
+ username,
+ ...this.filterOnOrgMembership(orgId),
+ },
+ });
+ }
+
+ async getOrganizationUserByEmail(orgId: number, email: string) {
+ return await this.dbRead.prisma.user.findFirst({
+ where: {
+ email,
+ ...this.filterOnOrgMembership(orgId),
+ },
+ });
+ }
+
+ async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) {
+ const createdUser = await this.dbWrite.prisma.user.create({
+ data: createUserBody,
+ });
+
+ return createdUser;
+ }
+
+ async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) {
+ return await this.dbWrite.prisma.user.update({
+ where: {
+ id: userId,
+ organizationId: orgId,
+ },
+ data: updateUserBody,
+ });
+ }
+
+ async deleteUser(orgId: number, userId: number) {
+ return await this.dbWrite.prisma.user.delete({
+ where: {
+ id: userId,
+ organizationId: orgId,
+ },
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts
new file mode 100644
index 00000000000000..766cf64c42a96b
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts
@@ -0,0 +1,162 @@
+import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
+import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
+import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Injectable } from "@nestjs/common";
+
+import {
+ CreateTeamEventTypeInput_2024_06_14,
+ UpdateTeamEventTypeInput_2024_06_14,
+ HostPriority,
+} from "@calcom/platform-types";
+
+@Injectable()
+export class InputOrganizationsEventTypesService {
+ constructor(
+ private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
+ private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
+ private readonly usersRepository: UsersRepository,
+ private readonly orgEventTypesRepository: OrganizationsEventTypesRepository
+ ) {}
+ async transformInputCreateTeamEventType(
+ teamId: number,
+ inputEventType: CreateTeamEventTypeInput_2024_06_14
+ ) {
+ const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
+
+ const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
+
+ const metadata = rest.schedulingType === "MANAGED" ? { managedEventConfig: {} } : undefined;
+
+ const teamEventType = {
+ ...eventType,
+ hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts),
+ assignAllTeamMembers,
+ metadata,
+ };
+
+ return teamEventType;
+ }
+
+ async transformInputUpdateTeamEventType(
+ eventTypeId: number,
+ teamId: number,
+ inputEventType: UpdateTeamEventTypeInput_2024_06_14
+ ) {
+ const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
+
+ const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
+
+ const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
+ const teamEventType = {
+ ...eventType,
+ // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
+ hosts: !children
+ ? assignAllTeamMembers
+ ? await this.getAllTeamMembers(teamId)
+ : this.transformInputHosts(hosts)
+ : undefined,
+ assignAllTeamMembers,
+ children,
+ };
+
+ return teamEventType;
+ }
+
+ async getChildEventTypesForManagedEventType(
+ eventTypeId: number,
+ inputEventType: UpdateTeamEventTypeInput_2024_06_14,
+ teamId: number
+ ) {
+ const eventType = await this.orgEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
+
+ if (!eventType || eventType.schedulingType !== "MANAGED") {
+ return undefined;
+ }
+
+ const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType);
+ const owners = await this.getOwnersForManagedEventType(ownersIds);
+
+ return owners.map((owner) => {
+ return {
+ hidden: false,
+ owner,
+ };
+ });
+ }
+
+ async getOwnersIdsForManagedEventType(
+ teamId: number,
+ inputEventType: UpdateTeamEventTypeInput_2024_06_14,
+ eventType: { children: { userId: number | null }[] }
+ ) {
+ if (inputEventType.assignAllTeamMembers) {
+ return await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
+ }
+
+ // note(Lauris): when API user updates managed event type users
+ if (inputEventType.hosts) {
+ return inputEventType.hosts.map((host) => host.userId);
+ }
+
+ // note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
+ return eventType.children.map((child) => child.userId).filter((id) => !!id) as number[];
+ }
+
+ async getOwnersForManagedEventType(userIds: number[]) {
+ const users = await this.usersRepository.findByIdsWithEventTypes(userIds);
+
+ return users.map((user) => {
+ const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId);
+ return {
+ id: user.id,
+ name: user.name || user.email,
+ email: user.email,
+ // note(Lauris): managed event types slugs have to be excluded otherwise checkExistentEventTypes within handleChildrenEventTypes.ts will incorrectly delete managed user event type.
+ eventTypeSlugs: nonManagedEventTypes.map((eventType) => eventType.slug),
+ };
+ });
+ }
+
+ async getAllTeamMembers(teamId: number) {
+ const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
+
+ return membersIds.map((id) => ({
+ userId: id,
+ isFixed: false,
+ priority: 2,
+ }));
+ }
+
+ transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
+ if (!inputHosts) {
+ return undefined;
+ }
+
+ const defaultMandatory = false;
+ const defaultPriority = "medium";
+
+ return inputHosts.map((host) => ({
+ userId: host.userId,
+ isFixed: host.mandatory || defaultMandatory,
+ priority: getPriorityValue(host.priority || defaultPriority),
+ }));
+ }
+}
+
+function getPriorityValue(priority: keyof typeof HostPriority): number {
+ switch (priority) {
+ case "lowest":
+ return 0;
+ case "low":
+ return 1;
+ case "medium":
+ return 2;
+ case "high":
+ return 3;
+ case "highest":
+ return 4;
+ default:
+ throw new Error("Invalid HostPriority label");
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts
new file mode 100644
index 00000000000000..5ddcafe750f3cf
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts
@@ -0,0 +1,179 @@
+import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
+import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
+import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
+import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
+import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
+import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import { Injectable, NotFoundException } from "@nestjs/common";
+
+import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.19";
+import {
+ CreateTeamEventTypeInput_2024_06_14,
+ UpdateTeamEventTypeInput_2024_06_14,
+} from "@calcom/platform-types";
+
+@Injectable()
+export class OrganizationsEventTypesService {
+ constructor(
+ private readonly inputService: InputOrganizationsEventTypesService,
+ private readonly eventTypesService: EventTypesService_2024_06_14,
+ private readonly dbWrite: PrismaWriteService,
+ private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
+ private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
+ private readonly outputService: OutputOrganizationsEventTypesService,
+ private readonly membershipsRepository: MembershipsRepository,
+ private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
+ ) {}
+
+ async createTeamEventType(
+ user: UserWithProfile,
+ teamId: number,
+ orgId: number,
+ body: CreateTeamEventTypeInput_2024_06_14
+ ) {
+ await this.validateHosts(teamId, body.hosts);
+ const eventTypeUser = await this.getUserToCreateTeamEvent(user, orgId);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { hosts, assignAllTeamMembers, ...rest } =
+ await this.inputService.transformInputCreateTeamEventType(teamId, body);
+ const { eventType: eventTypeCreated } = await createEventType({
+ input: { teamId: teamId, ...rest },
+ ctx: {
+ user: eventTypeUser,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ prisma: this.dbWrite.prisma,
+ },
+ });
+
+ return await this.updateTeamEventType(
+ eventTypeCreated.id,
+ teamId,
+ { hosts: body.hosts, assignAllTeamMembers },
+ user
+ );
+ }
+
+ async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
+ if (hosts && hosts.length) {
+ const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
+ const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId));
+ if (invalidHosts.length) {
+ throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`);
+ }
+ }
+ }
+
+ async validateEventTypeExists(teamId: number, eventTypeId: number) {
+ const eventType = await this.organizationEventTypesRepository.getTeamEventType(teamId, eventTypeId);
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+ }
+
+ async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
+ const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
+ const profileId = user.movedToProfileId || null;
+ return {
+ id: user.id,
+ role: user.role,
+ organizationId: user.organizationId,
+ organization: { isOrgAdmin },
+ profile: { id: profileId },
+ metadata: user.metadata,
+ };
+ }
+
+ async getTeamEventType(teamId: number, eventTypeId: number) {
+ const eventType = await this.organizationEventTypesRepository.getTeamEventType(teamId, eventTypeId);
+
+ if (!eventType) {
+ return null;
+ }
+
+ return this.outputService.getResponseTeamEventType(eventType);
+ }
+
+ async getTeamEventTypes(teamId: number) {
+ const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
+
+ const eventTypePromises = eventTypes.map(async (eventType) => {
+ return await this.outputService.getResponseTeamEventType(eventType);
+ });
+
+ return await Promise.all(eventTypePromises);
+ }
+
+ async getTeamsEventTypes(orgId: number, skip = 0, take = 250) {
+ const eventTypes = await this.organizationEventTypesRepository.getTeamsEventTypes(orgId, skip, take);
+
+ const eventTypePromises = eventTypes.map(async (eventType) => {
+ return await this.outputService.getResponseTeamEventType(eventType);
+ });
+
+ return await Promise.all(eventTypePromises);
+ }
+
+ async updateTeamEventType(
+ eventTypeId: number,
+ teamId: number,
+ body: UpdateTeamEventTypeInput_2024_06_14,
+ user: UserWithProfile
+ ) {
+ await this.validateEventTypeExists(teamId, eventTypeId);
+ await this.validateHosts(teamId, body.hosts);
+ const eventTypeUser = await this.eventTypesService.getUserToUpdateEvent(user);
+ const bodyTransformed = await this.inputService.transformInputUpdateTeamEventType(
+ eventTypeId,
+ teamId,
+ body
+ );
+
+ await updateEventType({
+ input: { id: eventTypeId, ...bodyTransformed },
+ ctx: {
+ user: eventTypeUser,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ prisma: this.dbWrite.prisma,
+ },
+ });
+
+ const eventType = await this.organizationEventTypesRepository.getEventTypeById(eventTypeId);
+
+ if (!eventType) {
+ throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+ }
+
+ if (eventType.schedulingType !== "MANAGED") {
+ return this.outputService.getResponseTeamEventType(eventType);
+ }
+
+ const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventType.id);
+
+ const eventTypes = [eventType, ...children];
+
+ const eventTypePromises = eventTypes.map(async (e) => {
+ return await this.outputService.getResponseTeamEventType(e);
+ });
+
+ return await Promise.all(eventTypePromises);
+ }
+
+ async deleteTeamEventType(teamId: number, eventTypeId: number) {
+ const existingEventType = await this.organizationEventTypesRepository.getTeamEventType(
+ teamId,
+ eventTypeId
+ );
+
+ if (!existingEventType) {
+ throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
+ }
+
+ return this.eventTypesRepository.deleteEventType(eventTypeId);
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts
new file mode 100644
index 00000000000000..3eaf5eb0091517
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/event-types/output.service.ts
@@ -0,0 +1,114 @@
+import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
+import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
+import { Injectable } from "@nestjs/common";
+import type { EventType, User, Schedule, Host } from "@prisma/client";
+
+import { HostPriority } from "@calcom/platform-types";
+
+type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
+type DatabaseEventType = EventType & EventTypeRelations;
+
+type Input = Pick<
+ DatabaseEventType,
+ | "id"
+ | "length"
+ | "title"
+ | "description"
+ | "disableGuests"
+ | "slotInterval"
+ | "minimumBookingNotice"
+ | "beforeEventBuffer"
+ | "afterEventBuffer"
+ | "slug"
+ | "schedulingType"
+ | "requiresConfirmation"
+ | "price"
+ | "currency"
+ | "lockTimeZoneToggleOnBookingPage"
+ | "seatsPerTimeSlot"
+ | "forwardParamsSuccessRedirect"
+ | "successRedirectUrl"
+ | "seatsShowAvailabilityCount"
+ | "isInstantEvent"
+ | "locations"
+ | "bookingFields"
+ | "recurringEvent"
+ | "metadata"
+ | "users"
+ | "schedule"
+ | "hosts"
+ | "teamId"
+ | "userId"
+ | "parentId"
+ | "assignAllTeamMembers"
+>;
+
+@Injectable()
+export class OutputOrganizationsEventTypesService {
+ constructor(
+ private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
+ private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository
+ ) {}
+
+ async getResponseTeamEventType(databaseEventType: Input) {
+ const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { ownerId, users, ...rest } = await this.outputEventTypesService.getResponseEventType(
+ 0,
+ databaseEventType
+ );
+ const hosts =
+ databaseEventType.schedulingType === "MANAGED"
+ ? await this.getManagedEventTypeHosts(databaseEventType.id)
+ : this.transformHosts(databaseEventType.hosts);
+
+ return {
+ ...rest,
+ hosts,
+ teamId,
+ ownerId: userId,
+ parentEventTypeId: parentId,
+ assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
+ };
+ }
+
+ async getManagedEventTypeHosts(eventTypeId: number) {
+ const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
+ const hostsIds: number[] = [];
+ for (const child of children) {
+ if (child.userId) {
+ hostsIds.push(child.userId);
+ }
+ }
+ return hostsIds.map((userId) => ({ userId }));
+ }
+
+ transformHosts(hosts: Host[]) {
+ if (!hosts) return [];
+
+ return hosts.map((host) => {
+ return {
+ userId: host.userId,
+ mandatory: host.isFixed,
+ priority: getPriorityLabel(host.priority || 2),
+ };
+ });
+ }
+}
+
+function getPriorityLabel(priority: number): keyof typeof HostPriority {
+ switch (priority) {
+ case 0:
+ return "lowest";
+ case 1:
+ return "low";
+ case 2:
+ return "medium";
+ case 3:
+ return "high";
+ case 4:
+ return "highest";
+ default:
+ throw new Error("Invalid HostPriority value");
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts
new file mode 100644
index 00000000000000..c1a393dbda73d0
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts
@@ -0,0 +1,49 @@
+import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
+import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
+import { Injectable } from "@nestjs/common";
+
+import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
+
+@Injectable()
+export class OrganizationsMembershipService {
+ constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
+
+ async getOrgMembership(organizationId: number, membershipId: number) {
+ const membership = await this.organizationsMembershipRepository.findOrgMembership(
+ organizationId,
+ membershipId
+ );
+ return membership;
+ }
+
+ async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
+ const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated(
+ organizationId,
+ skip,
+ take
+ );
+ return memberships;
+ }
+
+ async deleteOrgMembership(organizationId: number, membershipId: number) {
+ const membership = await this.organizationsMembershipRepository.deleteOrgMembership(
+ organizationId,
+ membershipId
+ );
+ return membership;
+ }
+
+ async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
+ const membership = await this.organizationsMembershipRepository.updateOrgMembership(
+ organizationId,
+ membershipId,
+ data
+ );
+ return membership;
+ }
+
+ async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
+ const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
+ return membership;
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts
new file mode 100644
index 00000000000000..7dfd39f0330f1c
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/organizations-schedules.service.ts
@@ -0,0 +1,30 @@
+import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
+import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Injectable } from "@nestjs/common";
+
+import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
+
+@Injectable()
+export class OrganizationsSchedulesService {
+ constructor(
+ private readonly organizationSchedulesService: OrganizationSchedulesRepository,
+ private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
+ private readonly usersRepository: UsersRepository
+ ) {}
+
+ async getOrganizationSchedules(organizationId: number, skip = 0, take = 250) {
+ const users = await this.usersRepository.getOrganizationUsers(organizationId);
+ const usersIds = users.map((user) => user.id);
+
+ const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
+
+ const responseSchedules: ScheduleOutput_2024_06_11[] = [];
+
+ for (const schedule of schedules) {
+ responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
+ }
+
+ return responseSchedules;
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts
new file mode 100644
index 00000000000000..8eb65f30cc7cfb
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/organizations-teams-memberships.service.ts
@@ -0,0 +1,68 @@
+import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
+import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
+import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
+import { Injectable, NotFoundException } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsTeamsMembershipsService {
+ constructor(
+ private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository
+ ) {}
+
+ async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
+ const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership(
+ teamId,
+ data
+ );
+ return teamMembership;
+ }
+
+ async getPaginatedOrgTeamMemberships(organizationId: number, teamId: number, skip = 0, take = 250) {
+ const teamMemberships =
+ await this.organizationsTeamsMembershipsRepository.findOrgTeamMembershipsPaginated(
+ organizationId,
+ teamId,
+ skip,
+ take
+ );
+ return teamMemberships;
+ }
+
+ async getOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
+ const teamMemberships = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership(
+ organizationId,
+ teamId,
+ membershipId
+ );
+
+ if (!teamMemberships) {
+ throw new NotFoundException("Organization's Team membership not found");
+ }
+
+ return teamMemberships;
+ }
+
+ async updateOrgTeamMembership(
+ organizationId: number,
+ teamId: number,
+ membershipId: number,
+ data: UpdateOrgTeamMembershipDto
+ ) {
+ const teamMembership = await this.organizationsTeamsMembershipsRepository.updateOrgTeamMembershipById(
+ organizationId,
+ teamId,
+ membershipId,
+ data
+ );
+ return teamMembership;
+ }
+
+ async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
+ const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById(
+ organizationId,
+ teamId,
+ membershipId
+ );
+ return teamMembership;
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts
new file mode 100644
index 00000000000000..13975e7614b40f
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts
@@ -0,0 +1,36 @@
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
+import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
+import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
+import { UserWithProfile } from "@/modules/users/users.repository";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class OrganizationsTeamsService {
+ constructor(
+ private readonly organizationsTeamRepository: OrganizationsTeamsRepository,
+ private readonly membershipsRepository: MembershipsRepository
+ ) {}
+
+ async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
+ const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
+ return teams;
+ }
+
+ async deleteOrgTeam(organizationId: number, teamId: number) {
+ const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId);
+ return team;
+ }
+
+ async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
+ const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
+ return team;
+ }
+
+ async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
+ const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
+ if (user.role !== "ADMIN") {
+ await this.membershipsRepository.createMembership(team.id, user.id, "OWNER");
+ }
+ return team;
+ }
+}
diff --git a/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts
new file mode 100644
index 00000000000000..4c6a964cc05f4d
--- /dev/null
+++ b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts
@@ -0,0 +1,118 @@
+import { EmailService } from "@/modules/email/email.service";
+import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
+import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
+import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
+import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
+import { Injectable, ConflictException } from "@nestjs/common";
+import { plainToInstance } from "class-transformer";
+
+import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.19";
+import { Team } from "@calcom/prisma/client";
+
+@Injectable()
+export class OrganizationsUsersService {
+ constructor(
+ private readonly organizationsUsersRepository: OrganizationsUsersRepository,
+ private readonly emailService: EmailService
+ ) {}
+
+ async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) {
+ const emailArray = !emailInput ? [] : emailInput;
+
+ const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails(
+ orgId,
+ emailArray,
+ skip,
+ take
+ );
+
+ return users;
+ }
+
+ async createUser(org: Team, userCreateBody: CreateOrganizationUserInput, inviterName: string) {
+ // Check if email exists in the system
+ const userEmailCheck = await this.organizationsUsersRepository.getOrganizationUserByEmail(
+ org.id,
+ userCreateBody.email
+ );
+
+ if (userEmailCheck) throw new ConflictException("A user already exists with that email");
+
+ // Check if username is already in use in the org
+ if (userCreateBody.username) {
+ await this.checkForUsernameConflicts(org.id, userCreateBody.username);
+ }
+
+ const usernameOrEmail = userCreateBody.username ? userCreateBody.username : userCreateBody.email;
+
+ // Create new org user
+ const createdUserCall = await createNewUsersConnectToOrgIfExists({
+ invitations: [
+ {
+ usernameOrEmail: usernameOrEmail,
+ role: userCreateBody.organizationRole,
+ },
+ ],
+ teamId: org.id,
+ isOrg: true,
+ parentId: null,
+ autoAcceptEmailDomain: "not-required-for-this-endpoint",
+ orgConnectInfoByUsernameOrEmail: {
+ [usernameOrEmail]: {
+ orgId: org.id,
+ autoAccept: userCreateBody.autoAccept,
+ },
+ },
+ });
+
+ const createdUser = createdUserCall[0];
+
+ // Update user fields that weren't included in createNewUsersConnectToOrgIfExists
+ const updateUserBody = plainToInstance(CreateUserInput, userCreateBody, { strategy: "excludeAll" });
+
+ // Update new user with other userCreateBody params
+ const user = await this.organizationsUsersRepository.updateOrganizationUser(
+ org.id,
+ createdUser.id,
+ updateUserBody
+ );
+
+ // Need to send email to new user to create password
+ await this.emailService.sendSignupToOrganizationEmail({
+ usernameOrEmail,
+ orgName: org.name,
+ orgId: org.id,
+ locale: user?.locale,
+ inviterName,
+ });
+
+ return user;
+ }
+
+ async updateUser(orgId: number, userId: number, userUpdateBody: UpdateOrganizationUserInput) {
+ if (userUpdateBody.username) {
+ await this.checkForUsernameConflicts(orgId, userUpdateBody.username);
+ }
+
+ const user = await this.organizationsUsersRepository.updateOrganizationUser(
+ orgId,
+ userId,
+ userUpdateBody
+ );
+ return user;
+ }
+
+ async deleteUser(orgId: number, userId: number) {
+ const user = await this.organizationsUsersRepository.deleteUser(orgId, userId);
+ return user;
+ }
+
+ async checkForUsernameConflicts(orgId: number, username: string) {
+ const isUsernameTaken = await this.organizationsUsersRepository.getOrganizationUserByUsername(
+ orgId,
+ username
+ );
+
+ if (isUsernameTaken) throw new ConflictException("Username is already taken");
+ }
+}
diff --git a/apps/api/v2/src/modules/proxy/proxy.guard.ts b/apps/api/v2/src/modules/proxy/proxy.guard.ts
new file mode 100644
index 00000000000000..16074913d46d3c
--- /dev/null
+++ b/apps/api/v2/src/modules/proxy/proxy.guard.ts
@@ -0,0 +1,10 @@
+import { Injectable } from "@nestjs/common";
+import { ThrottlerGuard } from "@nestjs/throttler";
+
+@Injectable()
+export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
+ // TODO: adapt if required for CF / AWS / FlightControl proxying.
+ protected async getTracker(req: Record): Promise {
+ return req.ips.length ? req.ips[0] : req.ip;
+ }
+}
diff --git a/apps/api/v2/src/modules/redis/redis.module.ts b/apps/api/v2/src/modules/redis/redis.module.ts
new file mode 100644
index 00000000000000..bbe6472fc50b4d
--- /dev/null
+++ b/apps/api/v2/src/modules/redis/redis.module.ts
@@ -0,0 +1,8 @@
+import { RedisService } from "@/modules/redis/redis.service";
+import { Module } from "@nestjs/common";
+
+@Module({
+ providers: [RedisService],
+ exports: [RedisService],
+})
+export class RedisModule {}
diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts
new file mode 100644
index 00000000000000..ea55ec4638687e
--- /dev/null
+++ b/apps/api/v2/src/modules/redis/redis.service.ts
@@ -0,0 +1,20 @@
+import { AppConfig } from "@/config/type";
+import { Injectable, OnModuleDestroy } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { Redis } from "ioredis";
+
+@Injectable()
+export class RedisService implements OnModuleDestroy {
+ public redis: Redis;
+
+ constructor(readonly configService: ConfigService) {
+ const dbUrl = configService.get("db.redisUrl", { infer: true });
+ if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
+
+ this.redis = new Redis(dbUrl);
+ }
+
+ async onModuleDestroy() {
+ await this.redis.disconnect();
+ }
+}
diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts
index b8fcf941cdafba..f661ef6f8b56f1 100644
--- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts
+++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts
@@ -1,18 +1,18 @@
-import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsService } from "@/modules/slots/services/slots.service";
-import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common";
+import { Query, Body, Controller, Get, Delete, Post, Req, Res } 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 { getAvailableSlots } from "@calcom/platform-libraries-0.0.19";
+import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.19";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@Controller({
- path: "slots",
- version: "2",
+ path: "/v2/slots",
+ version: API_VERSIONS_VALUES,
})
@DocsTags("Slots")
export class SlotsController {
diff --git a/apps/api/v2/src/modules/slots/services/slots.service.ts b/apps/api/v2/src/modules/slots/services/slots.service.ts
index 1d17340ec8df52..4ba2e2c37c5ee3 100644
--- a/apps/api/v2/src/modules/slots/services/slots.service.ts
+++ b/apps/api/v2/src/modules/slots/services/slots.service.ts
@@ -1,4 +1,4 @@
-import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
+import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { v4 as uuid } from "uuid";
@@ -8,7 +8,7 @@ import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
export class SlotsService {
constructor(
- private readonly eventTypeRepo: EventTypesRepository,
+ private readonly eventTypeRepo: EventTypesRepository_2024_04_15,
private readonly slotsRepo: SlotsRepository
) {}
diff --git a/apps/api/v2/src/modules/slots/slots.module.ts b/apps/api/v2/src/modules/slots/slots.module.ts
index 94e7c0d27fe2a9..e85b49af6ada27 100644
--- a/apps/api/v2/src/modules/slots/slots.module.ts
+++ b/apps/api/v2/src/modules/slots/slots.module.ts
@@ -1,4 +1,4 @@
-import { EventTypesModule } from "@/ee/event-types/event-types.module";
+import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/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";
@@ -6,7 +6,7 @@ import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Module } from "@nestjs/common";
@Module({
- imports: [PrismaModule, EventTypesModule],
+ imports: [PrismaModule, EventTypesModule_2024_04_15],
providers: [SlotsRepository, SlotsService],
controllers: [SlotsController],
exports: [SlotsService],
diff --git a/apps/api/v2/src/modules/slots/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts
index 8ef589f9f87515..9b96fb303f7187 100644
--- a/apps/api/v2/src/modules/slots/slots.repository.ts
+++ b/apps/api/v2/src/modules/slots/slots.repository.ts
@@ -3,7 +3,7 @@ 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 { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.19";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts
new file mode 100644
index 00000000000000..2452e10b6e1f47
--- /dev/null
+++ b/apps/api/v2/src/modules/stripe/stripe.module.ts
@@ -0,0 +1,10 @@
+import { StripeService } from "@/modules/stripe/stripe.service";
+import { Module } from "@nestjs/common";
+import { ConfigModule } from "@nestjs/config";
+
+@Module({
+ imports: [ConfigModule],
+ exports: [StripeService],
+ providers: [StripeService],
+})
+export class StripeModule {}
diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts
new file mode 100644
index 00000000000000..b93f9f5e9a8beb
--- /dev/null
+++ b/apps/api/v2/src/modules/stripe/stripe.service.ts
@@ -0,0 +1,15 @@
+import { AppConfig } from "@/config/type";
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import Stripe from "stripe";
+
+@Injectable()
+export class StripeService {
+ public stripe: Stripe;
+
+ constructor(configService: ConfigService) {
+ this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
+ apiVersion: "2020-08-27",
+ });
+ }
+}
diff --git a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts
index 4882e154f390e3..e3661f7605a9c1 100644
--- a/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts
+++ b/apps/api/v2/src/modules/timezones/controllers/timezones.controller.ts
@@ -1,14 +1,15 @@
+import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 type { CityTimezones } from "@calcom/platform-libraries-0.0.19";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
- path: "timezones",
- version: "2",
+ path: "/v2/timezones",
+ version: API_VERSIONS_VALUES,
})
@DocsTags("Timezones")
export class TimezonesController {
diff --git a/apps/api/v2/src/modules/timezones/services/timezones.service.ts b/apps/api/v2/src/modules/timezones/services/timezones.service.ts
index b13c00f6bf4c5a..9abd98c8a63f03 100644
--- a/apps/api/v2/src/modules/timezones/services/timezones.service.ts
+++ b/apps/api/v2/src/modules/timezones/services/timezones.service.ts
@@ -1,10 +1,24 @@
+import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
-import { cityTimezonesHandler } from "@calcom/platform-libraries";
+import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.19";
+import type { CityTimezones } from "@calcom/platform-libraries-0.0.19";
@Injectable()
export class TimezonesService {
- async getCityTimeZones() {
- return cityTimezonesHandler();
+ private cacheKey = "cityTimezones";
+
+ constructor(private readonly redisService: RedisService) {}
+
+ async getCityTimeZones(): Promise {
+ const cachedTimezones = await this.redisService.redis.get(this.cacheKey);
+ if (!cachedTimezones) {
+ const timezones = await cityTimezonesHandler();
+ await this.redisService.redis.set(this.cacheKey, JSON.stringify(timezones), "EX", 60 * 60 * 24);
+
+ return timezones;
+ } else {
+ return JSON.parse(cachedTimezones) as CityTimezones;
+ }
}
}
diff --git a/apps/api/v2/src/modules/timezones/timezones.module.ts b/apps/api/v2/src/modules/timezones/timezones.module.ts
index 4447da80c1481f..e5089ac2252c0f 100644
--- a/apps/api/v2/src/modules/timezones/timezones.module.ts
+++ b/apps/api/v2/src/modules/timezones/timezones.module.ts
@@ -1,9 +1,10 @@
+import { RedisModule } from "@/modules/redis/redis.module";
import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Module } from "@nestjs/common";
@Module({
- imports: [],
+ imports: [RedisModule],
providers: [TimezonesService],
controllers: [TimezonesController],
exports: [TimezonesService],
diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts
index 46b90a2ddc6370..448b5fde64b3ec 100644
--- a/apps/api/v2/src/modules/tokens/tokens.repository.ts
+++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts
@@ -65,6 +65,7 @@ export class TokensRepository {
// 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([
@@ -88,6 +89,7 @@ export class TokensRepository {
return {
accessToken: accessToken.secret,
+ accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
};
}
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
index 7e66b9a749d46b..fdfa6d25f3ca14 100644
--- 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
@@ -1,8 +1,10 @@
-import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
-import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
+import { Locales } from "@/lib/enums/locales";
+import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty } from "@nestjs/swagger";
-import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator";
+import { IsOptional, IsTimeZone, IsString, IsEnum } from "class-validator";
+export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
+export type TimeFormat = 12 | 24;
export class CreateManagedUserInput {
@IsString()
@ApiProperty({ example: "alice@example.com" })
@@ -12,20 +14,26 @@ export class CreateManagedUserInput {
@IsOptional()
name?: string;
- @IsNumber()
@IsOptional()
- @Validate(IsTimeFormat)
- @ApiProperty({ example: 12 })
- timeFormat?: number;
+ @ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
+ timeFormat?: TimeFormat;
- @IsString()
@IsOptional()
- @Validate(IsWeekStart)
- @ApiProperty({ example: "Sunday" })
- weekStart?: string;
+ @IsString()
+ @ApiProperty({
+ example: "Monday",
+ enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
+ })
+ weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
+ @CapitalizeTimeZone()
@ApiProperty({ example: "America/New_York" })
timeZone?: string;
+
+ @IsEnum(Locales)
+ @IsOptional()
+ @ApiProperty({ example: Locales.EN, enum: Locales })
+ locale?: Locales;
}
diff --git a/apps/api/v2/src/modules/users/inputs/create-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-user.input.ts
new file mode 100644
index 00000000000000..03c7a27054fa2d
--- /dev/null
+++ b/apps/api/v2/src/modules/users/inputs/create-user.input.ts
@@ -0,0 +1,131 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Expose, Transform } from "class-transformer";
+import {
+ IsBoolean,
+ IsEmail,
+ IsHexColor,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Validate,
+ Min,
+} from "class-validator";
+
+import { AvatarValidator } from "../validators/avatarValidator";
+import { LocaleValidator } from "../validators/localeValidator";
+import { ThemeValidator } from "../validators/themeValidator";
+import { TimeFormatValidator } from "../validators/timeFormatValidator";
+import { TimeZoneValidator } from "../validators/timeZoneValidator";
+import { WeekdayValidator } from "../validators/weekdayValidator";
+
+export class CreateUserInput {
+ @ApiProperty({ type: String, description: "User email address", example: "user@example.com" })
+ @IsEmail()
+ @Transform(({ value }) => {
+ if (typeof value === "string") {
+ return value.toLowerCase();
+ }
+ })
+ @Expose()
+ email!: string;
+
+ @ApiProperty({ type: String, required: false, description: "Username", example: "user123" })
+ @IsOptional()
+ @IsString()
+ @Transform(({ value }) => {
+ if (typeof value === "string") {
+ return value.toLowerCase();
+ }
+ })
+ @Expose()
+ username?: string;
+
+ @ApiProperty({ type: String, required: false, description: "Preferred weekday", example: "Monday" })
+ @IsOptional()
+ @IsString()
+ @Validate(WeekdayValidator)
+ @Expose()
+ weekday?: string;
+
+ @ApiProperty({
+ type: String,
+ required: false,
+ description: "Brand color in HEX format",
+ example: "#FFFFFF",
+ })
+ @IsOptional()
+ @IsHexColor()
+ @Expose()
+ brandColor?: string;
+
+ @ApiProperty({
+ type: String,
+ required: false,
+ description: "Dark brand color in HEX format",
+ example: "#000000",
+ })
+ @IsOptional()
+ @IsHexColor()
+ @Expose()
+ darkBrandColor?: string;
+
+ @ApiProperty({ type: Boolean, required: false, description: "Hide branding", example: false })
+ @IsOptional()
+ @IsBoolean()
+ @Expose()
+ hideBranding?: boolean;
+
+ @ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
+ @IsOptional()
+ @IsString()
+ @Validate(TimeZoneValidator)
+ @Expose()
+ timeZone?: string;
+
+ @ApiProperty({ type: String, required: false, description: "Theme", example: "dark" })
+ @IsOptional()
+ @IsString()
+ @Validate(ThemeValidator)
+ @Expose()
+ theme?: string | null;
+
+ @ApiProperty({ type: String, required: false, description: "Application theme", example: "light" })
+ @IsOptional()
+ @IsString()
+ @Validate(ThemeValidator)
+ @Expose()
+ appTheme?: string | null;
+
+ @ApiProperty({ type: Number, required: false, description: "Time format", example: 24 })
+ @IsOptional()
+ @IsNumber()
+ @Validate(TimeFormatValidator)
+ @Expose()
+ timeFormat?: number;
+
+ @ApiProperty({ type: Number, required: false, description: "Default schedule ID", example: 1, minimum: 0 })
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ @Expose()
+ defaultScheduleId?: number;
+
+ @ApiProperty({ type: String, required: false, description: "Locale", example: "en", default: "en" })
+ @IsOptional()
+ @IsString()
+ @Validate(LocaleValidator)
+ @Expose()
+ locale?: string | null = "en";
+
+ @ApiProperty({
+ type: String,
+ required: false,
+ description: "Avatar URL",
+ example: "https://example.com/avatar.jpg",
+ })
+ @IsOptional()
+ @IsString()
+ @Validate(AvatarValidator)
+ @Expose()
+ avatarUrl?: string;
+}
diff --git a/apps/api/v2/src/modules/users/inputs/get-users.input.ts b/apps/api/v2/src/modules/users/inputs/get-users.input.ts
new file mode 100644
index 00000000000000..dae816f9448245
--- /dev/null
+++ b/apps/api/v2/src/modules/users/inputs/get-users.input.ts
@@ -0,0 +1,19 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Transform } from "class-transformer";
+import { IsOptional, Validate } from "class-validator";
+
+import { SkipTakePagination } from "@calcom/platform-types";
+
+import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray";
+
+export class GetUsersInput extends SkipTakePagination {
+ @IsOptional()
+ @Validate(IsEmailStringOrArray)
+ @Transform(({ value }: { value: string | string[] }) => {
+ return typeof value === "string" ? [value] : value;
+ })
+ @ApiProperty({
+ description: "The email address or an array of email addresses to filter by",
+ })
+ emails?: 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
index 4f3df48960a731..c1b7a10b602e97 100644
--- 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
@@ -1,6 +1,8 @@
-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";
+import { Locales } from "@/lib/enums/locales";
+import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
+import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@@ -11,21 +13,31 @@ export class UpdateManagedUserInput {
@IsOptional()
name?: string;
- @IsNumber()
@IsOptional()
- @Validate(IsTimeFormat)
- timeFormat?: number;
+ @IsIn(["12", "24"])
+ @ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
+ timeFormat?: TimeFormat;
@IsNumber()
@IsOptional()
defaultScheduleId?: number;
- @IsString()
@IsOptional()
- @Validate(IsWeekStart)
- weekStart?: string;
+ @IsString()
+ @IsIn(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"])
+ @ApiProperty({
+ example: "Monday",
+ enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
+ })
+ weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
+ @CapitalizeTimeZone()
timeZone?: string;
+
+ @IsEnum(Locales)
+ @IsOptional()
+ @ApiProperty({ example: Locales.EN, enum: Locales })
+ locale?: Locales;
}
diff --git a/apps/api/v2/src/modules/users/inputs/update-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-user.input.ts
new file mode 100644
index 00000000000000..501bd7e1d453d5
--- /dev/null
+++ b/apps/api/v2/src/modules/users/inputs/update-user.input.ts
@@ -0,0 +1,4 @@
+import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
+import { PartialType } from "@nestjs/mapped-types";
+
+export class UpdateUserInput extends PartialType(CreateUserInput) {}
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
deleted file mode 100644
index 2271a7e4f74420..00000000000000
--- a/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index d21cf1271910ec..00000000000000
--- a/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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/outputs/get-users.output.ts b/apps/api/v2/src/modules/users/outputs/get-users.output.ts
new file mode 100644
index 00000000000000..fdec53748b95a0
--- /dev/null
+++ b/apps/api/v2/src/modules/users/outputs/get-users.output.ts
@@ -0,0 +1,239 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { Expose } from "class-transformer";
+import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
+
+export class GetUserOutput {
+ @IsInt()
+ @Expose()
+ @ApiProperty({ type: Number, required: true, description: "The ID of the user", example: 1 })
+ id!: number;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The username of the user",
+ example: "john_doe",
+ })
+ username!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The name of the user",
+ example: "John Doe",
+ })
+ name!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: "The email of the user",
+ example: "john@example.com",
+ })
+ email!: string;
+
+ @IsDateString()
+ @Expose()
+ @ApiProperty({
+ type: Date,
+ nullable: true,
+ required: false,
+ description: "The date when the email was verified",
+ example: "2022-01-01T00:00:00Z",
+ })
+ emailVerified!: Date | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The bio of the user",
+ example: "I am a software developer",
+ })
+ bio!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The URL of the user's avatar",
+ example: "https://example.com/avatar.jpg",
+ })
+ avatarUrl!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: "The time zone of the user",
+ example: "America/New_York",
+ })
+ timeZone!: string;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: "The week start day of the user",
+ example: "Monday",
+ })
+ weekStart!: string;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The app theme of the user",
+ example: "light",
+ })
+ appTheme!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The theme of the user",
+ example: "default",
+ })
+ theme!: string | null;
+
+ @IsInt()
+ @Expose()
+ @ApiProperty({
+ type: Number,
+ nullable: true,
+ required: false,
+ description: "The ID of the default schedule for the user",
+ example: 1,
+ })
+ defaultScheduleId!: number | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The locale of the user",
+ example: "en-US",
+ })
+ locale!: string | null;
+
+ @IsInt()
+ @Expose()
+ @ApiProperty({
+ type: Number,
+ nullable: true,
+ required: false,
+ description: "The time format of the user",
+ example: 12,
+ })
+ timeFormat!: number | null;
+
+ @IsBoolean()
+ @Expose()
+ @ApiProperty({
+ type: Boolean,
+ required: true,
+ description: "Whether to hide branding for the user",
+ example: false,
+ })
+ hideBranding!: boolean;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The brand color of the user",
+ example: "#ffffff",
+ })
+ brandColor!: string | null;
+
+ @IsString()
+ @Expose()
+ @ApiProperty({
+ type: String,
+ nullable: true,
+ required: false,
+ description: "The dark brand color of the user",
+ example: "#000000",
+ })
+ darkBrandColor!: string | null;
+
+ @IsBoolean()
+ @Expose()
+ @ApiProperty({
+ type: Boolean,
+ nullable: true,
+ required: false,
+ description: "Whether dynamic booking is allowed for the user",
+ example: true,
+ })
+ allowDynamicBooking!: boolean | null;
+
+ @IsDateString()
+ @Expose()
+ @ApiProperty({
+ type: Date,
+ required: true,
+ description: "The date when the user was created",
+ example: "2022-01-01T00:00:00Z",
+ })
+ createdDate!: Date;
+
+ @IsBoolean()
+ @Expose()
+ @ApiProperty({
+ type: Boolean,
+ nullable: true,
+ required: false,
+ description: "Whether the user is verified",
+ example: true,
+ })
+ verified!: boolean | null;
+
+ @IsInt()
+ @Expose()
+ @ApiProperty({
+ type: Number,
+ nullable: true,
+ required: false,
+ description: "The ID of the user who invited this user",
+ example: 1,
+ })
+ invitedTo!: number | null;
+}
+
+export class GetUsersOutput {
+ @ValidateNested()
+ @Type(() => GetUserOutput)
+ @IsArray()
+ @ApiProperty({
+ type: [GetUserOutput],
+ required: true,
+ description: "The list of users",
+ example: [{ id: 1, username: "john_doe", name: "John Doe", email: "john@example.com" }],
+ })
+ users!: GetUserOutput[];
+}
diff --git a/apps/api/v2/src/modules/users/services/users.service.ts b/apps/api/v2/src/modules/users/services/users.service.ts
new file mode 100644
index 00000000000000..c8fbb44c6b6b20
--- /dev/null
+++ b/apps/api/v2/src/modules/users/services/users.service.ts
@@ -0,0 +1,24 @@
+import { UsersRepository } from "@/modules/users/users.repository";
+import { Injectable } from "@nestjs/common";
+
+import { User } from "@calcom/prisma/client";
+
+@Injectable()
+export class UsersService {
+ constructor(private readonly usersRepository: UsersRepository) {}
+
+ async getByUsernames(usernames: string[]) {
+ const users = await Promise.all(
+ usernames.map((username) => this.usersRepository.findByUsername(username))
+ );
+ const usersFiltered: User[] = [];
+
+ for (const user of users) {
+ if (user) {
+ usersFiltered.push(user);
+ }
+ }
+
+ return users;
+ }
+}
diff --git a/apps/api/v2/src/modules/users/users.module.ts b/apps/api/v2/src/modules/users/users.module.ts
index 932f4e0ea9c606..a7eed6c99de5cc 100644
--- a/apps/api/v2/src/modules/users/users.module.ts
+++ b/apps/api/v2/src/modules/users/users.module.ts
@@ -1,10 +1,13 @@
+import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { TokensModule } from "@/modules/tokens/tokens.module";
+import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
- imports: [PrismaModule],
- providers: [UsersRepository],
- exports: [UsersRepository],
+ imports: [PrismaModule, EventTypesModule_2024_06_14, TokensModule],
+ providers: [UsersRepository, UsersService],
+ exports: [UsersRepository, UsersService],
})
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
index e0239ed07b9689..a60c648de45986 100644
--- a/apps/api/v2/src/modules/users/users.repository.ts
+++ b/apps/api/v2/src/modules/users/users.repository.ts
@@ -77,6 +77,19 @@ export class UsersRepository {
});
}
+ async findByIdsWithEventTypes(userIds: number[]) {
+ return this.dbRead.prisma.user.findMany({
+ where: {
+ id: {
+ in: userIds,
+ },
+ },
+ include: {
+ eventTypes: true,
+ },
+ });
+ }
+
async findByIdWithCalendars(userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
@@ -157,11 +170,7 @@ export class UsersRepository {
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
if (userInput.weekStart) {
- userInput.weekStart = capitalize(userInput.weekStart);
- }
-
- if (userInput.timeZone) {
- userInput.timeZone = capitalizeTimezone(userInput.timeZone);
+ userInput.weekStart = userInput.weekStart;
}
}
@@ -173,18 +182,24 @@ export class UsersRepository {
},
});
}
-}
-function capitalizeTimezone(timezone: string) {
- const segments = timezone.split("/");
+ async getUserScheduleDefaultId(userId: number) {
+ const user = await this.findById(userId);
- const capitalizedSegments = segments.map((segment) => {
- return capitalize(segment);
- });
+ if (!user?.defaultScheduleId) return null;
- return capitalizedSegments.join("/");
-}
+ return user?.defaultScheduleId;
+ }
-function capitalize(str: string) {
- return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
+ async getOrganizationUsers(organizationId: number) {
+ const profiles = await this.dbRead.prisma.profile.findMany({
+ where: {
+ organizationId,
+ },
+ include: {
+ user: true,
+ },
+ });
+ return profiles.map((profile) => profile.user);
+ }
}
diff --git a/apps/api/v2/src/modules/users/validators/avatarValidator.ts b/apps/api/v2/src/modules/users/validators/avatarValidator.ts
new file mode 100644
index 00000000000000..d6b01e91671ca8
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/avatarValidator.ts
@@ -0,0 +1,11 @@
+import { ValidatorConstraint } from "class-validator";
+import type { ValidatorConstraintInterface } from "class-validator";
+
+@ValidatorConstraint({ name: "avatarValidator", async: false })
+export class AvatarValidator implements ValidatorConstraintInterface {
+ validate(avatarString: string) {
+ // Checks if avatar string is a valid base 64 image
+ const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
+ return regex.test(avatarString);
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts
new file mode 100644
index 00000000000000..b4156be4b642ef
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts
@@ -0,0 +1,24 @@
+import { ValidatorConstraint } from "class-validator";
+import type { ValidatorConstraintInterface } from "class-validator";
+
+@ValidatorConstraint({ name: "IsEmailStringOrArray", async: false })
+export class IsEmailStringOrArray implements ValidatorConstraintInterface {
+ validate(value: any): boolean {
+ if (typeof value === "string") {
+ return this.validateEmail(value);
+ } else if (Array.isArray(value)) {
+ return value.every((item) => this.validateEmail(item));
+ }
+ return false;
+ }
+
+ validateEmail(email: string): boolean {
+ const regex =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ return regex.test(email);
+ }
+
+ defaultMessage() {
+ return "Please submit only valid email addresses";
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/localeValidator.ts b/apps/api/v2/src/modules/users/validators/localeValidator.ts
new file mode 100644
index 00000000000000..18c25d0eab23d7
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/localeValidator.ts
@@ -0,0 +1,39 @@
+import type { ValidatorConstraintInterface } from "class-validator";
+import { ValidatorConstraint } from "class-validator";
+
+@ValidatorConstraint({ name: "localeValidator", async: false })
+export class LocaleValidator implements ValidatorConstraintInterface {
+ validate(locale: string) {
+ const localeValues = [
+ "en",
+ "fr",
+ "it",
+ "ru",
+ "es",
+ "de",
+ "pt",
+ "ro",
+ "nl",
+ "pt-BR",
+ "ko",
+ "ja",
+ "pl",
+ "ar",
+ "iw",
+ "zh-CN",
+ "zh-TW",
+ "cs",
+ "sr",
+ "sv",
+ "vi",
+ ];
+
+ if (localeValues.includes(locale)) return true;
+
+ return false;
+ }
+
+ defaultMessage() {
+ return "Please include a valid locale";
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/themeValidator.ts b/apps/api/v2/src/modules/users/validators/themeValidator.ts
new file mode 100644
index 00000000000000..7b6ce944b80733
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/themeValidator.ts
@@ -0,0 +1,17 @@
+import type { ValidatorConstraintInterface } from "class-validator";
+import { ValidatorConstraint } from "class-validator";
+
+@ValidatorConstraint({ name: "themeValidator", async: false })
+export class ThemeValidator implements ValidatorConstraintInterface {
+ validate(theme: string) {
+ const themeValues = ["dark", "light"];
+
+ if (themeValues.includes(theme)) return true;
+
+ return false;
+ }
+
+ defaultMessage() {
+ return "Please include either 'dark' or 'light";
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts
new file mode 100644
index 00000000000000..c5b82ef111d253
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts
@@ -0,0 +1,17 @@
+import type { ValidatorConstraintInterface } from "class-validator";
+import { ValidatorConstraint } from "class-validator";
+
+@ValidatorConstraint({ name: "timeFormatValidator", async: false })
+export class TimeFormatValidator implements ValidatorConstraintInterface {
+ validate(timeFormat: number) {
+ const timeFormatValues = [12, 24];
+
+ if (timeFormatValues.includes(timeFormat)) return true;
+
+ return false;
+ }
+
+ defaultMessage() {
+ return "Please include either 12 or 24";
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts
new file mode 100644
index 00000000000000..448d79f0d0aa3c
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts
@@ -0,0 +1,18 @@
+import type { ValidatorConstraintInterface } from "class-validator";
+import { ValidatorConstraint } from "class-validator";
+import tzdata from "tzdata";
+
+@ValidatorConstraint({ name: "timezoneValidator", async: false })
+export class TimeZoneValidator implements ValidatorConstraintInterface {
+ validate(timeZone: string) {
+ const timeZoneList = Object.keys(tzdata.zones);
+
+ if (timeZoneList.includes(timeZone)) return true;
+
+ return false;
+ }
+
+ defaultMessage() {
+ return "Please include a valid time zone";
+ }
+}
diff --git a/apps/api/v2/src/modules/users/validators/weekdayValidator.ts b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts
new file mode 100644
index 00000000000000..8a53a649e7b202
--- /dev/null
+++ b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts
@@ -0,0 +1,16 @@
+import type { ValidatorConstraintInterface } from "class-validator";
+import { ValidatorConstraint } from "class-validator";
+
+@ValidatorConstraint({ name: "weekdayValidator", async: false })
+export class WeekdayValidator implements ValidatorConstraintInterface {
+ validate(weekday: string) {
+ const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+ if (weekdays.includes(weekday)) return true;
+ return false;
+ }
+
+ defaultMessage() {
+ return "Please include a valid weekday";
+ }
+}
diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json
index b822b36de23a41..ad8e6453f69ee6 100644
--- a/apps/api/v2/swagger/documentation.json
+++ b/apps/api/v2/swagger/documentation.json
@@ -33,6 +33,16 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
}
],
"responses": {
@@ -404,6 +414,48 @@
]
}
},
+ "/v2/oauth-clients/{clientId}/managed-users": {
+ "get": {
+ "operationId": "OAuthClientsController_getOAuthClientManagedUsersById",
+ "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"
+ }
+ },
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetManagedUsersOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "OAuth - development only"
+ ]
+ }
+ },
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
@@ -545,14 +597,14 @@
},
"/v2/event-types": {
"post": {
- "operationId": "EventTypesController_createEventType",
+ "operationId": "EventTypesController_2024_04_15_createEventType",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateEventTypeInput"
+ "$ref": "#/components/schemas/CreateEventTypeInput_2024_04_15"
}
}
}
@@ -574,7 +626,7 @@
]
},
"get": {
- "operationId": "EventTypesController_getEventTypes",
+ "operationId": "EventTypesController_2024_04_15_getEventTypes",
"parameters": [],
"responses": {
"200": {
@@ -595,14 +647,14 @@
},
"/v2/event-types/{eventTypeId}": {
"get": {
- "operationId": "EventTypesController_getEventType",
+ "operationId": "EventTypesController_2024_04_15_getEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
@@ -623,7 +675,7 @@
]
},
"patch": {
- "operationId": "EventTypesController_updateEventType",
+ "operationId": "EventTypesController_2024_04_15_updateEventType",
"parameters": [
{
"name": "eventTypeId",
@@ -639,7 +691,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateEventTypeInput"
+ "$ref": "#/components/schemas/UpdateEventTypeInput_2024_04_15"
}
}
}
@@ -661,7 +713,7 @@
]
},
"delete": {
- "operationId": "EventTypesController_deleteEventType",
+ "operationId": "EventTypesController_2024_04_15_deleteEventType",
"parameters": [
{
"name": "eventTypeId",
@@ -691,7 +743,7 @@
},
"/v2/event-types/{username}/{eventSlug}/public": {
"get": {
- "operationId": "EventTypesController_getPublicEventType",
+ "operationId": "EventTypesController_2024_04_15_getPublicEventType",
"parameters": [
{
"name": "username",
@@ -722,6 +774,7 @@
"required": false,
"in": "query",
"schema": {
+ "nullable": true,
"type": "string"
}
}
@@ -745,7 +798,7 @@
},
"/v2/event-types/{username}/public": {
"get": {
- "operationId": "EventTypesController_getPublicEventTypes",
+ "operationId": "EventTypesController_2024_04_15_getPublicEventTypes",
"parameters": [
{
"name": "username",
@@ -773,16 +826,37 @@
]
}
},
- "/v2/gcal/oauth/auth-url": {
+ "/v2/organizations/{orgId}/teams": {
"get": {
- "operationId": "GcalController_redirect",
+ "operationId": "OrganizationsTeamsController_getAllTeams",
+ "summary": "Get all the teams of an organization.",
"parameters": [
{
- "name": "Authorization",
+ "name": "orgId",
"required": true,
- "in": "header",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
+ }
+ },
+ {
+ "name": "take",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "skip",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
+ "schema": {
+ "type": "number"
}
}
],
@@ -792,58 +866,60 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GcalAuthUrlOutput"
+ "$ref": "#/components/schemas/OrgTeamsOutputResponseDto"
}
}
}
}
},
"tags": [
- "Google Calendar"
+ "Organizations Teams"
]
- }
- },
- "/v2/gcal/oauth/save": {
- "get": {
- "operationId": "GcalController_save",
+ },
+ "post": {
+ "operationId": "OrganizationsTeamsController_createTeam",
+ "summary": "Create a team for an organization.",
"parameters": [
{
- "name": "state",
- "required": true,
- "in": "query",
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "code",
+ "name": "orgId",
"required": true,
- "in": "query",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateOrgTeamDto"
+ }
+ }
+ }
+ },
"responses": {
- "200": {
+ "201": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GcalSaveRedirectOutput"
+ "$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
}
},
"tags": [
- "Google Calendar"
+ "Organizations Teams"
]
}
},
- "/v2/gcal/check": {
+ "/v2/organizations/{orgId}/teams/{teamId}": {
"get": {
- "operationId": "GcalController_check",
+ "operationId": "OrganizationsTeamsController_getTeam",
+ "summary": "Get a team of the organization by ID.",
"parameters": [],
"responses": {
"200": {
@@ -851,27 +927,34 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GcalCheckOutput"
+ "$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
}
},
"tags": [
- "Google Calendar"
+ "Organizations Teams"
]
- }
- },
- "/v2/provider/{clientId}": {
- "get": {
- "operationId": "CalProviderController_verifyClientId",
+ },
+ "delete": {
+ "operationId": "OrganizationsTeamsController_deleteTeam",
+ "summary": "Delete a team of the organization by ID.",
"parameters": [
{
- "name": "clientId",
+ "name": "orgId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
}
}
],
@@ -881,27 +964,94 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ProviderVerifyClientOutput"
+ "$ref": "#/components/schemas/OrgTeamOutputResponseDto"
}
}
}
}
},
"tags": [
- "Cal provider"
+ "Organizations Teams"
+ ]
+ },
+ "patch": {
+ "operationId": "OrganizationsTeamsController_updateTeam",
+ "summary": "Update a team of the organization by ID.",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateOrgTeamDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrgTeamOutputResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Teams"
]
}
},
- "/v2/provider/{clientId}/access-token": {
+ "/v2/organizations/{orgId}/schedules": {
"get": {
- "operationId": "CalProviderController_verifyAccessToken",
+ "operationId": "OrganizationsSchedulesController_getOrganizationSchedules",
"parameters": [
{
- "name": "clientId",
+ "name": "orgId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
+ }
+ },
+ {
+ "name": "take",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "skip",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
+ "schema": {
+ "type": "number"
}
}
],
@@ -911,27 +1061,36 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ProviderVerifyAccessTokenOutput"
+ "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Cal provider"
+ "Organizations Schedules"
]
}
},
- "/v2/schedules": {
+ "/v2/organizations/{orgId}/users/{userId}/schedules": {
"post": {
- "operationId": "SchedulesController_createSchedule",
- "parameters": [],
+ "operationId": "OrganizationsSchedulesController_createUserSchedule",
+ "parameters": [
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateScheduleInput"
+ "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
}
}
}
@@ -942,61 +1101,57 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateScheduleOutput"
+ "$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Schedules"
+ "Organizations Schedules"
]
},
"get": {
- "operationId": "SchedulesController_getSchedules",
- "parameters": [],
- "responses": {
- "200": {
- "description": "",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/GetSchedulesOutput"
- }
- }
+ "operationId": "OrganizationsSchedulesController_getUserSchedules",
+ "parameters": [
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
}
}
- },
- "tags": [
- "Schedules"
- ]
- }
- },
- "/v2/schedules/default": {
- "get": {
- "operationId": "SchedulesController_getDefaultSchedule",
- "parameters": [],
+ ],
"responses": {
"200": {
- "description": "Returns the default schedule",
+ "description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetDefaultScheduleOutput"
+ "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Schedules"
+ "Organizations Schedules"
]
}
},
- "/v2/schedules/{scheduleId}": {
+ "/v2/organizations/{orgId}/users/{userId}/schedules/{scheduleId}": {
"get": {
- "operationId": "SchedulesController_getSchedule",
+ "operationId": "OrganizationsSchedulesController_getUserSchedule",
"parameters": [
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
{
"name": "scheduleId",
"required": true,
@@ -1012,25 +1167,33 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetScheduleOutput"
+ "$ref": "#/components/schemas/GetScheduleOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Schedules"
+ "Organizations Schedules"
]
},
"patch": {
- "operationId": "SchedulesController_updateSchedule",
+ "operationId": "OrganizationsSchedulesController_updateUserSchedule",
"parameters": [
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
{
"name": "scheduleId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
@@ -1039,7 +1202,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateScheduleInput"
+ "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
}
}
}
@@ -1050,170 +1213,267 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateScheduleOutput"
+ "$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Schedules"
+ "Organizations Schedules"
]
},
"delete": {
- "operationId": "SchedulesController_deleteSchedule",
+ "operationId": "OrganizationsSchedulesController_deleteUserSchedule",
"parameters": [
{
- "name": "scheduleId",
+ "name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
- }
- ],
- "responses": {
+ },
+ {
+ "name": "scheduleId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/DeleteScheduleOutput"
+ "$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11"
}
}
}
}
},
"tags": [
- "Schedules"
+ "Organizations Schedules"
]
}
},
- "/v2/me": {
+ "/v2/organizations/{orgId}/users": {
"get": {
- "operationId": "MeController_getMe",
- "parameters": [],
+ "operationId": "OrganizationsUsersController_getOrganizationsUsers",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "take",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "skip",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "emails",
+ "required": false,
+ "in": "query",
+ "description": "The email address or an array of email addresses to filter by",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetMeOutput"
+ "$ref": "#/components/schemas/GetOrganizationUsersOutput"
}
}
}
}
},
"tags": [
- "Me"
+ "Organizations Users"
]
},
- "patch": {
- "operationId": "MeController_updateMe",
- "parameters": [],
+ "post": {
+ "operationId": "OrganizationsUsersController_createOrganizationUser",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateManagedUserInput"
+ "$ref": "#/components/schemas/CreateOrganizationUserInput"
}
}
}
},
"responses": {
- "200": {
+ "201": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateMeOutput"
+ "$ref": "#/components/schemas/GetOrganizationUserOutput"
}
}
}
}
},
"tags": [
- "Me"
+ "Organizations Users"
]
}
},
- "/v2/calendars/busy-times": {
- "get": {
- "operationId": "CalendarsController_getBusyTimes",
- "parameters": [],
+ "/v2/organizations/{orgId}/users/{userId}": {
+ "patch": {
+ "operationId": "OrganizationsUsersController_updateOrganizationUser",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateOrganizationUserInput"
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetBusyTimesOutput"
+ "$ref": "#/components/schemas/GetOrganizationUserOutput"
}
}
}
}
},
"tags": [
- "Calendars"
+ "Organizations Users"
]
- }
- },
- "/v2/calendars": {
- "get": {
- "operationId": "CalendarsController_getCalendars",
- "parameters": [],
+ },
+ "delete": {
+ "operationId": "OrganizationsUsersController_deleteOrganizationUser",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "userId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ConnectedCalendarsOutput"
+ "$ref": "#/components/schemas/GetOrganizationUserOutput"
}
}
}
}
},
"tags": [
- "Calendars"
+ "Organizations Users"
]
}
},
- "/v2/bookings": {
+ "/v2/organizations/{orgId}/memberships": {
"get": {
- "operationId": "BookingsController_getBookings",
+ "operationId": "OrganizationsMembershipsController_getAllMemberships",
"parameters": [
{
- "name": "cursor",
- "required": false,
- "in": "query",
+ "name": "orgId",
+ "required": true,
+ "in": "path",
"schema": {
"type": "number"
}
},
{
- "name": "limit",
+ "name": "take",
"required": false,
"in": "query",
+ "description": "The number of items to return",
+ "example": 10,
"schema": {
"type": "number"
}
},
{
- "name": "filters[status]",
- "required": true,
+ "name": "skip",
+ "required": false,
"in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
"schema": {
- "enum": [
- "upcoming",
- "recurring",
- "past",
- "cancelled",
- "unconfirmed"
- ],
- "type": "string"
+ "type": "number"
}
}
],
@@ -1223,25 +1483,25 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetBookingsOutput"
+ "$ref": "#/components/schemas/GetAllOrgMemberships"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Memberships"
]
},
"post": {
- "operationId": "BookingsController_createBooking",
+ "operationId": "OrganizationsMembershipsController_createMembership",
"parameters": [
{
- "name": "x-cal-client-id",
+ "name": "orgId",
"required": true,
- "in": "header",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
@@ -1250,7 +1510,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateBookingInput"
+ "$ref": "#/components/schemas/CreateOrgMembershipDto"
}
}
}
@@ -1261,57 +1521,54 @@
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/CreateOrgMembershipOutput"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Memberships"
]
}
},
- "/v2/bookings/{bookingUid}": {
+ "/v2/organizations/{orgId}/memberships/{membershipId}": {
"get": {
- "operationId": "BookingsController_getBooking",
- "parameters": [
- {
- "name": "bookingUid",
- "required": true,
- "in": "path",
- "schema": {
- "type": "string"
- }
- }
- ],
+ "operationId": "OrganizationsMembershipsController_getUserSchedule",
+ "parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GetBookingOutput"
+ "$ref": "#/components/schemas/GetOrgMembership"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Memberships"
]
- }
- },
- "/v2/bookings/{bookingUid}/reschedule": {
- "get": {
- "operationId": "BookingsController_getBookingForReschedule",
+ },
+ "delete": {
+ "operationId": "OrganizationsMembershipsController_deleteMembership",
"parameters": [
{
- "name": "bookingUid",
+ "name": "orgId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
+ }
+ },
+ {
+ "name": "membershipId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
}
}
],
@@ -1321,35 +1578,33 @@
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/DeleteOrgMembership"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Memberships"
]
- }
- },
- "/v2/bookings/{bookingId}/cancel": {
- "post": {
- "operationId": "BookingsController_cancelBooking",
+ },
+ "patch": {
+ "operationId": "OrganizationsMembershipsController_updateMembership",
"parameters": [
{
- "name": "bookingId",
+ "name": "orgId",
"required": true,
"in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
},
{
- "name": "x-cal-client-id",
+ "name": "membershipId",
"required": true,
- "in": "header",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
@@ -1358,38 +1613,46 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CancelBookingInput"
+ "$ref": "#/components/schemas/UpdateOrgMembershipDto"
}
}
}
},
"responses": {
- "201": {
+ "200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/UpdateOrgMembership"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Memberships"
]
}
},
- "/v2/bookings/recurring": {
+ "/v2/organizations/{orgId}/teams/{teamId}/event-types": {
"post": {
- "operationId": "BookingsController_createRecurringBooking",
+ "operationId": "OrganizationsEventTypesController_createTeamEventType",
"parameters": [
{
- "name": "x-cal-client-id",
+ "name": "teamId",
"required": true,
- "in": "header",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
+ }
+ },
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
}
}
],
@@ -1398,10 +1661,7 @@
"content": {
"application/json": {
"schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "$ref": "#/components/schemas/CreateTeamEventTypeInput_2024_06_14"
}
}
}
@@ -1412,213 +1672,3490 @@
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/CreateTeamEventTypeOutput"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Event Types"
]
- }
- },
- "/v2/bookings/instant": {
- "post": {
- "operationId": "BookingsController_createInstantBooking",
+ },
+ "get": {
+ "operationId": "OrganizationsEventTypesController_getTeamEventTypes",
"parameters": [
{
- "name": "x-cal-client-id",
+ "name": "teamId",
"required": true,
- "in": "header",
+ "in": "path",
"schema": {
- "type": "string"
+ "type": "number"
}
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateBookingInput"
- }
- }
- }
- },
"responses": {
- "201": {
+ "200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/GetTeamEventTypesOutput"
}
}
}
}
},
"tags": [
- "Bookings"
+ "Organizations Event Types"
]
}
},
- "/v2/slots/reserve": {
- "post": {
- "operationId": "SlotsController_reserveSlot",
- "parameters": [],
+ "/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}": {
+ "get": {
+ "operationId": "OrganizationsEventTypesController_getTeamEventType",
+ "parameters": [
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "eventTypeId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetTeamEventTypeOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Event Types"
+ ]
+ },
+ "patch": {
+ "operationId": "OrganizationsEventTypesController_updateTeamEventType",
+ "parameters": [
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "eventTypeId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ReserveSlotInput"
+ "$ref": "#/components/schemas/UpdateTeamEventTypeInput_2024_06_14"
}
}
}
},
"responses": {
- "201": {
+ "200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/UpdateTeamEventTypeOutput"
}
}
}
}
},
"tags": [
- "Slots"
+ "Organizations Event Types"
]
- }
- },
- "/v2/slots/selected-slot": {
+ },
"delete": {
- "operationId": "SlotsController_deleteSelectedSlot",
- "parameters": [],
+ "operationId": "OrganizationsEventTypesController_deleteTeamEventType",
+ "parameters": [
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "eventTypeId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/DeleteTeamEventTypeOutput"
}
}
}
}
},
"tags": [
- "Slots"
+ "Organizations Event Types"
]
}
},
- "/v2/slots/available": {
+ "/v2/organizations/{orgId}/teams/event-types": {
"get": {
- "operationId": "SlotsController_getAvailableSlots",
- "parameters": [],
+ "operationId": "OrganizationsEventTypesController_getTeamsEventTypes",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "take",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "skip",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/GetTeamEventTypesOutput"
}
}
}
}
},
"tags": [
- "Slots"
+ "Organizations Event Types"
]
}
},
- "/v2/timezones": {
+ "/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": {
- "operationId": "TimezonesController_getTimeZones",
- "parameters": [],
+ "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
+ "summary": "Get all the memberships of a team of an organization.",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "take",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to return",
+ "example": 10,
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "skip",
+ "required": false,
+ "in": "query",
+ "description": "The number of items to skip",
+ "example": 0,
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
- "type": "object"
+ "$ref": "#/components/schemas/OrgTeamMembershipsOutputResponseDto"
}
}
}
}
},
"tags": [
- "Timezones"
+ "Organizations Teams"
]
- }
- }
- },
- "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
+ },
+ "post": {
+ "operationId": "OrganizationsTeamsMembershipsController_createOrgTeamMembership",
+ "summary": "Create a membership of an organization's team",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
},
- "defaultScheduleId": {
- "type": "number",
- "nullable": true,
- "example": null
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateOrgTeamMembershipDto"
+ }
+ }
}
},
- "required": [
- "id",
- "email",
- "username",
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Teams"
+ ]
+ }
+ },
+ "/v2/organizations/{orgId}/teams/{teamId}/memberships/{membershipId}": {
+ "get": {
+ "operationId": "OrganizationsTeamsMembershipsController_getOrgTeamMembership",
+ "summary": "Get the membership of an organization's team by ID",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "membershipId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Teams"
+ ]
+ },
+ "delete": {
+ "operationId": "OrganizationsTeamsMembershipsController_deleteOrgTeamMembership",
+ "summary": "Delete the membership of an organization's team by ID",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "membershipId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Teams"
+ ]
+ },
+ "patch": {
+ "operationId": "OrganizationsTeamsMembershipsController_updateOrgTeamMembership",
+ "summary": "Update the membership of an organization's team by ID",
+ "parameters": [
+ {
+ "name": "orgId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "teamId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ },
+ {
+ "name": "membershipId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateOrgTeamMembershipDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Organizations Teams"
+ ]
+ }
+ },
+ "/v2/schedules": {
+ "post": {
+ "operationId": "SchedulesController_2024_04_15_createSchedule",
+ "parameters": [],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateScheduleInput_2024_04_15"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateScheduleOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ },
+ "get": {
+ "operationId": "SchedulesController_2024_04_15_getSchedules",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetSchedulesOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ }
+ },
+ "/v2/schedules/default": {
+ "get": {
+ "operationId": "SchedulesController_2024_04_15_getDefaultSchedule",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Returns the default schedule",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ }
+ },
+ "/v2/schedules/{scheduleId}": {
+ "get": {
+ "operationId": "SchedulesController_2024_04_15_getSchedule",
+ "parameters": [
+ {
+ "name": "scheduleId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetScheduleOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ },
+ "patch": {
+ "operationId": "SchedulesController_2024_04_15_updateSchedule",
+ "parameters": [
+ {
+ "name": "scheduleId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateScheduleOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ },
+ "delete": {
+ "operationId": "SchedulesController_2024_04_15_deleteSchedule",
+ "parameters": [
+ {
+ "name": "scheduleId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "number"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteScheduleOutput_2024_04_15"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Schedules"
+ ]
+ }
+ },
+ "/v2/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"
+ ]
+ }
+ },
+ "/v2/gcal/oauth/save": {
+ "get": {
+ "operationId": "GcalController_save",
+ "parameters": [
+ {
+ "name": "state",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "code",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GcalSaveRedirectOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Google Calendar"
+ ]
+ }
+ },
+ "/v2/gcal/check": {
+ "get": {
+ "operationId": "GcalController_check",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GcalCheckOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Google Calendar"
+ ]
+ }
+ },
+ "/v2/provider/{clientId}": {
+ "get": {
+ "operationId": "CalProviderController_verifyClientId",
+ "parameters": [
+ {
+ "name": "clientId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProviderVerifyClientOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Cal provider"
+ ]
+ }
+ },
+ "/v2/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"
+ ]
+ }
+ },
+ "/v2/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"
+ ]
+ }
+ },
+ "/v2/calendars/busy-times": {
+ "get": {
+ "operationId": "CalendarsController_getBusyTimes",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetBusyTimesOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/calendars": {
+ "get": {
+ "operationId": "CalendarsController_getCalendars",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ConnectedCalendarsOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/calendars/{calendar}/connect": {
+ "get": {
+ "operationId": "CalendarsController_redirect",
+ "parameters": [
+ {
+ "name": "Authorization",
+ "required": true,
+ "in": "header",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "calendar",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/calendars/{calendar}/save": {
+ "get": {
+ "operationId": "CalendarsController_save",
+ "parameters": [
+ {
+ "name": "state",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "code",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "calendar",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/calendars/{calendar}/credentials": {
+ "post": {
+ "operationId": "CalendarsController_syncCredentials",
+ "parameters": [
+ {
+ "name": "calendar",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": ""
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/calendars/{calendar}/check": {
+ "get": {
+ "operationId": "CalendarsController_check",
+ "parameters": [
+ {
+ "name": "calendar",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Calendars"
+ ]
+ }
+ },
+ "/v2/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": [
+ {
+ "name": "x-cal-client-id",
+ "required": true,
+ "in": "header",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateBookingInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Bookings"
+ ]
+ }
+ },
+ "/v2/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"
+ ]
+ }
+ },
+ "/v2/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"
+ ]
+ }
+ },
+ "/v2/bookings/{bookingId}/cancel": {
+ "post": {
+ "operationId": "BookingsController_cancelBooking",
+ "parameters": [
+ {
+ "name": "bookingId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "x-cal-client-id",
+ "required": true,
+ "in": "header",
+ "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"
+ ]
+ }
+ },
+ "/v2/bookings/{bookingUid}/mark-no-show": {
+ "post": {
+ "operationId": "BookingsController_markNoShow",
+ "parameters": [
+ {
+ "name": "bookingUid",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MarkNoShowInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MarkNoShowOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Bookings"
+ ]
+ }
+ },
+ "/v2/bookings/recurring": {
+ "post": {
+ "operationId": "BookingsController_createRecurringBooking",
+ "parameters": [
+ {
+ "name": "x-cal-client-id",
+ "required": true,
+ "in": "header",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Bookings"
+ ]
+ }
+ },
+ "/v2/bookings/instant": {
+ "post": {
+ "operationId": "BookingsController_createInstantBooking",
+ "parameters": [
+ {
+ "name": "x-cal-client-id",
+ "required": true,
+ "in": "header",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateBookingInput"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Bookings"
+ ]
+ }
+ },
+ "/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"
+ ]
+ }
+ },
+ "/v2/slots/selected-slot": {
+ "delete": {
+ "operationId": "SlotsController_deleteSelectedSlot",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Slots"
+ ]
+ }
+ },
+ "/v2/slots/available": {
+ "get": {
+ "operationId": "SlotsController_getAvailableSlots",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Slots"
+ ]
+ }
+ },
+ "/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
+ },
+ "locale": {
+ "enum": [
+ "ar",
+ "ca",
+ "de",
+ "es",
+ "eu",
+ "he",
+ "id",
+ "ja",
+ "lv",
+ "pl",
+ "ro",
+ "sr",
+ "th",
+ "vi",
+ "az",
+ "cs",
+ "el",
+ "es-419",
+ "fi",
+ "hr",
+ "it",
+ "km",
+ "nl",
+ "pt",
+ "ru",
+ "sv",
+ "tr",
+ "zh-CN",
+ "bg",
+ "da",
+ "en",
+ "et",
+ "fr",
+ "hu",
+ "iw",
+ "ko",
+ "no",
+ "pt-BR",
+ "sk",
+ "ta",
+ "uk",
+ "zh-TW"
+ ],
+ "type": "string",
+ "example": "en"
+ }
+ },
+ "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,
+ "enum": [
+ 12,
+ 24
+ ],
+ "description": "Must be 12 or 24"
+ },
+ "weekStart": {
+ "type": "string",
+ "example": "Monday",
+ "enum": [
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday"
+ ]
+ },
+ "timeZone": {
+ "type": "string",
+ "example": "America/New_York"
+ },
+ "locale": {
+ "enum": [
+ "ar",
+ "ca",
+ "de",
+ "es",
+ "eu",
+ "he",
+ "id",
+ "ja",
+ "lv",
+ "pl",
+ "ro",
+ "sr",
+ "th",
+ "vi",
+ "az",
+ "cs",
+ "el",
+ "es-419",
+ "fi",
+ "hr",
+ "it",
+ "km",
+ "nl",
+ "pt",
+ "ru",
+ "sv",
+ "tr",
+ "zh-CN",
+ "bg",
+ "da",
+ "en",
+ "et",
+ "fr",
+ "hu",
+ "iw",
+ "ko",
+ "no",
+ "pt-BR",
+ "sk",
+ "ta",
+ "uk",
+ "zh-TW"
+ ],
+ "type": "string",
+ "example": "en"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ]
+ },
+ "CreateManagedUserData": {
+ "type": "object",
+ "properties": {
+ "user": {
+ "$ref": "#/components/schemas/ManagedUserOutput"
+ },
+ "accessToken": {
+ "type": "string"
+ },
+ "refreshToken": {
+ "type": "string"
+ },
+ "accessTokenExpiresAt": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "user",
+ "accessToken",
+ "refreshToken",
+ "accessTokenExpiresAt"
+ ]
+ },
+ "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": {
+ "timeFormat": {
+ "type": "number",
+ "enum": [
+ 12,
+ 24
+ ],
+ "example": 12,
+ "description": "Must be 12 or 24"
+ },
+ "weekStart": {
+ "type": "string",
+ "enum": [
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday"
+ ],
+ "example": "Monday"
+ },
+ "locale": {
+ "enum": [
+ "ar",
+ "ca",
+ "de",
+ "es",
+ "eu",
+ "he",
+ "id",
+ "ja",
+ "lv",
+ "pl",
+ "ro",
+ "sr",
+ "th",
+ "vi",
+ "az",
+ "cs",
+ "el",
+ "es-419",
+ "fi",
+ "hr",
+ "it",
+ "km",
+ "nl",
+ "pt",
+ "ru",
+ "sv",
+ "tr",
+ "zh-CN",
+ "bg",
+ "da",
+ "en",
+ "et",
+ "fr",
+ "hu",
+ "iw",
+ "ko",
+ "no",
+ "pt-BR",
+ "sk",
+ "ta",
+ "uk",
+ "zh-TW"
+ ],
+ "type": "string",
+ "example": "en"
+ },
+ "email": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "defaultScheduleId": {
+ "type": "number"
+ },
+ "timeZone": {
+ "type": "string"
+ }
+ }
+ },
+ "KeysDto": {
+ "type": "object",
+ "properties": {
+ "accessToken": {
+ "type": "string",
+ "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ },
+ "refreshToken": {
+ "type": "string",
+ "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ },
+ "accessTokenExpiresAt": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "accessToken",
+ "refreshToken",
+ "accessTokenExpiresAt"
+ ]
+ },
+ "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"
+ ]
+ },
+ "CreateEventTypeInput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "lengthInMinutes": {
+ "type": "number",
+ "example": 60
+ },
+ "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!"
+ }
+ },
+ "required": [
+ "lengthInMinutes",
+ "title",
+ "description"
+ ]
+ },
+ "EventTypeOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "example": 1
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "CreateEventTypeOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "success",
+ "error"
+ ],
+ "example": "success"
+ },
+ "data": {
+ "$ref": "#/components/schemas/EventTypeOutput_2024_06_14"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "GetEventTypeOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "success",
+ "error"
+ ],
+ "example": "success"
+ },
+ "data": {
+ "nullable": true,
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/EventTypeOutput_2024_06_14"
+ }
+ ]
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "GetEventTypesOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "success",
+ "error"
+ ],
+ "example": "success"
+ },
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EventTypeOutput_2024_06_14"
+ }
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "UpdateEventTypeInput_2024_06_14": {
+ "type": "object",
+ "properties": {}
+ },
+ "UpdateEventTypeOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "success",
+ "error"
+ ],
+ "example": "success"
+ },
+ "data": {
+ "$ref": "#/components/schemas/EventTypeOutput_2024_06_14"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "DeleteData_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "example": 1
+ },
+ "lengthInMinutes": {
+ "type": "number",
+ "example": 60
+ },
+ "title": {
+ "type": "string",
+ "example": "Learn the secrets of masterchief!"
+ },
+ "slug": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "lengthInMinutes",
+ "title",
+ "slug"
+ ]
+ },
+ "DeleteEventTypeOutput_2024_06_14": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "success",
+ "error"
+ ],
+ "example": "success"
+ },
+ "data": {
+ "$ref": "#/components/schemas/DeleteData_2024_06_14"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "EventTypeLocation_2024_04_15": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "link"
+ },
+ "link": {
+ "type": "string",
+ "example": "https://masterchief.com/argentina/flan/video/9129412"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ "CreateEventTypeInput_2024_04_15": {
+ "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_2024_04_15"
+ }
+ },
+ "disableGuests": {
+ "type": "boolean"
+ },
+ "slotInterval": {
+ "type": "number",
+ "minimum": 0
+ },
+ "minimumBookingNotice": {
+ "type": "number",
+ "minimum": 0
+ },
+ "beforeEventBuffer": {
+ "type": "number",
+ "minimum": 0
+ },
+ "afterEventBuffer": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ "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_2024_04_15"
+ }
+ }
+ },
+ "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"
+ ]
+ },
+ "Option": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "value",
+ "label"
+ ]
+ },
+ "VariantsConfig": {
+ "type": "object",
+ "properties": {
+ "variants": {
+ "type": "object"
+ }
+ },
+ "required": [
+ "variants"
+ ]
+ },
+ "View": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "label"
+ ]
+ },
+ "BookingField_2024_04_15": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "number",
+ "boolean",
+ "address",
+ "name",
+ "text",
+ "textarea",
+ "email",
+ "phone",
+ "multiemail",
+ "select",
+ "multiselect",
+ "checkbox",
+ "radio",
+ "radioInput"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Option"
+ }
+ },
+ "label": {
+ "type": "string"
+ },
+ "labelAsSafeHtml": {
+ "type": "string"
+ },
+ "defaultLabel": {
+ "type": "string"
+ },
+ "placeholder": {
+ "type": "string"
+ },
+ "required": {
+ "type": "boolean"
+ },
+ "getOptionsAt": {
+ "type": "string"
+ },
+ "optionsInputs": {
+ "type": "object"
+ },
+ "variant": {
+ "type": "string"
+ },
+ "variantsConfig": {
+ "$ref": "#/components/schemas/VariantsConfig"
+ },
+ "views": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/View"
+ }
+ },
+ "hideWhenJustOneOption": {
+ "type": "boolean"
+ },
+ "hidden": {
+ "type": "boolean"
+ },
+ "editable": {
+ "type": "string",
+ "enum": [
+ "system",
+ "system-but-optional",
+ "system-but-hidden",
+ "user",
+ "user-readonly"
+ ]
+ },
+ "sources": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Source"
+ }
+ }
+ },
+ "required": [
+ "type",
+ "name"
+ ]
+ },
+ "UpdateEventTypeInput_2024_04_15": {
+ "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_2024_04_15"
+ }
+ },
+ "bookingFields": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BookingField_2024_04_15"
+ }
+ },
+ "disableGuests": {
+ "type": "boolean"
+ },
+ "minimumBookingNotice": {
+ "type": "number",
+ "minimum": 0
+ },
+ "beforeEventBuffer": {
+ "type": "number",
+ "minimum": 0
+ },
+ "afterEventBuffer": {
+ "type": "number",
+ "minimum": 0
+ },
+ "slotInterval": {
+ "type": "number",
+ "minimum": 0
+ }
+ }
+ },
+ "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"
+ ]
+ },
+ "OrgTeamOutputDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number"
+ },
+ "parentId": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "slug": {
+ "type": "string"
+ },
+ "logoUrl": {
+ "type": "string"
+ },
+ "calVideoLogo": {
+ "type": "string"
+ },
+ "appLogo": {
+ "type": "string"
+ },
+ "appIconLogo": {
+ "type": "string"
+ },
+ "bio": {
+ "type": "string"
+ },
+ "hideBranding": {
+ "type": "boolean"
+ },
+ "isOrganization": {
+ "type": "boolean"
+ },
+ "isPrivate": {
+ "type": "boolean"
+ },
+ "hideBookATeamMember": {
+ "type": "boolean",
+ "default": false
+ },
+ "metadata": {
+ "type": "string"
+ },
+ "theme": {
+ "type": "string"
+ },
+ "brandColor": {
+ "type": "string"
+ },
+ "darkBrandColor": {
+ "type": "string"
+ },
+ "bannerUrl": {
+ "type": "string"
+ },
+ "timeFormat": {
+ "type": "number"
+ },
+ "timeZone": {
+ "type": "string",
+ "default": "Europe/London"
+ },
+ "weekStart": {
+ "type": "string",
+ "default": "Sunday"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ },
+ "OrgTeamsOutputResponseDto": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/OrgTeamOutputDto"
+ }
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "OrgTeamOutputResponseDto": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "$ref": "#/components/schemas/OrgTeamOutputDto"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "CreateOrgTeamDto": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "slug": {
+ "type": "string"
+ },
+ "logoUrl": {
+ "type": "string"
+ },
+ "calVideoLogo": {
+ "type": "string"
+ },
+ "appLogo": {
+ "type": "string"
+ },
+ "appIconLogo": {
+ "type": "string"
+ },
+ "bio": {
+ "type": "string"
+ },
+ "hideBranding": {
+ "type": "boolean",
+ "default": false
+ },
+ "isPrivate": {
+ "type": "boolean"
+ },
+ "hideBookATeamMember": {
+ "type": "boolean"
+ },
+ "metadata": {
+ "type": "string"
+ },
+ "theme": {
+ "type": "string"
+ },
+ "brandColor": {
+ "type": "string"
+ },
+ "darkBrandColor": {
+ "type": "string"
+ },
+ "bannerUrl": {
+ "type": "string"
+ },
+ "timeFormat": {
+ "type": "number"
+ },
+ "timeZone": {
+ "type": "string",
+ "default": "Europe/London"
+ },
+ "weekStart": {
+ "type": "string",
+ "default": "Sunday"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "ScheduleAvailabilityInput_2024_06_11": {
+ "type": "object",
+ "properties": {
+ "days": {
+ "example": [
+ "Monday",
+ "Tuesday"
+ ],
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "startTime": {
+ "type": "string",
+ "pattern": "TIME_FORMAT_HH_MM",
+ "example": "09:00"
+ },
+ "endTime": {
+ "type": "string",
+ "pattern": "TIME_FORMAT_HH_MM",
+ "example": "10:00"
+ }
+ },
+ "required": [
+ "days",
+ "startTime",
+ "endTime"
+ ]
+ },
+ "ScheduleOverrideInput_2024_06_11": {
+ "type": "object",
+ "properties": {
+ "date": {
+ "type": "string",
+ "example": "2024-05-20"
+ },
+ "startTime": {
+ "type": "string",
+ "pattern": "TIME_FORMAT_HH_MM",
+ "example": "12:00"
+ },
+ "endTime": {
+ "type": "string",
+ "pattern": "TIME_FORMAT_HH_MM",
+ "example": "13:00"
+ }
+ },
+ "required": [
+ "date",
+ "startTime",
+ "endTime"
+ ]
+ },
+ "ScheduleOutput_2024_06_11": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number",
+ "example": 254
+ },
+ "ownerId": {
+ "type": "number",
+ "example": 478
+ },
+ "name": {
+ "type": "string",
+ "example": "One-on-one coaching"
+ },
+ "timeZone": {
+ "type": "string",
+ "example": "Europe/Rome"
+ },
+ "availability": {
+ "example": [
+ {
+ "days": [
+ "Monday",
+ "Tuesday"
+ ],
+ "startTime": "09:00",
+ "endTime": "10:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
+ }
+ },
+ "isDefault": {
+ "type": "boolean",
+ "example": true
+ },
+ "overrides": {
+ "example": [
+ {
+ "date": "2024-05-20",
+ "startTime": "12:00",
+ "endTime": "13:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "ownerId",
+ "name",
"timeZone",
- "weekStart",
- "createdDate",
- "timeFormat",
- "defaultScheduleId"
+ "availability",
+ "isDefault",
+ "overrides"
]
},
- "GetManagedUsersOutput": {
+ "GetSchedulesOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
@@ -1632,8 +5169,11 @@
"data": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/ManagedUserOutput"
+ "$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
+ },
+ "error": {
+ "type": "object"
}
},
"required": [
@@ -1641,53 +5181,58 @@
"data"
]
},
- "CreateManagedUserInput": {
+ "CreateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
- "email": {
- "type": "string",
- "example": "alice@example.com"
- },
- "timeFormat": {
- "type": "number",
- "example": 12
- },
- "weekStart": {
+ "name": {
"type": "string",
- "example": "Sunday"
+ "example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
- "example": "America/New_York"
+ "example": "Europe/Rome"
},
- "name": {
- "type": "string"
- }
- },
- "required": [
- "email"
- ]
- },
- "CreateManagedUserData": {
- "type": "object",
- "properties": {
- "user": {
- "$ref": "#/components/schemas/ManagedUserOutput"
+ "availability": {
+ "example": [
+ {
+ "days": [
+ "Monday",
+ "Tuesday"
+ ],
+ "startTime": "09:00",
+ "endTime": "10:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
+ }
},
- "accessToken": {
- "type": "string"
+ "isDefault": {
+ "type": "boolean",
+ "example": true
},
- "refreshToken": {
- "type": "string"
+ "overrides": {
+ "example": [
+ {
+ "date": "2024-05-20",
+ "startTime": "12:00",
+ "endTime": "14:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
+ }
}
},
"required": [
- "user",
- "accessToken",
- "refreshToken"
+ "name",
+ "timeZone",
+ "isDefault"
]
},
- "CreateManagedUserOutput": {
+ "CreateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
@@ -1699,7 +5244,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/CreateManagedUserData"
+ "$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
@@ -1707,7 +5252,7 @@
"data"
]
},
- "GetManagedUserOutput": {
+ "GetScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
@@ -1719,7 +5264,15 @@
]
},
"data": {
- "$ref": "#/components/schemas/ManagedUserOutput"
+ "nullable": true,
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
+ }
+ ]
+ },
+ "error": {
+ "type": "object"
}
},
"required": [
@@ -1727,47 +5280,53 @@
"data"
]
},
- "UpdateManagedUserInput": {
+ "UpdateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
- "email": {
- "type": "string"
- },
"name": {
- "type": "string"
+ "type": "string",
+ "example": "One-on-one coaching"
},
- "timeFormat": {
- "type": "number"
+ "timeZone": {
+ "type": "string",
+ "example": "Europe/Rome"
},
- "defaultScheduleId": {
- "type": "number"
+ "availability": {
+ "example": [
+ {
+ "days": [
+ "Monday",
+ "Tuesday"
+ ],
+ "startTime": "09:00",
+ "endTime": "10:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
+ }
},
- "weekStart": {
- "type": "string"
+ "isDefault": {
+ "type": "boolean",
+ "example": true
},
- "timeZone": {
- "type": "string"
+ "overrides": {
+ "example": [
+ {
+ "date": "2024-05-20",
+ "startTime": "12:00",
+ "endTime": "14:00"
+ }
+ ],
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
+ }
}
}
},
- "KeysDto": {
- "type": "object",
- "properties": {
- "accessToken": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
- },
- "refreshToken": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
- }
- },
- "required": [
- "accessToken",
- "refreshToken"
- ]
- },
- "KeysResponseDto": {
+ "UpdateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
@@ -1779,7 +5338,10 @@
]
},
"data": {
- "$ref": "#/components/schemas/KeysDto"
+ "$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
+ },
+ "error": {
+ "type": "object"
}
},
"required": [
@@ -1787,109 +5349,158 @@
"data"
]
},
- "CreateOAuthClientInput": {
- "type": "object",
- "properties": {}
- },
- "DataDto": {
- "type": "object",
- "properties": {
- "clientId": {
- "type": "string",
- "example": "clsx38nbl0001vkhlwin9fmt0"
- },
- "clientSecret": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi"
- }
- },
- "required": [
- "clientId",
- "clientSecret"
- ]
- },
- "CreateOAuthClientResponseDto": {
+ "DeleteScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
+ "example": "success",
"enum": [
"success",
"error"
- ],
- "example": "success"
- },
- "data": {
- "example": {
- "clientId": "clsx38nbl0001vkhlwin9fmt0",
- "clientSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi"
- },
- "allOf": [
- {
- "$ref": "#/components/schemas/DataDto"
- }
]
}
},
"required": [
- "status",
- "data"
+ "status"
]
},
- "PlatformOAuthClientDto": {
+ "GetUserOutput": {
"type": "object",
"properties": {
"id": {
+ "type": "number",
+ "description": "The ID of the user",
+ "example": 1
+ },
+ "username": {
"type": "string",
- "example": "clsx38nbl0001vkhlwin9fmt0"
+ "nullable": true,
+ "description": "The username of the user",
+ "example": "john_doe"
},
"name": {
"type": "string",
- "example": "MyClient"
+ "nullable": true,
+ "description": "The name of the user",
+ "example": "John Doe"
},
- "secret": {
+ "email": {
"type": "string",
- "example": "secretValue"
+ "description": "The email of the user",
+ "example": "john@example.com"
},
- "permissions": {
- "type": "number",
- "example": 3
+ "emailVerified": {
+ "format": "date-time",
+ "type": "string",
+ "nullable": true,
+ "description": "The date when the email was verified",
+ "example": "2022-01-01T00:00:00Z"
},
- "logo": {
+ "bio": {
"type": "string",
"nullable": true,
- "example": "https://example.com/logo.png"
+ "description": "The bio of the user",
+ "example": "I am a software developer"
},
- "redirectUris": {
- "example": [
- "https://example.com/callback"
- ],
- "type": "array",
- "items": {
- "type": "string"
- }
+ "avatarUrl": {
+ "type": "string",
+ "nullable": true,
+ "description": "The URL of the user's avatar",
+ "example": "https://example.com/avatar.jpg"
},
- "organizationId": {
+ "timeZone": {
+ "type": "string",
+ "description": "The time zone of the user",
+ "example": "America/New_York"
+ },
+ "weekStart": {
+ "type": "string",
+ "description": "The week start day of the user",
+ "example": "Monday"
+ },
+ "appTheme": {
+ "type": "string",
+ "nullable": true,
+ "description": "The app theme of the user",
+ "example": "light"
+ },
+ "theme": {
+ "type": "string",
+ "nullable": true,
+ "description": "The theme of the user",
+ "example": "default"
+ },
+ "defaultScheduleId": {
"type": "number",
+ "nullable": true,
+ "description": "The ID of the default schedule for the user",
"example": 1
},
- "createdAt": {
+ "locale": {
+ "type": "string",
+ "nullable": true,
+ "description": "The locale of the user",
+ "example": "en-US"
+ },
+ "timeFormat": {
+ "type": "number",
+ "nullable": true,
+ "description": "The time format of the user",
+ "example": 12
+ },
+ "hideBranding": {
+ "type": "boolean",
+ "description": "Whether to hide branding for the user",
+ "example": false
+ },
+ "brandColor": {
+ "type": "string",
+ "nullable": true,
+ "description": "The brand color of the user",
+ "example": "#ffffff"
+ },
+ "darkBrandColor": {
+ "type": "string",
+ "nullable": true,
+ "description": "The dark brand color of the user",
+ "example": "#000000"
+ },
+ "allowDynamicBooking": {
+ "type": "boolean",
+ "nullable": true,
+ "description": "Whether dynamic booking is allowed for the user",
+ "example": true
+ },
+ "createdDate": {
"format": "date-time",
"type": "string",
- "example": "2024-03-23T08:33:21.851Z"
+ "description": "The date when the user was created",
+ "example": "2022-01-01T00:00:00Z"
+ },
+ "verified": {
+ "type": "boolean",
+ "nullable": true,
+ "description": "Whether the user is verified",
+ "example": true
+ },
+ "invitedTo": {
+ "type": "number",
+ "nullable": true,
+ "description": "The ID of the user who invited this user",
+ "example": 1
}
- },
- "required": [
- "id",
- "name",
- "secret",
- "permissions",
- "redirectUris",
- "organizationId",
- "createdAt"
+ },
+ "required": [
+ "id",
+ "email",
+ "timeZone",
+ "weekStart",
+ "hideBranding",
+ "createdDate"
]
},
- "GetOAuthClientsResponseDto": {
+ "GetOrganizationUsersOutput": {
"type": "object",
"properties": {
"status": {
@@ -1903,7 +5514,7 @@
"data": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/PlatformOAuthClientDto"
+ "$ref": "#/components/schemas/GetUserOutput"
}
}
},
@@ -1912,180 +5523,154 @@
"data"
]
},
- "GetOAuthClientResponseDto": {
+ "CreateOrganizationUserInput": {
"type": "object",
"properties": {
- "status": {
+ "email": {
"type": "string",
- "example": "success",
- "enum": [
- "success",
- "error"
- ]
+ "description": "User email address",
+ "example": "user@example.com"
},
- "data": {
- "$ref": "#/components/schemas/PlatformOAuthClientDto"
- }
- },
- "required": [
- "status",
- "data"
- ]
- },
- "UpdateOAuthClientInput": {
- "type": "object",
- "properties": {
- "logo": {
- "type": "string"
+ "username": {
+ "type": "string",
+ "description": "Username",
+ "example": "user123"
},
- "name": {
- "type": "string"
+ "weekday": {
+ "type": "string",
+ "description": "Preferred weekday",
+ "example": "Monday"
},
- "redirectUris": {
- "default": [],
- "type": "array",
- "items": {
- "type": "string"
- }
+ "brandColor": {
+ "type": "string",
+ "description": "Brand color in HEX format",
+ "example": "#FFFFFF"
},
- "bookingRedirectUri": {
- "type": "string"
+ "darkBrandColor": {
+ "type": "string",
+ "description": "Dark brand color in HEX format",
+ "example": "#000000"
},
- "bookingCancelRedirectUri": {
- "type": "string"
+ "hideBranding": {
+ "type": "boolean",
+ "description": "Hide branding",
+ "example": false
},
- "bookingRescheduleRedirectUri": {
- "type": "string"
+ "timeZone": {
+ "type": "string",
+ "description": "Time zone",
+ "example": "America/New_York"
},
- "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": {
+ "theme": {
"type": "string",
- "example": "link"
+ "nullable": true,
+ "description": "Theme",
+ "example": "dark"
},
- "link": {
+ "appTheme": {
"type": "string",
- "example": "https://masterchief.com/argentina/flan/video/9129412"
+ "nullable": true,
+ "description": "Application theme",
+ "example": "light"
+ },
+ "timeFormat": {
+ "type": "number",
+ "description": "Time format",
+ "example": 24
+ },
+ "defaultScheduleId": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Default schedule ID",
+ "example": 1
+ },
+ "locale": {
+ "type": "string",
+ "nullable": true,
+ "default": "en",
+ "description": "Locale",
+ "example": "en"
+ },
+ "avatarUrl": {
+ "type": "string",
+ "description": "Avatar URL",
+ "example": "https://example.com/avatar.jpg"
+ },
+ "organizationRole": {
+ "type": "object",
+ "default": "MEMBER"
+ },
+ "autoAccept": {
+ "type": "object",
+ "default": true
}
},
"required": [
- "type"
+ "email",
+ "organizationRole",
+ "autoAccept"
]
},
- "CreateEventTypeInput": {
+ "GetOrganizationUserOutput": {
"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": {
+ "status": {
"type": "string",
- "example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
},
- "locations": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/EventTypeLocation"
- }
+ "data": {
+ "$ref": "#/components/schemas/GetUserOutput"
}
},
"required": [
- "length",
- "slug",
- "title"
+ "status",
+ "data"
]
},
- "EventTypeOutput": {
+ "UpdateOrganizationUserInput": {
+ "type": "object",
+ "properties": {}
+ },
+ "OrgMembershipOutputDto": {
"type": "object",
"properties": {
- "id": {
- "type": "number",
- "example": 1
+ "role": {
+ "type": "string",
+ "enum": [
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
+ ]
},
- "length": {
- "type": "number",
- "example": 60
+ "id": {
+ "type": "number"
},
- "slug": {
- "type": "string",
- "example": "cooking-class"
+ "userId": {
+ "type": "number"
},
- "title": {
- "type": "string",
- "example": "Learn the secrets of masterchief!"
+ "teamId": {
+ "type": "number"
},
- "description": {
- "type": "string",
- "nullable": true,
- "example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
+ "accepted": {
+ "type": "boolean"
},
- "locations": {
- "nullable": true,
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/EventTypeLocation"
- }
+ "disableImpersonation": {
+ "type": "boolean"
}
},
"required": [
+ "role",
"id",
- "length",
- "slug",
- "title",
- "description",
- "locations"
+ "userId",
+ "teamId",
+ "accepted"
]
},
- "CreateEventTypeOutput": {
+ "GetAllOrgMemberships": {
"type": "object",
"properties": {
"status": {
@@ -2097,7 +5682,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/EventTypeOutput"
+ "$ref": "#/components/schemas/OrgMembershipOutputDto"
}
},
"required": [
@@ -2105,18 +5690,36 @@
"data"
]
},
- "Data": {
+ "CreateOrgMembershipDto": {
"type": "object",
"properties": {
- "eventType": {
- "$ref": "#/components/schemas/EventTypeOutput"
+ "role": {
+ "type": "string",
+ "default": "MEMBER",
+ "enum": [
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
+ ]
+ },
+ "userId": {
+ "type": "number"
+ },
+ "accepted": {
+ "type": "boolean",
+ "default": false
+ },
+ "disableImpersonation": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
- "eventType"
+ "role",
+ "userId"
]
},
- "GetEventTypeOutput": {
+ "CreateOrgMembershipOutput": {
"type": "object",
"properties": {
"status": {
@@ -2128,7 +5731,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/Data"
+ "$ref": "#/components/schemas/OrgMembershipOutputDto"
}
},
"required": [
@@ -2136,35 +5739,27 @@
"data"
]
},
- "EventTypeGroup": {
- "type": "object",
- "properties": {
- "eventTypes": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/EventTypeOutput"
- }
- }
- },
- "required": [
- "eventTypes"
- ]
- },
- "GetEventTypesData": {
+ "GetOrgMembership": {
"type": "object",
"properties": {
- "eventTypeGroups": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/EventTypeGroup"
- }
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "$ref": "#/components/schemas/OrgMembershipOutputDto"
}
},
"required": [
- "eventTypeGroups"
+ "status",
+ "data"
]
},
- "GetEventTypesOutput": {
+ "DeleteOrgMembership": {
"type": "object",
"properties": {
"status": {
@@ -2176,7 +5771,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/GetEventTypesData"
+ "$ref": "#/components/schemas/OrgMembershipOutputDto"
}
},
"required": [
@@ -2184,218 +5779,189 @@
"data"
]
},
- "Location": {
+ "UpdateOrgMembershipDto": {
"type": "object",
"properties": {
- "type": {
- "type": "string"
+ "role": {
+ "type": "string",
+ "default": "MEMBER",
+ "enum": [
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
+ ]
+ },
+ "accepted": {
+ "type": "boolean",
+ "default": false
+ },
+ "disableImpersonation": {
+ "type": "boolean",
+ "default": false
}
- },
- "required": [
- "type"
- ]
+ }
},
- "Source": {
+ "UpdateOrgMembership": {
"type": "object",
"properties": {
- "id": {
- "type": "string"
- },
- "type": {
- "type": "string"
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
},
- "label": {
- "type": "string"
+ "data": {
+ "$ref": "#/components/schemas/OrgMembershipOutputDto"
}
},
"required": [
- "id",
- "type",
- "label"
+ "status",
+ "data"
]
},
- "BookingField": {
+ "CreateTeamEventTypeInput_2024_06_14": {
"type": "object",
"properties": {
- "name": {
- "type": "string"
+ "lengthInMinutes": {
+ "type": "number",
+ "example": 60
},
- "type": {
- "type": "string"
+ "title": {
+ "type": "string",
+ "example": "Learn the secrets of masterchief!"
},
- "defaultLabel": {
- "type": "string"
+ "description": {
+ "type": "string",
+ "example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
- "label": {
- "type": "string"
+ "locations": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
- "placeholder": {
- "type": "string"
+ "bookingFields": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
- "required": {
+ "disableGuests": {
"type": "boolean"
},
- "getOptionsAt": {
- "type": "string"
+ "slotInterval": {
+ "type": "number"
},
- "hideWhenJustOneOption": {
- "type": "boolean"
+ "minimumBookingNotice": {
+ "type": "number"
},
- "editable": {
- "type": "string"
+ "beforeEventBuffer": {
+ "type": "number"
},
- "sources": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/Source"
- }
- }
- },
- "required": [
- "name",
- "type"
- ]
- },
- "Organization": {
- "type": "object",
- "properties": {
- "id": {
+ "afterEventBuffer": {
"type": "number"
},
- "slug": {
- "type": "string",
- "nullable": true
+ "schedulingType": {
+ "type": "object"
},
- "name": {
- "type": "string"
+ "hosts": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
- "metadata": {
- "type": "object"
+ "assignAllTeamMembers": {
+ "type": "boolean"
}
},
"required": [
- "id",
- "name",
- "metadata"
+ "lengthInMinutes",
+ "title",
+ "description",
+ "locations",
+ "bookingFields",
+ "disableGuests",
+ "slotInterval",
+ "minimumBookingNotice",
+ "beforeEventBuffer",
+ "afterEventBuffer",
+ "schedulingType",
+ "hosts",
+ "assignAllTeamMembers"
]
},
- "Profile": {
+ "CreateTeamEventTypeOutput": {
"type": "object",
"properties": {
- "username": {
+ "status": {
"type": "string",
- "nullable": true
- },
- "id": {
- "type": "number",
- "nullable": true
- },
- "userId": {
- "type": "number"
- },
- "uid": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "organizationId": {
- "type": "number",
- "nullable": true
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
},
- "organization": {
- "nullable": true,
- "allOf": [
+ "data": {
+ "oneOf": [
{
- "$ref": "#/components/schemas/Organization"
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
+ }
}
]
- },
- "upId": {
- "type": "string"
- },
- "image": {
- "type": "string"
- },
- "brandColor": {
- "type": "string"
- },
- "darkBrandColor": {
- "type": "string"
- },
- "theme": {
- "type": "string"
- },
- "bookerLayouts": {
- "type": "object"
}
},
"required": [
- "username",
- "id",
- "organizationId",
- "upId"
+ "status",
+ "data"
]
},
- "Owner": {
+ "RecurringEvent_2024_06_14": {
"type": "object",
"properties": {
- "id": {
- "type": "number"
- },
- "avatarUrl": {
- "type": "string",
- "nullable": true
- },
- "username": {
- "type": "string",
- "nullable": true
- },
- "name": {
- "type": "string",
- "nullable": true
- },
- "weekStart": {
+ "dtstart": {
+ "format": "date-time",
"type": "string"
},
- "brandColor": {
- "type": "string",
- "nullable": true
- },
- "darkBrandColor": {
- "type": "string",
- "nullable": true
- },
- "theme": {
- "type": "string",
- "nullable": true
+ "interval": {
+ "type": "number"
},
- "metadata": {
- "type": "object"
+ "count": {
+ "type": "number"
},
- "defaultScheduleId": {
+ "freq": {
"type": "number",
- "nullable": true
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6
+ ]
},
- "nonProfileUsername": {
- "type": "string",
- "nullable": true
+ "until": {
+ "format": "date-time",
+ "type": "string"
},
- "profile": {
- "$ref": "#/components/schemas/Profile"
+ "tzid": {
+ "type": "string"
}
},
"required": [
- "id",
- "username",
- "name",
- "weekStart",
- "metadata",
- "nonProfileUsername",
- "profile"
+ "interval",
+ "count",
+ "freq"
]
},
- "Schedule": {
+ "Schedule_2024_06_14": {
"type": "object",
"properties": {
"id": {
@@ -2411,80 +5977,52 @@
"timeZone"
]
},
- "User": {
+ "Host": {
"type": "object",
"properties": {
- "username": {
- "type": "string",
- "nullable": true
- },
- "name": {
- "type": "string",
- "nullable": true
- },
- "weekStart": {
- "type": "string"
- },
- "organizationId": {
+ "userId": {
"type": "number"
},
- "avatarUrl": {
- "type": "string",
- "nullable": true
- },
- "profile": {
- "$ref": "#/components/schemas/Profile"
+ "mandatory": {
+ "type": "boolean",
+ "default": false
},
- "bookerUrl": {
- "type": "string"
+ "priority": {
+ "type": "object",
+ "default": "medium"
}
},
"required": [
- "username",
- "name",
- "weekStart",
- "profile",
- "bookerUrl"
+ "userId"
]
},
- "PublicEventTypeOutput": {
+ "TeamEventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
"id": {
- "type": "number"
+ "type": "number",
+ "example": 1
+ },
+ "lengthInMinutes": {
+ "type": "number",
+ "minimum": 1
},
"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"
+ "description": {
+ "type": "string"
},
"locations": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/Location"
+ "type": "object"
}
},
- "customInputs": {
+ "bookingFields": {
"type": "array",
"items": {
"type": "object"
@@ -2493,129 +6031,122 @@
"disableGuests": {
"type": "boolean"
},
- "metadata": {
- "type": "object",
+ "slotInterval": {
+ "type": "number",
"nullable": true
},
- "lockTimeZoneToggleOnBookingPage": {
- "type": "boolean"
+ "minimumBookingNotice": {
+ "type": "number",
+ "minimum": 0
},
- "requiresConfirmation": {
- "type": "boolean"
+ "beforeEventBuffer": {
+ "type": "number"
},
- "requiresBookerEmailVerification": {
- "type": "boolean"
+ "afterEventBuffer": {
+ "type": "number"
+ },
+ "schedulingType": {
+ "type": "object",
+ "nullable": true
},
"recurringEvent": {
+ "nullable": true,
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/RecurringEvent_2024_06_14"
+ }
+ ]
+ },
+ "metadata": {
"type": "object"
},
+ "requiresConfirmation": {
+ "type": "boolean"
+ },
"price": {
"type": "number"
},
"currency": {
"type": "string"
},
+ "lockTimeZoneToggleOnBookingPage": {
+ "type": "boolean"
+ },
"seatsPerTimeSlot": {
"type": "number",
"nullable": true
},
- "seatsShowAvailabilityCount": {
+ "forwardParamsSuccessRedirect": {
"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"
- }
+ "seatsShowAvailabilityCount": {
+ "type": "boolean",
+ "nullable": true
},
- "owner": {
- "nullable": true,
- "allOf": [
- {
- "$ref": "#/components/schemas/Owner"
- }
- ]
+ "isInstantEvent": {
+ "type": "boolean"
},
"schedule": {
"nullable": true,
"allOf": [
{
- "$ref": "#/components/schemas/Schedule"
+ "$ref": "#/components/schemas/Schedule_2024_06_14"
}
]
},
- "hidden": {
- "type": "boolean"
+ "teamId": {
+ "type": "number",
+ "nullable": true
},
- "assignAllTeamMembers": {
- "type": "boolean"
+ "ownerId": {
+ "type": "number",
+ "nullable": true
},
- "bookerLayouts": {
- "type": "object"
+ "parentEventTypeId": {
+ "type": "number",
+ "nullable": true
},
- "users": {
+ "hosts": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/User"
+ "$ref": "#/components/schemas/Host"
}
},
- "entity": {
- "type": "object"
- },
- "isDynamic": {
+ "assignAllTeamMembers": {
"type": "boolean"
}
},
"required": [
"id",
+ "lengthInMinutes",
"title",
- "description",
"slug",
- "isInstantEvent",
- "length",
+ "description",
"locations",
- "customInputs",
+ "bookingFields",
"disableGuests",
+ "schedulingType",
+ "recurringEvent",
"metadata",
- "lockTimeZoneToggleOnBookingPage",
"requiresConfirmation",
- "requiresBookerEmailVerification",
"price",
"currency",
+ "lockTimeZoneToggleOnBookingPage",
+ "seatsPerTimeSlot",
+ "forwardParamsSuccessRedirect",
+ "successRedirectUrl",
"seatsShowAvailabilityCount",
- "bookingFields",
- "workflows",
- "hosts",
- "owner",
+ "isInstantEvent",
"schedule",
- "hidden",
- "assignAllTeamMembers",
- "users",
- "entity",
- "isDynamic"
+ "hosts"
]
},
- "GetEventTypePublicOutput": {
+ "GetTeamEventTypeOutput": {
"type": "object",
"properties": {
"status": {
@@ -2627,12 +6158,7 @@
]
},
"data": {
- "nullable": true,
- "allOf": [
- {
- "$ref": "#/components/schemas/PublicEventTypeOutput"
- }
- ]
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
}
},
"required": [
@@ -2640,38 +6166,7 @@
"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": {
+ "GetTeamEventTypesOutput": {
"type": "object",
"properties": {
"status": {
@@ -2685,7 +6180,7 @@
"data": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/PublicEventType"
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
}
}
},
@@ -2694,34 +6189,75 @@
"data"
]
},
- "UpdateEventTypeInput": {
+ "UpdateTeamEventTypeInput_2024_06_14": {
"type": "object",
"properties": {
- "length": {
- "type": "number",
- "minimum": 1
+ "lengthInMinutes": {
+ "type": "number"
},
- "slug": {
+ "title": {
"type": "string"
},
- "title": {
+ "slug": {
"type": "string"
},
"description": {
"type": "string"
},
- "hidden": {
+ "locations": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "bookingFields": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "disableGuests": {
"type": "boolean"
},
- "locations": {
+ "slotInterval": {
+ "type": "number"
+ },
+ "minimumBookingNotice": {
+ "type": "number"
+ },
+ "beforeEventBuffer": {
+ "type": "number"
+ },
+ "afterEventBuffer": {
+ "type": "number"
+ },
+ "hosts": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/EventTypeLocation"
+ "type": "string"
}
+ },
+ "assignAllTeamMembers": {
+ "type": "boolean"
}
- }
+ },
+ "required": [
+ "lengthInMinutes",
+ "title",
+ "slug",
+ "description",
+ "locations",
+ "bookingFields",
+ "disableGuests",
+ "slotInterval",
+ "minimumBookingNotice",
+ "beforeEventBuffer",
+ "afterEventBuffer",
+ "hosts",
+ "assignAllTeamMembers"
+ ]
},
- "UpdateEventTypeOutput": {
+ "UpdateTeamEventTypeOutput": {
"type": "object",
"properties": {
"status": {
@@ -2733,7 +6269,17 @@
]
},
"data": {
- "$ref": "#/components/schemas/EventTypeOutput"
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
+ }
+ }
+ ]
}
},
"required": [
@@ -2741,34 +6287,62 @@
"data"
]
},
- "DeleteData": {
+ "DeleteTeamEventTypeOutput": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "OrgTeamMembershipOutputDto": {
"type": "object",
"properties": {
+ "role": {
+ "type": "string",
+ "enum": [
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
+ ]
+ },
"id": {
- "type": "number",
- "example": 1
+ "type": "number"
},
- "length": {
- "type": "number",
- "example": 60
+ "userId": {
+ "type": "number"
+ },
+ "teamId": {
+ "type": "number"
},
- "slug": {
- "type": "string",
- "example": "cooking-class"
+ "accepted": {
+ "type": "boolean"
},
- "title": {
- "type": "string",
- "example": "Learn the secrets of masterchief!"
+ "disableImpersonation": {
+ "type": "boolean"
}
},
"required": [
+ "role",
"id",
- "length",
- "slug",
- "title"
+ "userId",
+ "teamId",
+ "accepted"
]
},
- "DeleteEventTypeOutput": {
+ "OrgTeamMembershipsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
@@ -2780,7 +6354,10 @@
]
},
"data": {
- "$ref": "#/components/schemas/DeleteData"
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
+ }
}
},
"required": [
@@ -2788,18 +6365,7 @@
"data"
]
},
- "AuthUrlData": {
- "type": "object",
- "properties": {
- "authUrl": {
- "type": "string"
- }
- },
- "required": [
- "authUrl"
- ]
- },
- "GcalAuthUrlOutput": {
+ "OrgTeamMembershipOutputResponseDto": {
"type": "object",
"properties": {
"status": {
@@ -2811,7 +6377,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/AuthUrlData"
+ "$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
}
},
"required": [
@@ -2819,50 +6385,58 @@
"data"
]
},
- "GcalSaveRedirectOutput": {
- "type": "object",
- "properties": {
- "url": {
- "type": "string"
- }
- },
- "required": [
- "url"
- ]
- },
- "GcalCheckOutput": {
+ "UpdateOrgTeamMembershipDto": {
"type": "object",
"properties": {
- "status": {
+ "role": {
"type": "string",
- "example": "success",
+ "default": "MEMBER",
"enum": [
- "success",
- "error"
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
]
+ },
+ "accepted": {
+ "type": "boolean",
+ "default": false
+ },
+ "disableImpersonation": {
+ "type": "boolean",
+ "default": false
}
- },
- "required": [
- "status"
- ]
+ }
},
- "ProviderVerifyClientOutput": {
+ "CreateOrgTeamMembershipDto": {
"type": "object",
"properties": {
- "status": {
+ "role": {
"type": "string",
- "example": "success",
+ "default": "MEMBER",
"enum": [
- "success",
- "error"
+ "MEMBER",
+ "OWNER",
+ "ADMIN"
]
+ },
+ "userId": {
+ "type": "number"
+ },
+ "accepted": {
+ "type": "boolean",
+ "default": false
+ },
+ "disableImpersonation": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
- "status"
+ "role",
+ "userId"
]
},
- "ProviderVerifyAccessTokenOutput": {
+ "GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
@@ -2872,13 +6446,17 @@
"success",
"error"
]
+ },
+ "data": {
+ "$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
- "status"
+ "status",
+ "data"
]
},
- "CreateAvailabilityInput": {
+ "CreateAvailabilityInput_2024_04_15": {
"type": "object",
"properties": {
"days": {
@@ -2906,7 +6484,7 @@
"endTime"
]
},
- "CreateScheduleInput": {
+ "CreateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"name": {
@@ -2918,7 +6496,7 @@
"availabilities": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/CreateAvailabilityInput"
+ "$ref": "#/components/schemas/CreateAvailabilityInput_2024_04_15"
}
},
"isDefault": {
@@ -3090,7 +6668,7 @@
"readOnly"
]
},
- "CreateScheduleOutput": {
+ "CreateScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3110,7 +6688,7 @@
"data"
]
},
- "GetDefaultScheduleOutput": {
+ "GetDefaultScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3135,7 +6713,7 @@
"data"
]
},
- "GetScheduleOutput": {
+ "GetScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3155,7 +6733,7 @@
"data"
]
},
- "GetSchedulesOutput": {
+ "GetSchedulesOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3175,7 +6753,7 @@
"data"
]
},
- "UpdateScheduleInput": {
+ "UpdateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"timeZone": {
@@ -3235,7 +6813,7 @@
"schedule"
]
},
- "EventTypeModel": {
+ "EventTypeModel_2024_04_15": {
"type": "object",
"properties": {
"id": {
@@ -3250,7 +6828,50 @@
"id"
]
},
- "ScheduleModel": {
+ "AvailabilityModel_2024_04_15": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number"
+ },
+ "userId": {
+ "type": "number",
+ "nullable": true
+ },
+ "scheduleId": {
+ "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
+ }
+ },
+ "required": [
+ "id",
+ "days"
+ ]
+ },
+ "ScheduleModel_2024_04_15": {
"type": "object",
"properties": {
"id": {
@@ -3269,13 +6890,13 @@
"eventType": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/EventTypeModel"
+ "$ref": "#/components/schemas/EventTypeModel_2024_04_15"
}
},
"availability": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/AvailabilityModel"
+ "$ref": "#/components/schemas/AvailabilityModel_2024_04_15"
}
}
},
@@ -3285,11 +6906,11 @@
"name"
]
},
- "UpdatedScheduleOutput": {
+ "UpdatedScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"schedule": {
- "$ref": "#/components/schemas/ScheduleModel"
+ "$ref": "#/components/schemas/ScheduleModel_2024_04_15"
},
"isDefault": {
"type": "boolean"
@@ -3311,7 +6932,54 @@
"isDefault"
]
},
- "UpdateScheduleOutput": {
+ "UpdateScheduleOutput_2024_04_15": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "$ref": "#/components/schemas/UpdatedScheduleOutput_2024_04_15"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
+ "DeleteScheduleOutput_2024_04_15": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ }
+ },
+ "required": [
+ "status"
+ ]
+ },
+ "AuthUrlData": {
+ "type": "object",
+ "properties": {
+ "authUrl": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "authUrl"
+ ]
+ },
+ "GcalAuthUrlOutput": {
"type": "object",
"properties": {
"status": {
@@ -3323,7 +6991,7 @@
]
},
"data": {
- "$ref": "#/components/schemas/UpdatedScheduleOutput"
+ "$ref": "#/components/schemas/AuthUrlData"
}
},
"required": [
@@ -3331,7 +6999,50 @@
"data"
]
},
- "DeleteScheduleOutput": {
+ "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": {
@@ -4226,6 +7937,9 @@
"responses": {
"$ref": "#/components/schemas/Response"
},
+ "orgSlug": {
+ "type": "string"
+ },
"locationUrl": {
"type": "string"
}
@@ -4267,6 +7981,63 @@
"seatReferenceUid"
]
},
+ "MarkNoShowInput": {
+ "type": "object",
+ "properties": {
+ "noShowHost": {
+ "type": "boolean"
+ },
+ "attendees": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Attendee"
+ }
+ }
+ }
+ },
+ "HandleMarkNoShowData": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "noShowHost": {
+ "type": "boolean"
+ },
+ "messageKey": {
+ "type": "string"
+ },
+ "attendees": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Attendee"
+ }
+ }
+ },
+ "required": [
+ "message"
+ ]
+ },
+ "MarkNoShowOutput": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": [
+ "success",
+ "error"
+ ]
+ },
+ "data": {
+ "$ref": "#/components/schemas/HandleMarkNoShowData"
+ }
+ },
+ "required": [
+ "status",
+ "data"
+ ]
+ },
"ReserveSlotInput": {
"type": "object",
"properties": {}
diff --git a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts
new file mode 100644
index 00000000000000..ab10f52ec9bb6a
--- /dev/null
+++ b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts
@@ -0,0 +1,28 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { TestingModule } from "@nestjs/testing";
+import { randomBytes, createHash } from "crypto";
+
+export class ApiKeysRepositoryFixture {
+ 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 createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
+ const keyString = randomBytes(16).toString("hex");
+ const apiKey = await this.prismaWriteClient.apiKey.create({
+ data: {
+ userId,
+ teamId,
+ hashedKey: createHash("sha256").update(keyString).digest("hex"),
+ expiresAt: expiresAt,
+ },
+ });
+
+ return { apiKey, keyString };
+ }
+}
diff --git a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts
new file mode 100644
index 00000000000000..472623e5644e61
--- /dev/null
+++ b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts
@@ -0,0 +1,25 @@
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
+import { TestingModule } from "@nestjs/testing";
+
+export class PlatformBillingRepositoryFixture {
+ 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(orgId: number) {
+ const randomString = Date.now().toString(36);
+ return this.prismaWriteClient.platformBilling.create({
+ data: {
+ id: orgId,
+ customerId: `cus_123_${randomString}`,
+ subscriptionId: `sub_123_${randomString}`,
+ plan: "STARTER",
+ },
+ });
+ }
+}
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
index 46e3cc0c032970..d802800a649e3e 100644
--- a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts
+++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts
@@ -1,4 +1,4 @@
-import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
+import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/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";
@@ -21,7 +21,18 @@ export class EventTypesRepositoryFixture {
});
}
- async create(data: Pick, userId: number) {
+ async getAllTeamEventTypes(teamId: number) {
+ return this.prismaWriteClient.eventType.findMany({
+ where: {
+ teamId,
+ },
+ });
+ }
+
+ async create(
+ data: Pick,
+ userId: number
+ ) {
return this.prismaWriteClient.eventType.create({
data: {
...data,
diff --git a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts
index 21ee461df0f66e..5cf8fd41f81a1e 100644
--- a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts
+++ b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts
@@ -24,6 +24,10 @@ export class MembershipRepositoryFixture {
return this.primaReadClient.membership.findFirst({ where: { id: membershipId } });
}
+ async getUserMembershipByTeamId(userId: User["id"], teamId: Team["id"]) {
+ return this.primaReadClient.membership.findFirst({ where: { teamId, userId } });
+ }
+
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 },
diff --git a/apps/api/v2/test/fixtures/repository/profiles.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/profiles.repository.fixture.ts
new file mode 100644
index 00000000000000..d31e6a7939f46f
--- /dev/null
+++ b/apps/api/v2/test/fixtures/repository/profiles.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 } from "@prisma/client";
+
+export class ProfileRepositoryFixture {
+ 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(profileId: number) {
+ return this.primaReadClient.profile.findFirst({ where: { id: profileId } });
+ }
+
+ async create(data: Prisma.ProfileCreateInput) {
+ return this.prismaWriteClient.profile.create({ data });
+ }
+
+ async delete(profileId: number) {
+ return this.prismaWriteClient.profile.delete({ where: { id: profileId } });
+ }
+}
diff --git a/apps/api/v2/test/mocks/access-token-mock.strategy.ts b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts
similarity index 79%
rename from apps/api/v2/test/mocks/access-token-mock.strategy.ts
rename to apps/api/v2/test/mocks/api-auth-mock.strategy.ts
index f7c89a2beb8ee0..9a7e760740b136 100644
--- a/apps/api/v2/test/mocks/access-token-mock.strategy.ts
+++ b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts
@@ -4,14 +4,14 @@ import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
@Injectable()
-export class AccessTokenMockStrategy extends PassportStrategy(BaseStrategy, "access-token") {
+export class ApiAuthMockStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
constructor(private readonly email: string, private readonly usersRepository: UsersRepository) {
super();
}
async authenticate() {
try {
- const user = await this.usersRepository.findByEmail(this.email);
+ const user = await this.usersRepository.findByEmailWithProfile(this.email);
if (!user) {
throw new Error("User with the provided ID not found");
}
diff --git a/apps/api/v2/test/mocks/calendars-service-mock.ts b/apps/api/v2/test/mocks/calendars-service-mock.ts
new file mode 100644
index 00000000000000..9730237aa5f38d
--- /dev/null
+++ b/apps/api/v2/test/mocks/calendars-service-mock.ts
@@ -0,0 +1,58 @@
+import { CalendarsService } from "@/ee/calendars/services/calendars.service";
+
+export class CalendarsServiceMock {
+ async getCalendars() {
+ return {
+ connectedCalendars: [
+ {
+ integration: {
+ installed: false,
+ type: "google_calendar",
+ title: "",
+ name: "",
+ description: "",
+ variant: "calendar",
+ slug: "",
+ locationOption: null,
+ categories: ["calendar"],
+ logo: "",
+ publisher: "",
+ url: "",
+ email: "",
+ },
+ credentialId: 1,
+ error: { message: "" },
+ },
+ {
+ integration: {
+ installed: false,
+ type: "office365_calendar",
+ title: "",
+ name: "",
+ description: "",
+ variant: "calendar",
+ slug: "",
+ locationOption: null,
+ categories: ["calendar"],
+ logo: "",
+ publisher: "",
+ url: "",
+ email: "",
+ },
+ credentialId: 2,
+ error: { message: "" },
+ },
+ ],
+ destinationCalendar: {
+ name: "destinationCalendar",
+ eventTypeId: 1,
+ credentialId: 1,
+ primaryEmail: "primaryEmail",
+ integration: "google_calendar",
+ externalId: "externalId",
+ userId: null,
+ id: 0,
+ },
+ } satisfies Awaited>;
+ }
+}
diff --git a/apps/api/v2/test/mocks/mock-redis-service.ts b/apps/api/v2/test/mocks/mock-redis-service.ts
new file mode 100644
index 00000000000000..1239c3c5403890
--- /dev/null
+++ b/apps/api/v2/test/mocks/mock-redis-service.ts
@@ -0,0 +1,15 @@
+import { RedisService } from "@/modules/redis/redis.service";
+import { Provider } from "@nestjs/common";
+
+export const MockedRedisService = {
+ provide: RedisService,
+ useValue: {
+ redis: {
+ get: jest.fn(),
+ hgetall: jest.fn(),
+ set: jest.fn(),
+ hmset: jest.fn(),
+ expireat: jest.fn(),
+ },
+ },
+} as Provider;
diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts
index a0e9bce9055a47..e7a2393878329e 100644
--- a/apps/api/v2/test/setEnvVars.ts
+++ b/apps/api/v2/test/setEnvVars.ts
@@ -1,13 +1,21 @@
import type { Environment } from "@/env";
const env: Partial> = {
+ API_URL: "http://localhost",
API_PORT: "5555",
+ DATABASE_URL: "postgresql://postgres:@localhost:5450/calendso",
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",
+ REDIS_URL: "redis://localhost:6379",
+ STRIPE_API_KEY: "sk_test_51J4",
+ STRIPE_WEBHOOK_SECRET: "whsec_51J4",
+ IS_E2E: true,
+ API_KEY_PREFIX: "cal_test_",
+ GET_LICENSE_KEY_URL: " https://console.cal.com/api/license",
+ CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7",
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
diff --git a/apps/api/v2/test/utils/withAccessTokenAuth.ts b/apps/api/v2/test/utils/withAccessTokenAuth.ts
deleted file mode 100644
index 809de3c683b873..00000000000000
--- a/apps/api/v2/test/utils/withAccessTokenAuth.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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/withApiAuth.ts b/apps/api/v2/test/utils/withApiAuth.ts
new file mode 100644
index 00000000000000..bc5a4904b8f53f
--- /dev/null
+++ b/apps/api/v2/test/utils/withApiAuth.ts
@@ -0,0 +1,10 @@
+import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
+import { UsersRepository } from "@/modules/users/users.repository";
+import { TestingModuleBuilder } from "@nestjs/testing";
+import { ApiAuthMockStrategy } from "test/mocks/api-auth-mock.strategy";
+
+export const withApiAuth = (email: string, module: TestingModuleBuilder) =>
+ module.overrideProvider(ApiAuthStrategy).useFactory({
+ factory: (usersRepository: UsersRepository) => new ApiAuthMockStrategy(email, usersRepository),
+ inject: [UsersRepository],
+ });
diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json
index 3e738f626136f8..384837665690b1 100644
--- a/apps/api/v2/tsconfig.json
+++ b/apps/api/v2/tsconfig.json
@@ -14,7 +14,10 @@
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"],
- "@prisma/client/*": ["@calcom/prisma/client/*"]
+ "@prisma/client/*": ["@calcom/prisma/client/*"],
+ "@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
+ "@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
+ "@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -23,6 +26,13 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
- "exclude": ["./dist", "next-i18next.config.js"],
+ "watchOptions": {
+ "watchFile": "fixedPollingInterval",
+ "watchDirectory": "useFsEvents",
+ "fallbackPolling": "dynamicPriority",
+ "synchronousWatchDirectory": true,
+ "excludeDirectories": ["**/node_modules", "dist"]
+ },
+ "exclude": ["./dist", "./node_modules", "next-i18next.config.js"],
"include": ["./**/*.ts", "../../../packages/types/*.d.ts"]
}
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 520a11d78c396a..3f657d09b55ab9 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -68,3 +68,5 @@ public/embed
# Copied app-store images
public/app-store
+
+certificates
\ No newline at end of file
diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md
index 4fc3c8a4f6c571..bfb263520b62ae 100644
--- a/apps/web/CHANGELOG.md
+++ b/apps/web/CHANGELOG.md
@@ -1,5 +1,14 @@
# @calcom/web
+## 4.0.8
+
+### Patch Changes
+
+- Updated dependencies
+ - @calcom/embed-core@1.5.0
+ - @calcom/embed-react@1.5.0
+ - @calcom/embed-snippet@1.3.0
+
## 3.9.9
### Patch Changes
diff --git a/apps/web/app/WithEmbedSSR.tsx b/apps/web/app/WithEmbedSSR.tsx
index c19ead200d5cc3..a7d5387425a7d7 100644
--- a/apps/web/app/WithEmbedSSR.tsx
+++ b/apps/web/app/WithEmbedSSR.tsx
@@ -3,7 +3,7 @@ import { isNotFoundError } from "next/dist/client/components/not-found";
import { getURLFromRedirectError, isRedirectError } from "next/dist/client/components/redirect";
import { notFound, redirect } from "next/navigation";
-import { WEBAPP_URL } from "@calcom/lib/constants";
+import { WebAppURL } from "@calcom/lib/WebAppURL";
export type EmbedProps = {
isEmbed?: boolean;
@@ -28,7 +28,7 @@ export default function withEmbedSsrAppDir>(
let urlPrefix = "";
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
- const destinationUrlObj = new URL(destinationUrl, WEBAPP_URL);
+ const destinationUrlObj = new WebAppURL(destinationUrl);
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
diff --git a/apps/web/app/future/event-types/page.tsx b/apps/web/app/future/event-types/page.tsx
index b277302dbae5e3..2dc466826cc0b2 100644
--- a/apps/web/app/future/event-types/page.tsx
+++ b/apps/web/app/future/event-types/page.tsx
@@ -1,6 +1,9 @@
+import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
+import { getServerSideProps } from "@lib/event-types/getServerSideProps";
+
import EventTypes from "~/event-types/views/event-types-listing-view";
export const generateMetadata = async () =>
@@ -9,4 +12,6 @@ export const generateMetadata = async () =>
(t) => t("event_types_page_subtitle")
);
-export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
+const getData = withAppDirSsr(getServerSideProps);
+
+export default WithLayout({ getLayout: null, getData, Page: EventTypes })<"P">;
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 0733f9c997d1f2..36acecac5f7294 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -85,7 +85,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
`}
{
- const i18n = await getTranslations(context);
-
- return {
- i18n,
- };
+import React from "react";
+
+const NotFound = () => {
+ return (
+
+
404 - Page Not Found
+
Sorry, the page you are looking for does not exist.
+
+ );
};
-export const dynamic = "force-static";
-
-export default WithLayout({ getLayout: null, getData, Page: NotFoundPage });
+export default NotFound;
diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx
index 4ff36ca97a8997..ac556f7045ca92 100644
--- a/apps/web/components/apps/AppPage.tsx
+++ b/apps/web/components/apps/AppPage.tsx
@@ -1,12 +1,17 @@
import Link from "next/link";
+import { useRouter } from "next/navigation";
import type { IframeHTMLAttributes } from "react";
import React, { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
+import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
+import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
+import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import classNames from "@calcom/lib/classNames";
-import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
+import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants";
+import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App as AppType } from "@calcom/types/App";
@@ -70,18 +75,57 @@ export const AppPage = ({
paid,
}: AppPageProps) => {
const { t, i18n } = useLocale();
+ const router = useRouter();
+ const searchParams = useCompatSearchParams();
+
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
+ setIsLoading(false);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
+ setIsLoading(false);
},
});
+ /**
+ * @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
+ * Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
+ * which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
+ */
+ const [isLoading, setIsLoading] = useState(mutation.isPending);
+
+ const handleAppInstall = () => {
+ setIsLoading(true);
+ if (isConferencing(categories)) {
+ mutation.mutate({
+ type,
+ variant,
+ slug,
+ returnTo:
+ WEBAPP_URL +
+ getAppOnboardingUrl({
+ slug,
+ step: AppOnboardingSteps.EVENT_TYPES_STEP,
+ }),
+ });
+ } else if (
+ !doesAppSupportTeamInstall({
+ appCategories: categories,
+ concurrentMeetings: concurrentMeetings,
+ isPaid: !!paid,
+ })
+ ) {
+ mutation.mutate({ type });
+ } else {
+ router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
+ }
+ };
+
const priceInDollar = Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
@@ -118,6 +162,11 @@ export const AppPage = ({
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
// Such apps, can only be installed once.
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
+ useEffect(() => {
+ if (searchParams?.get("defaultInstall") === "true") {
+ mutation.mutate({ type, variant, slug, defaultInstall: true });
+ }
+ }, []);
return (
@@ -210,22 +259,12 @@ export const AppPage = ({
props = {
...props,
onClick: () => {
- mutation.mutate({ type, variant, slug });
+ handleAppInstall();
},
- loading: mutation.isPending,
+ loading: isLoading,
};
}
- return (
-
- );
+ return
;
}}
/>
)}
@@ -249,21 +288,13 @@ export const AppPage = ({
props = {
...props,
onClick: () => {
- mutation.mutate({ type, variant, slug });
+ handleAppInstall();
},
- loading: mutation.isPending,
+ loading: isLoading,
};
}
return (
-
+
);
}}
/>
diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx
index 274f9dfcf6c60a..572680ba9f8dfc 100644
--- a/apps/web/components/apps/InstallAppButtonChild.tsx
+++ b/apps/web/components/apps/InstallAppButtonChild.tsx
@@ -1,53 +1,21 @@
-import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
-import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
-import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
-import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
-import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppFrontendPayload } from "@calcom/types/App";
import type { ButtonProps } from "@calcom/ui";
-import {
- Avatar,
- Button,
- Dropdown,
- DropdownItem,
- DropdownMenuContent,
- DropdownMenuLabel,
- DropdownMenuPortal,
- DropdownMenuTrigger,
- showToast,
-} from "@calcom/ui";
+import { Button } from "@calcom/ui";
export const InstallAppButtonChild = ({
- userAdminTeams,
- addAppMutationInput,
- appCategories,
multiInstall,
credentials,
- concurrentMeetings,
paid,
...props
}: {
- userAdminTeams?: UserAdminTeams;
- addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string };
- appCategories: string[];
multiInstall?: boolean;
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
- concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
} & ButtonProps) => {
const { t } = useLocale();
- const mutation = useAddAppMutation(null, {
- onSuccess: (data) => {
- if (data?.setupPending) return;
- showToast(t("app_successfully_installed"), "success");
- },
- onError: (error) => {
- if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
- },
- });
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
// Paid apps don't support team installs at the moment
@@ -65,83 +33,14 @@ export const InstallAppButtonChild = ({
);
}
- if (
- !userAdminTeams?.length ||
- !doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
- ) {
- return (
-
- );
- }
-
return (
-
-
-
-
-
- {
- if (mutation.isPending) event.preventDefault();
- }}>
- {mutation.isPending && (
-
-
-
- )}
- {t("install_app_on")}
- {userAdminTeams.map((team) => {
- const isInstalled =
- credentials &&
- credentials.some((credential) =>
- credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
- );
-
- return (
-
- }
- onClick={() => {
- mutation.mutate(
- team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
- );
- }}>
-
- {t(team.name)} {isInstalled && `(${t("installed")})`}
-
-
- );
- })}
-
-
-
+
);
};
diff --git a/apps/web/components/apps/installation/AccountsStepCard.tsx b/apps/web/components/apps/installation/AccountsStepCard.tsx
new file mode 100644
index 00000000000000..f14d5c1bb690ab
--- /dev/null
+++ b/apps/web/components/apps/installation/AccountsStepCard.tsx
@@ -0,0 +1,105 @@
+import type { FC } from "react";
+import React, { useState } from "react";
+
+import { classNames } from "@calcom/lib";
+import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import type { Team, User } from "@calcom/prisma/client";
+import { Avatar, StepCard } from "@calcom/ui";
+
+type AccountSelectorProps = {
+ avatar?: string;
+ name: string;
+ alreadyInstalled: boolean;
+ onClick: () => void;
+ loading: boolean;
+ testId: string;
+};
+
+const AccountSelector: FC
= ({
+ avatar,
+ alreadyInstalled,
+ name,
+ onClick,
+ loading,
+ testId,
+}) => {
+ const { t } = useLocale();
+ const [selected, setSelected] = useState(false);
+ return (
+ {
+ if (!alreadyInstalled && !loading) {
+ setSelected(true);
+ onClick();
+ }
+ }}>
+
+
+ {name}
+ {alreadyInstalled ? {t("already_installed")} : ""}
+
+
+ );
+};
+
+export type PersonalAccountProps = Pick & { alreadyInstalled: boolean };
+
+export type TeamsProp = (Pick & {
+ alreadyInstalled: boolean;
+})[];
+
+type AccountStepCardProps = {
+ teams?: TeamsProp;
+ personalAccount: PersonalAccountProps;
+ onSelect: (id?: number) => void;
+ loading: boolean;
+ installableOnTeams: boolean;
+};
+
+export const AccountsStepCard: FC = ({
+ teams,
+ personalAccount,
+ onSelect,
+ loading,
+ installableOnTeams,
+}) => {
+ const { t } = useLocale();
+ return (
+
+ {t("install_app_on")}
+
+
onSelect()}
+ loading={loading}
+ />
+ {installableOnTeams &&
+ teams?.map((team) => (
+ onSelect(team.id)}
+ loading={loading}
+ />
+ ))}
+
+
+ );
+};
diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx
new file mode 100644
index 00000000000000..a6f762baaee024
--- /dev/null
+++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx
@@ -0,0 +1,226 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
+import { X } from "lucide-react";
+import type { Dispatch, SetStateAction } from "react";
+import type { FC } from "react";
+import React, { forwardRef, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import type { LocationObject } from "@calcom/core/location";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import type { AppCategories } from "@calcom/prisma/enums";
+import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
+import { Button, Form } from "@calcom/ui";
+
+import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
+import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
+
+import { locationsResolver } from "~/event-types/views/event-types-single-view";
+
+export type TFormType = {
+ id: number;
+ metadata: z.infer;
+ locations: LocationObject[];
+ bookingFields: z.infer;
+ seatsPerTimeSlot: number | null;
+};
+
+export type ConfigureStepCardProps = {
+ slug: string;
+ userName: string;
+ categories: AppCategories[];
+ credentialId?: number;
+ loading?: boolean;
+ isConferencing: boolean;
+ formPortalRef: React.RefObject;
+ eventTypes: TEventType[] | undefined;
+ setConfigureStep: Dispatch>;
+ handleSetUpLater: () => void;
+};
+
+type EventTypeAppSettingsFormProps = Pick<
+ ConfigureStepCardProps,
+ "slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing"
+> & {
+ eventType: TEventType;
+ handleDelete: () => void;
+ onSubmit: ({
+ locations,
+ bookingFields,
+ metadata,
+ }: {
+ metadata?: z.infer;
+ bookingFields?: z.infer;
+ locations?: LocationObject[];
+ }) => void;
+};
+
+const EventTypeAppSettingsForm = forwardRef(
+ function EventTypeAppSettingsForm(props, ref) {
+ const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
+ const { t } = useLocale();
+
+ const formMethods = useForm({
+ defaultValues: {
+ id: eventType.id,
+ metadata: eventType?.metadata ?? undefined,
+ locations: eventType?.locations ?? undefined,
+ bookingFields: eventType?.bookingFields ?? undefined,
+ seatsPerTimeSlot: eventType?.seatsPerTimeSlot ?? undefined,
+ },
+ resolver: zodResolver(
+ z.object({
+ locations: locationsResolver(t),
+ })
+ ),
+ });
+
+ return (
+
+ );
+ }
+);
+
+export const ConfigureStepCard: FC = ({
+ loading,
+ formPortalRef,
+ eventTypes,
+ setConfigureStep,
+ handleSetUpLater,
+ ...props
+}) => {
+ const { t } = useLocale();
+ const { control, getValues } = useFormContext();
+ const { fields, update } = useFieldArray({
+ control,
+ name: "eventTypes",
+ keyName: "fieldId",
+ });
+
+ const submitRefs = useRef>>([]);
+ submitRefs.current = fields.map(
+ (_ref, index) => (submitRefs.current[index] = React.createRef())
+ );
+ const mainForSubmitRef = useRef(null);
+ const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
+ fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
+ );
+ const [submit, setSubmit] = useState(false);
+ const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
+
+ useEffect(() => {
+ setUpdatedEventTypesStatus((prev) =>
+ prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
+ );
+ if (!fields.some((field) => field.selected)) {
+ setConfigureStep(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fields]);
+
+ useEffect(() => {
+ if (submit && allUpdated && mainForSubmitRef.current) {
+ mainForSubmitRef.current?.click();
+ setSubmit(false);
+ }
+ }, [submit, allUpdated, getValues, mainForSubmitRef]);
+
+ return (
+ formPortalRef?.current &&
+ createPortal(
+
+
+ {fields.map((field, index) => {
+ return (
+ field.selected && (
+