diff --git a/.env.example b/.env.example index dcc04ef5d7ddc1..003c195a2230f8 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ # instructions. This environment variable is deprecated although still supported for backward compatibility. # @see https://console.cal.com CALCOM_LICENSE_KEY= +# Signature token for the Cal.com License API (used for self-hosted integrations) +# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key +CAL_SIGNATURE_TOKEN= +# The route to the Cal.com License API +CALCOM_PRIVATE_API_ROUTE= # *********************************************************************************************************** # - DATABASE ************************************************************************************************ @@ -359,3 +364,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/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index c118b59c887b8f..1849e475761152 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -8,7 +8,7 @@ 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 diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml index 6931d193e2ae31..3e4ff42ad7f198 100644 --- a/.github/actions/cache-db/action.yml +++ b/.github/actions/cache-db/action.yml @@ -12,7 +12,7 @@ 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') }} 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 4dfbf2e77b5a2d..3fdd51d512c038 100644 --- a/.github/workflows/api-v1-production-build.yml +++ b/.github/workflows/api-v1-production-build.yml @@ -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@v3 + 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 index 31cb8a23e0df20..da2f17c40a41b6 100644 --- a/.github/workflows/api-v2-production-build.yml +++ b/.github/workflows/api-v2-production-build.yml @@ -30,13 +30,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 + - name: Cache API v2 production build + uses: buildjet/cache@v3 + 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" - yarn workspace @calcom/api-v2 run generate-schemas - rm -rf apps/api/v2/node_modules - yarn install - yarn workspace @calcom/api-v2 run build + 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 5940ab301a5629..3b0f51ed1fbfa8 100644 --- a/.github/workflows/cache-clean.yml +++ b/.github/workflows/cache-clean.yml @@ -9,7 +9,7 @@ jobs: runs-on: buildjet-2vcpu-ubuntu-2204 steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Cleanup run: | 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 c1dfa12661cb6c..2838a454f97f50 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -1,4 +1,4 @@ -name: E2E App-Store Apps Tests +name: E2E App Store Tests on: workflow_call: env: @@ -41,7 +41,7 @@ env: 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: @@ -70,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 @@ -93,10 +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 }} + 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 611b06b51b1654..efd58e9f96a4b1 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -41,7 +41,7 @@ env: 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: @@ -62,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 @@ -79,11 +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 - 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 b70cd26bdfad52..149f774968fe65 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -41,7 +41,7 @@ env: 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: @@ -70,26 +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 }} + 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 74d23c56e5a336..b0084e82fdd7a6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,4 +1,4 @@ -name: E2E tests +name: E2E on: workflow_call: env: @@ -41,7 +41,7 @@ env: jobs: e2e: timeout-minutes: 20 - name: E2E tests (${{ matrix.shard }}/${{ strategy.job-total }}) + name: E2E (${{ matrix.shard }}/${{ strategy.job-total }}) runs-on: buildjet-8vcpu-ubuntu-2204 services: postgres: @@ -71,13 +71,13 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7] + 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 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000000000..d2a7aefdb86f9c --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,90 @@ +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_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: + integration: + timeout-minutes: 20 + name: Integration + runs-on: buildjet-8vcpu-ubuntu-2204 + 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 + 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: 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 + - 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 bd0e0c1ec8ff79..823063321f8096 100644 --- a/.github/workflows/nextjs-bundle-analysis.yml +++ b/.github/workflows/nextjs-bundle-analysis.yml @@ -55,7 +55,7 @@ jobs: if: always() 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.yml b/.github/workflows/pr.yml index b29a90f65b97d3..e8099fae9231e8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,7 +4,6 @@ on: pull_request_target: branches: - main - merge_group: workflow_dispatch: concurrency: @@ -20,9 +19,9 @@ jobs: outputs: has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }} 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: | @@ -35,13 +34,6 @@ jobs: 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] @@ -49,64 +41,22 @@ jobs: uses: ./.github/workflows/lint.yml secrets: inherit - build-api-v1: - name: Production builds + unit-test: + name: Tests needs: [changes] if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} - uses: ./.github/workflows/api-v1-production-build.yml - secrets: inherit - - build-api-v2: - name: Production builds - needs: [changes] - if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} - uses: ./.github/workflows/api-v2-production-build.yml + uses: ./.github/workflows/unit-tests.yml secrets: inherit - build: - name: Production builds + integration-test: + name: Tests needs: [changes] if: ${{ 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, build-api-v1, build-api-v2] - if: ${{ 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, build-api-v1, build-api-v2] - if: ${{ 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, build-api-v1, build-api-v2] - if: ${{ 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, build-api-v1, build-api-v2] - if: ${{ 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' }} - uses: ./.github/workflows/nextjs-bundle-analysis.yml + uses: ./.github/workflows/integration-tests.yml secrets: inherit required: - needs: [changes, lint, type-check, test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store] + needs: [changes, lint, type-check, unit-test, integration-test] if: always() runs-on: buildjet-2vcpu-ubuntu-2204 steps: diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 64a2a345f09b63..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: | diff --git a/.github/workflows/production-build-without-database.yml b/.github/workflows/production-build-without-database.yml index c50c6734655ac9..51949d8fd3a23b 100644 --- a/.github/workflows/production-build-without-database.yml +++ b/.github/workflows/production-build-without-database.yml @@ -40,7 +40,7 @@ jobs: 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 abd06ff3a08b88..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-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..ece744a7957547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/apps/api/v1/instrumentation.ts b/apps/api/v1/instrumentation.ts new file mode 100644 index 00000000000000..f3f5932bbd9d2f --- /dev/null +++ b/apps/api/v1/instrumentation.ts @@ -0,0 +1,17 @@ +import * as Sentry from "@sentry/nextjs"; + +export function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + // reduce sample rate to 10% on production + tracesSampleRate: process.env.NODE_ENV !== "production" ? 1.0 : 0.1, + }); + } + + if (process.env.NEXT_RUNTIME === "edge") { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + }); + } +} 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 65f73fe8de98be..e9dda7865bef6a 100644 --- a/apps/api/v1/lib/validations/booking.ts +++ b/apps/api/v1/lib/validations/booking.ts @@ -55,7 +55,7 @@ export const schemaBookingReadPublic = Booking.extend({ timeZone: true, locale: true, }) - .optional(), + .nullish(), payment: z .array( _PaymentModel.pick({ 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/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..3595f78ec9783e 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"; @@ -108,7 +113,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 +170,20 @@ function buildWhereClause( }; } -async function handler(req: NextApiRequest) { - const { userId, isAdmin } = req; - +export async function handler(req: NextApiRequest) { + const { + userId, + isSystemWideAdmin, + isOrganizationOwnerOrAdmin, + pagination: { take, skip }, + } = req; const { dateFrom, dateTo } = 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 +199,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 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); - 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, [], []); + 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 +233,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, [], []); } @@ -225,4 +255,53 @@ async function handler(req: NextApiRequest) { 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/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 14b947ce0bf27d..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: { 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..4fe81569bf1170 100644 --- a/apps/api/v1/pages/api/invites/_post.ts +++ b/apps/api/v1/pages/api/invites/_post.ts @@ -58,10 +58,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]/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/.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/package.json b/apps/api/v2/package.json index 512e7ad030c10c..17a36adcb1f34c 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -9,8 +9,8 @@ "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: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/apps/api/v2/src/main.js", @@ -25,11 +25,13 @@ }, "dependencies": { "@calcom/platform-constants": "*", - "@calcom/platform-libraries": "*", + "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", + "@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4", "@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", @@ -38,8 +40,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^5.1.2", - "@sentry/node": "^7.86.0", - "@sentry/tracing": "^7.86.0", + "@sentry/node": "^8.8.0", "body-parser": "^1.20.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -55,6 +56,7 @@ "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", diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 7b42702be6d742..ea4ab52594484d 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -50,8 +50,6 @@ import { AppController } from "./app.controller"; EndpointsModule, AuthModule, JwtModule, - //register prefix for all routes in EndpointsModule - RouterModule.register([{ path: "/v2", module: EndpointsModule }]), ], controllers: [AppController], providers: [ diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts index eb798b2cc5e829..79fd943efcc7e7 100644 --- a/apps/api/v2/src/app.ts +++ b/apps/api/v2/src/app.ts @@ -1,26 +1,43 @@ -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, ValidationPipe, VersioningType } from "@nestjs/common"; -import { HttpAdapterHost } from "@nestjs/core"; +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; + console.log("asap header headerVersion", headerVersion); + if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) { + console.log("asap return header headerVersion", headerVersion); + return headerVersion; + } + return VERSION_2024_04_15; + }, + defaultVersion: VERSION_2024_04_15, }); app.use(helmet()); @@ -28,7 +45,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 +71,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/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts index 891a8e7b62a700..52acb3970d0429 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"; @@ -20,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; -import { handleNewBooking } from "@calcom/platform-libraries"; +import { handleNewBooking } from "@calcom/platform-libraries-0.0.2"; 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"; @@ -43,7 +42,7 @@ describe("Bookings Endpoints", () => { const moduleRef = await withAccessTokenAuth( 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, 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 0453964828cc01..32b48bb6a6542c 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -2,6 +2,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input"; import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.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"; @@ -37,14 +38,14 @@ import { getBookingInfo, handleCancelBooking, getBookingForReschedule, -} from "@calcom/platform-libraries"; +} from "@calcom/platform-libraries-0.0.2"; import { handleNewBooking, BookingResponse, HttpError, handleNewRecurringBooking, handleInstantMeeting, -} from "@calcom/platform-libraries"; +} from "@calcom/platform-libraries-0.0.2"; import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -72,8 +73,8 @@ const DEFAULT_PLATFORM_PARAMS = { }; @Controller({ - path: "/bookings", - version: "2", + path: "/v2/bookings", + version: API_VERSIONS_VALUES, }) @UseGuards(PermissionsGuard) @DocsTags("Bookings") 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..138215b8d5d999 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.2"; export class CreateRecurringBookingInput extends CreateBookingInput { @IsBoolean() 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..26b4305f433e02 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts @@ -0,0 +1,211 @@ +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 { + 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"; + +class CalendarsServiceMock { + async getCalendars() { + return { + connectedCalendars: [ + { + integration: { + type: "google_calendar", + }, + }, + ], + }; + } +} + +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, + }) + .overrideProvider(CalendarsService) + .useClass(CalendarsServiceMock) + .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(); + }); + + 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..4ea4ec3b69218e --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts @@ -0,0 +1,12 @@ +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 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..17e39fc9b2e381 100644 --- a/apps/api/v2/src/ee/calendars/calendars.module.ts +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -1,13 +1,25 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; 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, + 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..995492fb64952c 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,48 @@ import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { 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 { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { Controller, Get, UseGuards, Query } from "@nestjs/common"; +import { + Controller, + Get, + UseGuards, + Query, + HttpStatus, + HttpCode, + Req, + Param, + Headers, + Redirect, + BadRequestException, +} from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; +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 } 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 + ) {} + @UseGuards(AccessTokenGuard) @Get("/busy-times") async getBusyTimes( @Query() queryParams: CalendarBusyTimesInput, @@ -47,6 +71,7 @@ export class CalendarsController { } @Get("/") + @UseGuards(AccessTokenGuard) async getCalendars(@GetUser("id") userId: number): Promise { const calendars = await this.calendarsService.getCalendars(userId); @@ -55,4 +80,74 @@ export class CalendarsController { data: calendars, }; } + + @UseGuards(AccessTokenGuard) + @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(", ") + ); + } + } + + @Get("/:calendar/check") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard, 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); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } } 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..37b7ed44a12dc9 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,27 @@ 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 } from "@calcom/platform-libraries-0.0.2"; +import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2"; 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 +117,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/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts index 870aed625ac6dd..30966a5e0c5fb8 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts @@ -2,6 +2,8 @@ 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 { Editable } from "@/ee/event-types/inputs/enums/editable"; +import { BaseField } from "@/ee/event-types/inputs/enums/field-type"; 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"; @@ -23,7 +25,12 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries"; +import { + EventTypesByViewer, + EventTypesPublic, + eventTypeBookingFields, + eventTypeLocations, +} from "@calcom/platform-libraries-0.0.2"; import { ApiSuccessResponse } from "@calcom/platform-types"; describe("Event types Endpoints", () => { @@ -132,6 +139,10 @@ describe("Event types Endpoints", () => { length: 60, hidden: false, disableGuests: true, + slotInterval: 15, + afterEventBuffer: 5, + beforeEventBuffer: 10, + minimumBookingNotice: 120, locations: [ { type: "Online", @@ -150,6 +161,11 @@ describe("Event types Endpoints", () => { 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; }); }); @@ -160,6 +176,10 @@ describe("Event types Endpoints", () => { const body: UpdateEventTypeInput = { title: newTitle, disableGuests: false, + slotInterval: 30, + afterEventBuffer: 10, + beforeEventBuffer: 15, + minimumBookingNotice: 240, }; return request(app.getHttpServer()) @@ -170,8 +190,98 @@ describe("Event types Endpoints", () => { 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 = { + 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 = { + 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 = { + 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; }); }); diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts index 557f55809bc7b9..319a7868b2429d 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts @@ -1,4 +1,5 @@ import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { EventTypeIdParams } from "@/ee/event-types/inputs/event-type-id.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"; @@ -9,6 +10,7 @@ import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-ty import { GetEventTypesData, 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 { 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"; @@ -29,17 +31,18 @@ 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 } from "@calcom/platform-libraries-0.0.2"; +import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2"; import { PrismaClient } from "@calcom/prisma"; @Controller({ - path: "event-types", - version: "2", + path: "/v2/event-types", + version: API_VERSIONS_VALUES, }) @UseGuards(PermissionsGuard) @DocsTags("Event types") @@ -68,7 +71,9 @@ export class EventTypesController { @Permissions([EVENT_TYPE_READ]) @UseGuards(AccessTokenGuard) async getEventType( - @Param("eventTypeId") eventTypeId: string, + @Param() params: EventTypeIdParams, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @GetUser() user: UserWithProfile ): Promise { const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId)); @@ -144,7 +149,8 @@ export class EventTypesController { @UseGuards(AccessTokenGuard) @HttpCode(HttpStatus.OK) async updateEventType( - @Param("eventTypeId") eventTypeId: number, + @Param() params: EventTypeIdParams, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, @Body() body: UpdateEventTypeInput, @GetUser() user: UserWithProfile ): Promise { @@ -160,7 +166,8 @@ export class EventTypesController { @Permissions([EVENT_TYPE_WRITE]) @UseGuards(AccessTokenGuard) async deleteEventType( - @Param("eventTypeId") eventTypeId: number, + @Param() params: EventTypeIdParams, + @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.repository.ts b/apps/api/v2/src/ee/event-types/event-types.repository.ts index cc696cdd12a141..c879aa080e9502 100644 --- a/apps/api/v2/src/ee/event-types/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types.repository.ts @@ -4,7 +4,7 @@ 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.2"; import type { PrismaClient } from "@calcom/prisma"; @Injectable() diff --git a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts index fb5b380164ea2e..ca9ceb04dbd526 100644 --- a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts +++ b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts @@ -1,7 +1,16 @@ import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input"; import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator"; +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"; @@ -45,6 +54,26 @@ export class CreateEventTypeInput { @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() // @IsNumber() diff --git a/apps/api/v2/src/ee/event-types/inputs/event-type-id.input.ts b/apps/api/v2/src/ee/event-types/inputs/event-type-id.input.ts new file mode 100644 index 00000000000000..392f330b00223c --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/event-type-id.input.ts @@ -0,0 +1,6 @@ +import { IsNumberString } from "class-validator"; + +export class EventTypeIdParams { + @IsNumberString() + eventTypeId!: number; +} diff --git a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts index 68a64d4204f856..8c112db433e59c 100644 --- a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts +++ b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts @@ -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) + bookingFields?: BookingField[]; // @IsString() // @IsOptional() @@ -330,18 +330,20 @@ export class UpdateEventTypeInput { // @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/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/services/event-types.service.ts index 3b84a609d984df..83c5e264fc7629 100644 --- a/apps/api/v2/src/ee/event-types/services/event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/services/event-types.service.ts @@ -9,8 +9,8 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select 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 } from "@calcom/platform-libraries-0.0.2"; +import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2"; import { EventType } from "@calcom/prisma/client"; @Injectable() diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts index 6eff8d60ebec92..b2a363de458a22 100644 --- a/apps/api/v2/src/ee/gcal/gcal.controller.ts +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -2,6 +2,7 @@ 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"; @@ -40,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 { @@ -81,56 +82,10 @@ 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); - // 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: 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") 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..4acc86b17e6409 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"; @@ -34,14 +33,7 @@ describe("Me Endpoints", () => { const moduleRef = await withAccessTokenAuth( userEmail, Test.createTestingModule({ - imports: [ - AppModule, - PrismaModule, - AvailabilitiesModule, - UsersModule, - TokensModule, - SchedulesModule, - ], + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], }) ) .overrideGuard(PermissionsGuard) @@ -118,13 +110,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..3f2b4c52876906 100644 --- a/apps/api/v2/src/ee/me/me.controller.ts +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -1,6 +1,7 @@ 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"; @@ -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) @DocsTags("Me") export class MeController { constructor( private readonly usersRepository: UsersRepository, - private readonly schedulesRepository: SchedulesService + private readonly schedulesRepository: SchedulesService_2024_04_15 ) {} @Get("/") 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..67c72ab70b2af5 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -4,7 +4,8 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module"; import { GcalModule } from "@/ee/gcal/gcal.module"; import { MeModule } from "@/ee/me/me.module"; import { ProviderModule } from "@/ee/provider/provider.module"; -import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { 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,7 +14,8 @@ import { Module } from "@nestjs/common"; imports: [ GcalModule, ProviderModule, - SchedulesModule, + SchedulesModule_2024_04_15, + SchedulesModule_2024_06_11, MeModule, EventTypesModule, CalendarsModule, diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts index 40230e2a2714ab..3059f6818c6e87 100644 --- a/apps/api/v2/src/ee/provider/provider.controller.ts +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -1,5 +1,6 @@ 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 { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; @@ -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 { 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 82% 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..8083886f732af4 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"; @@ -19,8 +18,8 @@ import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.r import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; -import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { UpdateScheduleInput } from "@calcom/platform-types"; +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,7 +31,7 @@ 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"; @@ -41,14 +40,7 @@ describe("Schedules Endpoints", () => { const moduleRef = await withAccessTokenAuth( 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 65% 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 b2860cfe5e4509..2bf7eae8daca73 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,10 +1,11 @@ -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"; @@ -26,25 +27,25 @@ 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) @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); @@ -56,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) @@ -75,7 +82,7 @@ export class SchedulesController { 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); @@ -87,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); @@ -102,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), @@ -123,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 81% 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 c91176e422f0dd..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,26 +1,25 @@ -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 ) {} @@ -34,10 +33,10 @@ export class SchedulesService { return this.createUserSchedule(userId, schedule); } - async createUserSchedule(userId: number, schedule: CreateScheduleInput) { + 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, @@ -76,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) { @@ -158,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..79e03b74cd4734 --- /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 { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +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 withAccessTokenAuth( + 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..3efd3647cdfc25 --- /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_11_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 { 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_11_VALUE, +}) +@UseGuards(AccessTokenGuard, 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..0f92ac4daf953f --- /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], +}) +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..087687aecb0404 --- /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.4"; +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/filters/sentry-exception.filter.ts b/apps/api/v2/src/filters/sentry-exception.filter.ts deleted file mode 100644 index 996d9faa6dabc4..00000000000000 --- a/apps/api/v2/src/filters/sentry-exception.filter.ts +++ /dev/null @@ -1,39 +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(); - const requestId = request.headers["X-Request-Id"]; - - this.logger.error(`Sentry Exception Filter: ${exception?.message}`, { - exception, - body: request.body, - headers: request.headers, - url: request.url, - method: request.method, - requestId, - }); - - // 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 47dbcd00b4c85a..5c195349f9b970 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.2"; import { Response } from "@calcom/platform-types"; @Catch(TRPCError) 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-versions.ts b/apps/api/v2/src/lib/api-versions.ts new file mode 100644 index 00000000000000..c220d9c997cfe5 --- /dev/null +++ b/apps/api/v2/src/lib/api-versions.ts @@ -0,0 +1,13 @@ +import { VersionValue } from "@nestjs/common/interfaces"; + +import { + API_VERSIONS, + VERSION_2024_04_15, + VERSION_2024_05_21, + VERSION_2024_06_11, +} from "@calcom/platform-constants"; + +export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue; +export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue; +export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue; +export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue; 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/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts index dc57103c860c8a..72846cdfad6727 100644 --- 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 @@ -38,7 +38,9 @@ export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access- } if (requestOrigin && !client.redirectUris.some((uri) => uri.startsWith(requestOrigin))) { - throw new UnauthorizedException("Invalid request origin"); + throw new UnauthorizedException( + `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${requestOrigin}' to the 'Redirect uris' of your OAuth client.` + ); } const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); 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/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index f22d9a5441b1ae..828ada839fb9e9 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -1,4 +1,5 @@ import { AppConfig } from "@/config/type"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; @@ -29,8 +30,8 @@ import { Stripe } from "stripe"; import { ApiResponse } from "@calcom/platform-types"; @Controller({ - path: "/billing", - version: "2", + path: "/v2/billing", + version: API_VERSIONS_VALUES, }) @ApiExcludeController(true) export class BillingController { @@ -122,7 +123,7 @@ export class BillingController { await this.billingService.setSubscriptionForTeam( teamId, subscription, - PlatformPlan[plan as keyof typeof PlatformPlan] + PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan] ); return { 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 index a31d3ed26127f0..a9a25e35b14101 100644 --- a/apps/api/v2/src/modules/billing/services/billing.config.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.config.service.ts @@ -11,7 +11,7 @@ export class BillingConfigService { const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key))); for (const key of planKeys) { this.config.set( - PlatformPlan[key as keyof typeof PlatformPlan], + PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], process.env[`STRIPE_PRICE_ID_${key}`] ?? "" ); } diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 445c9208e745a2..aa0f4b0caba14b 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -75,6 +75,7 @@ export class BillingService { plan: plan.toString(), }, }, + allow_promotion_codes: true, }); if (!url) throw new InternalServerErrorException("Failed to create Stripe session."); 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..29f544b5dd8f5f 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,4 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; 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 +22,6 @@ import { HttpStatus, Param, Patch, - BadRequestException, Delete, Query, NotFoundException, @@ -33,8 +33,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 +91,7 @@ export class OAuthClientUsersController { data: { user: this.getResponseUser(user), accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(), refreshToken: tokens.refreshToken, }, }; 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-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts index 93fe11003e5bdd..dba95be466e87c 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,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 { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; @@ -46,8 +47,8 @@ const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next- 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") 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..975db6df0e7b46 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") 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 52c99eca6444c5..04393d27e7e6f3 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,5 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module"; -import { SchedulesModule } from "@/ee/schedules/schedules.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"; @@ -32,7 +32,7 @@ import { Global, Module } from "@nestjs/common"; OrganizationsModule, StripeModule, BillingModule, - SchedulesModule, + SchedulesModule_2024_04_15, ], providers: [ OAuthClientRepository, diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index a595b377ffec08..691f33c59b37b2 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,5 +1,5 @@ import { EventTypesService } from "@/ee/event-types/services/event-types.service"; -import { SchedulesService } from "@/ee/schedules/services/schedules.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"; @@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable } from "@nestjs/common"; import { User } from "@prisma/client"; -import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries"; +import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2"; @Injectable() export class OAuthClientUsersService { @@ -15,7 +15,7 @@ export class OAuthClientUsersService { private readonly userRepository: UsersRepository, private readonly tokensRepository: TokensRepository, private readonly eventTypesService: EventTypesService, - private readonly schedulesService: SchedulesService + private readonly schedulesService: SchedulesService_2024_04_15 ) {} async createOauthClientUser( @@ -62,7 +62,7 @@ export class OAuthClientUsersService { await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined }); } - const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens( oAuthClientId, user.id ); @@ -78,6 +78,7 @@ export class OAuthClientUsersService { user, tokens: { accessToken, + accessTokenExpiresAt, refreshToken, }, }; 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..4ab18d70aeee82 100644 --- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -1,3 +1,4 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { SlotsService } from "@/modules/slots/services/slots.service"; import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common"; @@ -5,14 +6,14 @@ 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.2"; +import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.2"; 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/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts index 8ef589f9f87515..abf5d916bb4744 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.2"; import { ReserveSlotInput } from "@calcom/platform-types"; @Injectable() 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..7d2707c270808f 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.2"; 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 be623bebdcf559..7fc1657b9eaae6 100644 --- a/apps/api/v2/src/modules/timezones/services/timezones.service.ts +++ b/apps/api/v2/src/modules/timezones/services/timezones.service.ts @@ -1,8 +1,8 @@ import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; -import { cityTimezonesHandler } from "@calcom/platform-libraries"; -import type { CityTimezones } from "@calcom/platform-libraries"; +import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.2"; +import type { CityTimezones } from "@calcom/platform-libraries-0.0.2"; @Injectable() export class TimezonesService { diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts index 80bbc027fd4f52..448b5fde64b3ec 100644 --- a/apps/api/v2/src/modules/tokens/tokens.repository.ts +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -89,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 8d209089572a6e..755088e6a89b1f 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,3 +1,4 @@ +import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; import { ApiProperty } from "@nestjs/swagger"; import { IsOptional, IsTimeZone, IsString } from "class-validator"; @@ -26,6 +27,7 @@ export class CreateManagedUserInput { @IsTimeZone() @IsOptional() + @CapitalizeTimeZone() @ApiProperty({ example: "America/New_York" }) timeZone?: string; } diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index e03429b192cb32..cd774690350c89 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -173,6 +173,14 @@ export class UsersRepository { }, }); } + + async getUserScheduleDefaultId(userId: number) { + const user = await this.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return user?.defaultScheduleId; + } } function capitalizeTimezone(timezone: string) { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index ffc41ecf7265fb..78bdca87a610fb 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -634,7 +634,7 @@ "required": true, "in": "path", "schema": { - "type": "string" + "type": "number" } } ], @@ -808,14 +808,14 @@ }, "/v2/schedules": { "post": { - "operationId": "SchedulesController_createSchedule", + "operationId": "SchedulesController_2024_06_11_createSchedule", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateScheduleInput" + "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" } } } @@ -826,7 +826,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateScheduleOutput" + "$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11" } } } @@ -837,7 +837,7 @@ ] }, "get": { - "operationId": "SchedulesController_getSchedules", + "operationId": "SchedulesController_2024_06_11_getSchedules", "parameters": [], "responses": { "200": { @@ -845,7 +845,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetSchedulesOutput" + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" } } } @@ -858,7 +858,7 @@ }, "/v2/schedules/default": { "get": { - "operationId": "SchedulesController_getDefaultSchedule", + "operationId": "SchedulesController_2024_06_11_getDefaultSchedule", "parameters": [], "responses": { "200": { @@ -866,7 +866,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetDefaultScheduleOutput" + "$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_06_11" } } } @@ -879,7 +879,7 @@ }, "/v2/schedules/{scheduleId}": { "get": { - "operationId": "SchedulesController_getSchedule", + "operationId": "SchedulesController_2024_06_11_getSchedule", "parameters": [ { "name": "scheduleId", @@ -896,7 +896,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetScheduleOutput" + "$ref": "#/components/schemas/GetScheduleOutput_2024_06_11" } } } @@ -907,7 +907,7 @@ ] }, "patch": { - "operationId": "SchedulesController_updateSchedule", + "operationId": "SchedulesController_2024_06_11_updateSchedule", "parameters": [ { "name": "scheduleId", @@ -923,7 +923,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput" + "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" } } } @@ -934,7 +934,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateScheduleOutput" + "$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11" } } } @@ -945,7 +945,7 @@ ] }, "delete": { - "operationId": "SchedulesController_deleteSchedule", + "operationId": "SchedulesController_2024_06_11_deleteSchedule", "parameters": [ { "name": "scheduleId", @@ -962,7 +962,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteScheduleOutput" + "$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11" } } } @@ -1214,6 +1214,113 @@ ] } }, + "/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}/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", @@ -1726,12 +1833,16 @@ }, "refreshToken": { "type": "string" + }, + "accessTokenExpiresAt": { + "type": "number" } }, "required": [ "user", "accessToken", - "refreshToken" + "refreshToken", + "accessTokenExpiresAt" ] }, "CreateManagedUserOutput": { @@ -2102,6 +2213,22 @@ }, "disableGuests": { "type": "boolean" + }, + "slotInterval": { + "type": "number", + "minimum": 0 + }, + "minimumBookingNotice": { + "type": "number", + "minimum": 0 + }, + "beforeEventBuffer": { + "type": "number", + "minimum": 0 + }, + "afterEventBuffer": { + "type": "number", + "minimum": 0 } }, "required": [ @@ -2785,8 +2912,30 @@ "$ref": "#/components/schemas/EventTypeLocation" } }, + "bookingFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingField" + } + }, "disableGuests": { "type": "boolean" + }, + "minimumBookingNotice": { + "type": "number", + "minimum": 0 + }, + "beforeEventBuffer": { + "type": "number", + "minimum": 0 + }, + "afterEventBuffer": { + "type": "number", + "minimum": 0 + }, + "slotInterval": { + "type": "number", + "minimum": 0 } } }, @@ -2857,7 +3006,7 @@ "data" ] }, - "CreateAvailabilityInput": { + "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { "days": { @@ -2885,7 +3034,7 @@ "endTime" ] }, - "CreateScheduleInput": { + "CreateScheduleInput_2024_04_15": { "type": "object", "properties": { "name": { @@ -2897,7 +3046,7 @@ "availabilities": { "type": "array", "items": { - "$ref": "#/components/schemas/CreateAvailabilityInput" + "$ref": "#/components/schemas/CreateAvailabilityInput_2024_04_15" } }, "isDefault": { @@ -3069,7 +3218,7 @@ "readOnly" ] }, - "CreateScheduleOutput": { + "CreateScheduleOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3089,7 +3238,7 @@ "data" ] }, - "GetDefaultScheduleOutput": { + "GetDefaultScheduleOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3114,7 +3263,7 @@ "data" ] }, - "GetScheduleOutput": { + "GetScheduleOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3134,7 +3283,7 @@ "data" ] }, - "GetSchedulesOutput": { + "GetSchedulesOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3154,7 +3303,7 @@ "data" ] }, - "UpdateScheduleInput": { + "UpdateScheduleInput_2024_04_15": { "type": "object", "properties": { "timeZone": { @@ -3214,7 +3363,7 @@ "schedule" ] }, - "EventTypeModel": { + "EventTypeModel_2024_04_15": { "type": "object", "properties": { "id": { @@ -3229,7 +3378,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": { @@ -3248,13 +3440,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" } } }, @@ -3264,11 +3456,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" @@ -3290,7 +3482,7 @@ "isDefault" ] }, - "UpdateScheduleOutput": { + "UpdateScheduleOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3302,7 +3494,7 @@ ] }, "data": { - "$ref": "#/components/schemas/UpdatedScheduleOutput" + "$ref": "#/components/schemas/UpdatedScheduleOutput_2024_04_15" } }, "required": [ @@ -3310,7 +3502,7 @@ "data" ] }, - "DeleteScheduleOutput": { + "DeleteScheduleOutput_2024_04_15": { "type": "object", "properties": { "status": { @@ -3416,6 +3608,349 @@ "status" ] }, + "ScheduleAvailabilityInput_2024_06_11": { + "type": "object", + "properties": { + "days": { + "example": [ + "Monday", + "Tuesday" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "startTime": { + "type": "string", + "example": "09:00" + }, + "endTime": { + "type": "string", + "example": "10:00" + } + }, + "required": [ + "days", + "startTime", + "endTime" + ] + }, + "ScheduleOverrideInput_2024_06_11": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2024-05-20" + }, + "startTime": { + "type": "string", + "example": "12:00" + }, + "endTime": { + "type": "string", + "example": "13:00" + } + }, + "required": [ + "date", + "startTime", + "endTime" + ] + }, + "CreateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "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": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + }, + "required": [ + "name", + "timeZone", + "isDefault" + ] + }, + "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", + "availability", + "isDefault", + "overrides" + ] + }, + "CreateScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetDefaultScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + ] + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetSchedulesOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "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": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + } + }, + "UpdateScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + }, + "error": { + "type": "object" + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + } + }, + "required": [ + "status" + ] + }, "MeOutput": { "type": "object", "properties": { diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json index 03623b53d81673..384837665690b1 100644 --- a/apps/api/v2/tsconfig.json +++ b/apps/api/v2/tsconfig.json @@ -15,7 +15,6 @@ "paths": { "@/*": ["./src/*"], "@prisma/client/*": ["@calcom/prisma/client/*"], - "@calcom/platform-libraries": ["../../../packages/platform/libraries/index.ts"], "@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"], "@calcom/platform-types": ["../../../packages/platform/types/index.ts"], "@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"] @@ -27,17 +26,13 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false }, + "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", - "../../../packages/platform/libraries/**/*.ts", - "../../../packages/platform/libraries/**/*.d.ts", - "../../../packages/platform/constants/**/*.ts", - "../../../packages/platform/constants/**/*.d.ts", - "../../../packages/platform/types/**/*.ts", - "../../../packages/platform/types/**/*.d.ts", - "../../../packages/platform/utils/**/*.ts", - "../../../packages/platform/utils/**/*.d.ts" - ] + "include": ["./**/*.ts", "../../../packages/types/*.d.ts"] } diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 4ff36ca97a8997..ea341898362629 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -7,6 +7,7 @@ import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/comp import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; import classNames from "@calcom/lib/classNames"; import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } 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"; @@ -21,6 +22,7 @@ export type AppPageProps = { isGlobal?: AppType["isGlobal"]; logo: string; slug: string; + dirName: string | undefined; variant: string; body: React.ReactNode; categories: string[]; @@ -68,8 +70,11 @@ export const AppPage = ({ dependencies, concurrentMeetings, paid, + dirName, }: AppPageProps) => { const { t, i18n } = useLocale(); + const searchParams = useCompatSearchParams(); + const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; const mutation = useAddAppMutation(null, { @@ -118,6 +123,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 (
@@ -223,6 +233,7 @@ export const AppPage = ({ multiInstall concurrentMeetings={concurrentMeetings} paid={paid} + dirName={dirName} {...props} /> ); @@ -262,6 +273,7 @@ export const AppPage = ({ credentials={appDbQuery.data?.credentials} concurrentMeetings={concurrentMeetings} paid={paid} + dirName={dirName} {...props} /> ); diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx index 274f9dfcf6c60a..52900a523b6ac4 100644 --- a/apps/web/components/apps/InstallAppButtonChild.tsx +++ b/apps/web/components/apps/InstallAppButtonChild.tsx @@ -1,7 +1,14 @@ +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; + import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; 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 { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -26,7 +33,9 @@ export const InstallAppButtonChild = ({ multiInstall, credentials, concurrentMeetings, + dirName, paid, + onClick, ...props }: { userAdminTeams?: UserAdminTeams; @@ -36,8 +45,10 @@ export const InstallAppButtonChild = ({ credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"]; concurrentMeetings?: boolean; paid?: AppFrontendPayload["paid"]; + dirName: string | undefined; } & ButtonProps) => { const { t } = useLocale(); + const router = useRouter(); const mutation = useAddAppMutation(null, { onSuccess: (data) => { @@ -49,6 +60,18 @@ export const InstallAppButtonChild = ({ }, }); const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false; + const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; + const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]); + + const _onClick = (e: React.MouseEvent) => { + if (redirectToAppOnboarding) { + router.push( + getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }) + ); + } else if (onClick) { + onClick(e); + } + }; // Paid apps don't support team installs at the moment // Also, cal.ai(the only paid app at the moment) doesn't support team install either @@ -56,6 +79,7 @@ export const InstallAppButtonChild = ({ return ( + ); + } return ( diff --git a/apps/web/components/apps/installation/AccountsStepCard.tsx b/apps/web/components/apps/installation/AccountsStepCard.tsx new file mode 100644 index 00000000000000..1c67f70de881e9 --- /dev/null +++ b/apps/web/components/apps/installation/AccountsStepCard.tsx @@ -0,0 +1,97 @@ +import type { FC } from "react"; +import React, { useState } from "react"; + +import { classNames } from "@calcom/lib"; +import { CAL_URL } from "@calcom/lib/constants"; +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; +}; + +export const AccountsStepCard: FC = ({ teams, personalAccount, onSelect, loading }) => { + const { t } = useLocale(); + return ( + +
{t("install_app_on")}
+
+ onSelect()} + loading={loading} + /> + {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..118ad0cc47154c --- /dev/null +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -0,0 +1,223 @@ +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 type { z } from "zod"; + +import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; +import type { EventTypeAppsList } from "@calcom/app-store/utils"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AppCategories } from "@calcom/prisma/enums"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { Button, Form } from "@calcom/ui"; + +import useAppsData from "@lib/hooks/useAppsData"; + +type TFormType = { + metadata: z.infer; +}; + +type ConfigureStepCardProps = { + slug: string; + userName: string; + categories: AppCategories[]; + credentialId?: number; + loading?: boolean; + formPortalRef: React.RefObject; + eventTypes: TEventType[] | undefined; + setConfigureStep: Dispatch>; + handleSetUpLater: () => void; +}; + +type EventTypeAppSettingsFormProps = Pick< + ConfigureStepCardProps, + "slug" | "userName" | "categories" | "credentialId" | "loading" +> & { + eventType: TEventType; + handleDelete: () => void; + onSubmit: (values: z.infer) => void; +}; + +type EventTypeAppSettingsWrapperProps = Pick< + ConfigureStepCardProps, + "slug" | "userName" | "categories" | "credentialId" +> & { + eventType: TEventType; +}; + +const EventTypeAppSettingsWrapper: FC = ({ + slug, + eventType, + categories, + credentialId, +}) => { + const { getAppDataGetter, getAppDataSetter } = useAppsData(); + + useEffect(() => { + const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId); + appDataSetter("enabled", true); + }, []); + + return ( + + ); +}; + +const EventTypeAppSettingsForm = forwardRef( + function EventTypeAppSettingsForm(props, ref) { + const { handleDelete, onSubmit, eventType, loading } = props; + + const formMethods = useForm({ + defaultValues: { + metadata: eventType?.metadata, + }, + }); + + return ( +
{ + const data = formMethods.getValues("metadata"); + onSubmit(data); + }}> +
+
+
+ {eventType.title}{" "} + + /{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug} + +
+ + !loading && handleDelete()} + /> + +
+
+
+ ); + } +); + +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); + } + }, [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 && ( + { + const eventMetadataDb = eventTypes?.find( + (eventType) => eventType.id == field.id + )?.metadata; + update(index, { ...field, selected: false, metadata: eventMetadataDb }); + }} + onSubmit={(data) => { + update(index, { ...field, metadata: data }); + setUpdatedEventTypesStatus((prev) => + prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item)) + ); + }} + ref={submitRefs.current[index]} + {...props} + /> + ) + ); + })} +
+ + + + +
+ +
+
, + formPortalRef?.current + ) + ); +}; diff --git a/apps/web/components/apps/installation/EventTypesStepCard.tsx b/apps/web/components/apps/installation/EventTypesStepCard.tsx new file mode 100644 index 00000000000000..c11fe9627a3e35 --- /dev/null +++ b/apps/web/components/apps/installation/EventTypesStepCard.tsx @@ -0,0 +1,130 @@ +import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; +import type { Dispatch, SetStateAction } from "react"; +import type { FC } from "react"; +import React from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { ScrollableArea, Badge, Button } from "@calcom/ui"; + +type EventTypesCardProps = { + userName: string; + setConfigureStep: Dispatch>; + handleSetUpLater: () => void; +}; + +export const EventTypesStepCard: FC = ({ + setConfigureStep, + userName, + handleSetUpLater, +}) => { + const { t } = useLocale(); + const { control } = useFormContext(); + const { fields, update } = useFieldArray({ + control, + name: "eventTypes", + keyName: "fieldId", + }); + + return ( +
+
+ +
    + {fields.map((field, index) => ( + update(index, { ...field, selected: !field.selected })} + userName={userName} + key={field.fieldId} + {...field} + /> + ))} +
+
+
+ + + +
+ +
+
+ ); +}; + +type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void }; + +const EventTypeCard: FC = ({ + title, + description, + id, + metadata, + length, + selected, + slug, + handleSelect, + team, + userName, +}) => { + const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata); + const durations = + parsedMetaData.success && + parsedMetaData.data?.multipleDuration && + Boolean(parsedMetaData.data?.multipleDuration.length) + ? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort() + : [length]; + return ( +
handleSelect()}> + + +
+ ); +}; diff --git a/apps/web/components/apps/installation/StepHeader.tsx b/apps/web/components/apps/installation/StepHeader.tsx new file mode 100644 index 00000000000000..e79dbc2ea6d77b --- /dev/null +++ b/apps/web/components/apps/installation/StepHeader.tsx @@ -0,0 +1,19 @@ +import type { FC, ReactNode } from "react"; + +type StepHeaderProps = { + children?: ReactNode; + title: string; + subtitle: string; +}; +export const StepHeader: FC = ({ children, title, subtitle }) => { + return ( +
+
+

{title}

+ +

{subtitle}

+
+ {children} +
+ ); +}; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 248d4e2be1ded2..2a8c220d57ec43 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { useState } from "react"; +import { useForm, Controller, useFieldArray } from "react-hook-form"; import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations"; import { @@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames"; import { formatTime } from "@calcom/lib/date-fns"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -35,6 +37,14 @@ import { TableActions, TextAreaField, Tooltip, + Dropdown, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuItem, + DropdownItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuCheckboxItem, } from "@calcom/ui"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; @@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) { }); const isUpcoming = new Date(booking.endTime) >= new Date(); - const isPast = new Date(booking.endTime) < new Date(); + const isBookingInPast = new Date(booking.endTime) < new Date(); const isCancelled = booking.status === BookingStatus.CANCELLED; const isConfirmed = booking.status === BookingStatus.ACCEPTED; const isRejected = booking.status === BookingStatus.REJECTED; @@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) { bookedActions = bookedActions.filter((action) => action.id !== "edit_booking"); } - if (isPast && isPending && !isConfirmed) { + if (isBookingInPast && isPending && !isConfirmed) { bookedActions = bookedActions.filter((action) => action.id !== "cancel"); } @@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) { const title = booking.title; - const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed); + const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed); const showCheckRecordingButton = - isPast && + isBookingInPast && isConfirmed && !booking.isRecorded && (!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""); @@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) { ]; const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid; - + const attendeeList = booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + id: attendee.id, + noShow: attendee.noShow || false, + }; + }); return ( <> - + {/* Time and Badges for mobile */}
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) { )} {booking.attendees.length !== 0 && ( )} {isCancelled && booking.rescheduled && ( @@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) { {isRejected &&
{t("rejected")}
} ) : null} - {isPast && isPending && !isConfirmed ? : null} + {isBookingInPast && isPending && !isConfirmed ? : null} {(showViewRecordingsButton || showCheckRecordingButton) && ( )} @@ -654,13 +673,215 @@ const FirstAttendee = ({ type AttendeeProps = { name?: string; email: string; + id: number; + noShow: boolean; +}; + +type NoShowProps = { + bookingUid: string; + isBookingInPast: boolean; }; -const Attendee = ({ email, name }: AttendeeProps) => { +const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => { + const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps; + const { t } = useLocale(); + + const [noShow, setNoShow] = useState(noShowAttendee); + const [openDropdown, setOpenDropdown] = useState(false); + const { copyToClipboard, isCopied } = useCopy(); + + const noShowMutation = trpc.viewer.public.noShow.useMutation({ + onSuccess: async () => { + showToast( + t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", { + x: name || email, + }), + "success" + ); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + function toggleNoShow({ + attendee, + bookingUid, + }: { + attendee: { email: string; noShow: boolean }; + bookingUid: string; + }) { + noShowMutation.mutate({ bookingUid, attendees: [attendee] }); + setNoShow(!noShow); + } + return ( - e.stopPropagation()}> - {name || email} - + + + + + + + { + setOpenDropdown(false); + e.stopPropagation(); + }}> + {t("email")} + + + + { + e.preventDefault(); + copyToClipboard(email); + setOpenDropdown(false); + showToast(t("email_copied"), "success"); + }}> + {!isCopied ? t("copy") : t("copied")} + + + {isBookingInPast && ( + + {noShow ? ( + { + setOpenDropdown(false); + toggleNoShow({ attendee: { noShow: false, email }, bookingUid }); + e.preventDefault(); + }} + StartIcon="eye"> + {t("unmark_as_no_show")} + + ) : ( + { + setOpenDropdown(false); + toggleNoShow({ attendee: { noShow: true, email }, bookingUid }); + e.preventDefault(); + }} + StartIcon="eye-off"> + {t("mark_as_no_show")} + + )} + + )} + + + ); +}; + +type GroupedAttendeeProps = { + attendees: AttendeeProps[]; + bookingUid: string; +}; + +const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { + const { bookingUid } = groupedAttendeeProps; + const attendees = groupedAttendeeProps.attendees.map((attendee) => { + return { + id: attendee.id, + email: attendee.email, + name: attendee.name, + noShow: attendee.noShow || false, + }; + }); + const { t } = useLocale(); + const noShowMutation = trpc.viewer.public.noShow.useMutation({ + onSuccess: async () => { + showToast(t("no_show_updated"), "success"); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + const { control, handleSubmit } = useForm<{ + attendees: AttendeeProps[]; + }>({ + defaultValues: { + attendees, + }, + mode: "onBlur", + }); + + const { fields } = useFieldArray({ + control, + name: "attendees", + }); + + const onSubmit = (data: { attendees: AttendeeProps[] }) => { + const filteredData = data.attendees.slice(1); + noShowMutation.mutate({ bookingUid, attendees: filteredData }); + setOpenDropdown(false); + }; + + const [openDropdown, setOpenDropdown] = useState(false); + + return ( + + + + + + + {t("mark_as_no_show_title")} + +
+ {fields.slice(1).map((field, index) => ( + ( + { + e.preventDefault(); + onChange(!value); + }}> + {field.email} + + )} + /> + ))} + +
+ +
+ +
+
); }; @@ -668,17 +889,23 @@ const DisplayAttendees = ({ attendees, user, currentEmail, + bookingUid, + isBookingInPast, }: { attendees: AttendeeProps[]; user: UserProps | null; currentEmail?: string | null; + bookingUid: string; + isBookingInPast: boolean; }) => { const { t } = useLocale(); + attendees.sort((a, b) => a.id - b.id); + return (
{user && } {attendees.length > 1 ? :  {t("and")} } - + {attendees.length > 1 && ( <>
 {t("and")} 
@@ -686,13 +913,13 @@ const DisplayAttendees = ({ (

- +

))}> -
{t("plus_more", { count: attendees.length - 1 })}
+ {isBookingInPast && }
) : ( - + )} )} diff --git a/apps/web/components/eventtype/AssignmentWarningDialog.tsx b/apps/web/components/eventtype/AssignmentWarningDialog.tsx new file mode 100644 index 00000000000000..292018d22052af --- /dev/null +++ b/apps/web/components/eventtype/AssignmentWarningDialog.tsx @@ -0,0 +1,56 @@ +import { useRouter } from "next/navigation"; +import type { Dispatch, SetStateAction } from "react"; +import type { MutableRefObject } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Dialog, DialogContent, Button, DialogFooter } from "@calcom/ui"; + +interface AssignmentWarningDialogProps { + isOpenAssignmentWarnDialog: boolean; + setIsOpenAssignmentWarnDialog: Dispatch>; + pendingRoute: string; + leaveWithoutAssigningHosts: MutableRefObject; + id: number; +} +const AssignmentWarningDialog = (props: AssignmentWarningDialogProps) => { + const { t } = useLocale(); + const { + isOpenAssignmentWarnDialog, + setIsOpenAssignmentWarnDialog, + pendingRoute, + leaveWithoutAssigningHosts, + id, + } = props; + const router = useRouter(); + return ( + + +
+

{t("leave_without_adding_attendees")}

+

{t("no_availability_shown_to_bookers")}

+
+ + + + +
+
+ ); +}; +export default AssignmentWarningDialog; diff --git a/apps/web/components/eventtype/CustomEventTypeModal.tsx b/apps/web/components/eventtype/CustomEventTypeModal.tsx index 7cda6b75565e6f..5a6ab8c5c466c7 100644 --- a/apps/web/components/eventtype/CustomEventTypeModal.tsx +++ b/apps/web/components/eventtype/CustomEventTypeModal.tsx @@ -19,11 +19,12 @@ interface CustomEventTypeModalFormProps { setValue: (value: string) => void; event: EventNameObjectType; defaultValue: string; + isNameFieldSplit: boolean; } const CustomEventTypeModalForm: FC = (props) => { const { t } = useLocale(); - const { placeHolder, close, setValue, event } = props; + const { placeHolder, close, setValue, event, isNameFieldSplit } = props; const { register, handleSubmit, watch, getValues } = useFormContext(); const onSubmit: SubmitHandler = (data) => { setValue(data.customEventName); @@ -68,10 +69,26 @@ const CustomEventTypeModalForm: FC = (props) => {

{`{Organiser}`}

{t("your_full_name")}

+
+

{`{Organiser first name}`}

+

{t("organizer_first_name")}

+

{`{Scheduler}`}

{t("scheduler_full_name")}

+ {isNameFieldSplit && ( +
+

{`{Scheduler first name}`}

+

{t("scheduler_first_name")}

+
+ )} + {isNameFieldSplit && ( +
+

{`{Scheduler last name}`}

+

{t("scheduler_last_name")}

+
+ )}

{`{Location}`}

{t("location_info")}

@@ -101,12 +118,13 @@ interface CustomEventTypeModalProps { close: () => void; setValue: (value: string) => void; event: EventNameObjectType; + isNameFieldSplit: boolean; } const CustomEventTypeModal: FC = (props) => { const { t } = useLocale(); - const { defaultValue, placeHolder, close, setValue, event } = props; + const { defaultValue, placeHolder, close, setValue, event, isNameFieldSplit } = props; const methods = useForm({ defaultValues: { @@ -128,6 +146,7 @@ const CustomEventTypeModal: FC = (props) => { setValue={setValue} placeHolder={placeHolder} defaultValue={defaultValue} + isNameFieldSplit={isNameFieldSplit} /> diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 43ede471002140..c0cedaedc87f4b 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -65,6 +65,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick field.name === "name"); + const isSplit = (nameBookingField && nameBookingField.variant === "firstAndLastName") ?? false; + const eventNameObject: EventNameObjectType = { attendeeName: t("scheduler"), eventType: formMethods.getValues("title"), @@ -79,6 +82,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick 1; const noShowFeeEnabled = formMethods.getValues("metadata")?.apps?.stripe?.enabled === true && formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD"; @@ -431,7 +435,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { // Enabling seats will disable guests and requiring confirmation until fully supported if (e) { @@ -564,6 +575,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick formMethods.setValue("eventName", val, { shouldDirty: true })} defaultValue={formMethods.getValues("eventName")} placeHolder={eventNamePlaceholder} + isNameFieldSplit={isSplit} event={eventNameObject} /> )} diff --git a/apps/web/components/eventtype/EventAppsTab.tsx b/apps/web/components/eventtype/EventAppsTab.tsx index 10160ae34a4038..9625c791d4c32a 100644 --- a/apps/web/components/eventtype/EventAppsTab.tsx +++ b/apps/web/components/eventtype/EventAppsTab.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useFormContext } from "react-hook-form"; -import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext"; import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface"; import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; @@ -13,6 +12,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Alert, Button, EmptyScreen } from "@calcom/ui"; +import useAppsData from "@lib/hooks/useAppsData"; + export type EventType = Pick["eventType"] & EventTypeAppCardComponentProps["eventType"]; @@ -28,51 +29,8 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || []; const notInstalledApps = eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || []; - const allAppsData = formMethods.watch("metadata")?.apps || {}; - - const setAllAppsData = (_allAppsData: typeof allAppsData) => { - formMethods.setValue( - "metadata", - { - ...formMethods.getValues("metadata"), - apps: _allAppsData, - }, - { shouldDirty: true } - ); - }; - - const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => { - return function (key) { - const appData = allAppsData[appId as keyof typeof allAppsData] || {}; - if (key) { - return appData[key as keyof typeof appData]; - } - return appData; - }; - }; - - const eventTypeFormMetadata = formMethods.getValues("metadata"); - const getAppDataSetter = ( - appId: EventTypeAppsList, - appCategories: string[], - credentialId?: number - ): SetAppData => { - return function (key, value) { - // Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render) - const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {}; - const appData = allAppsDataFromForm[appId]; - setAllAppsData({ - ...allAppsDataFromForm, - [appId]: { - ...appData, - [key]: value, - credentialId, - appCategories, - }, - }); - }; - }; + const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData(); const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({ eventType, diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/apps/web/components/eventtype/EventLimitsTab.tsx index 22d9272a59848c..d814df5e131a69 100644 --- a/apps/web/components/eventtype/EventLimitsTab.tsx +++ b/apps/web/components/eventtype/EventLimitsTab.tsx @@ -3,7 +3,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group"; import type { EventTypeSetupProps } from "pages/event-types/[type]"; import type { Key } from "react"; import React, { useEffect, useState } from "react"; -import type { UseFormRegisterReturn } from "react-hook-form"; +import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form"; import type { SingleValue } from "react-select"; @@ -11,15 +11,193 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { classNames } from "@calcom/lib"; +import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants"; import type { DurationType } from "@calcom/lib/convertToNewDurationType"; import convertToNewDurationType from "@calcom/lib/convertToNewDurationType"; import findDurationType from "@calcom/lib/findDurationType"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; -import type { PeriodType } from "@calcom/prisma/enums"; +import { PeriodType } from "@calcom/prisma/enums"; import type { IntervalLimit } from "@calcom/types/Calendar"; import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui"; +import CheckboxField from "@components/ui/form/CheckboxField"; + +type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType]; + +/** + * We technically have a ROLLING_WINDOW future limit option that isn't shown as a Radio Option. Because UX is better by providing it as a toggle with ROLLING Limit radio option. + * Also, ROLLING_WINDOW reuses the same `periodDays` field and `periodCountCalendarDays` fields + * + * So we consider `periodType=ROLLING && rollingExcludeUnavailableDays=true` to be the ROLLING_WINDOW option + * We can't set `periodType=ROLLING_WINDOW` directly because it is not a valid Radio Option in UI + * So, here we can convert from periodType to uiValue any time. + */ +const getUiValueFromPeriodType = (periodType: PeriodType) => { + if (periodType === PeriodType.ROLLING_WINDOW) { + return { + value: PeriodType.ROLLING, + rollingExcludeUnavailableDays: true, + }; + } + + if (periodType === PeriodType.ROLLING) { + return { + value: PeriodType.ROLLING, + rollingExcludeUnavailableDays: false, + }; + } + + return { + value: periodType, + rollingExcludeUnavailableDays: null, + }; +}; + +/** + * It compliments `getUiValueFromPeriodType` + */ +const getPeriodTypeFromUiValue = (uiValue: { value: PeriodType; rollingExcludeUnavailableDays: boolean }) => { + if (uiValue.value === PeriodType.ROLLING && uiValue.rollingExcludeUnavailableDays === true) { + return PeriodType.ROLLING_WINDOW; + } + + return uiValue.value; +}; + +function RangeLimitRadioItem({ + isDisabled, + formMethods, + radioValue, +}: { + radioValue: string; + isDisabled: boolean; + formMethods: UseFormReturn; +}) { + const { t } = useLocale(); + return ( +
+ {!isDisabled && ( + + + + )} +
+ {t("within_date_range")}  +
+ ( + { + onChange({ + startDate, + endDate, + }); + }} + /> + )} + /> +
+
+
+ ); +} + +function RollingLimitRadioItem({ + radioValue, + isDisabled, + formMethods, + onChange, + rollingExcludeUnavailableDays, +}: { + radioValue: IPeriodType; + isDisabled: boolean; + formMethods: UseFormReturn; + onChange: (opt: { value: number } | null) => void; + rollingExcludeUnavailableDays: boolean; +}) { + const { t } = useLocale(); + + const options = [ + { value: 0, label: t("business_days") }, + { value: 1, label: t("calendar_days") }, + ]; + const getSelectedOption = () => + options.find((opt) => opt.value === (formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0)); + + const periodDaysWatch = formMethods.watch("periodDays"); + return ( +
+ {!isDisabled && ( + + + + )} + +
+
+ + { - formMethods.setValue( - "periodCountCalendarDays", - opt?.value === 1 ? true : false, - { shouldDirty: true } - ); - }} - name="periodCoundCalendarDays" - value={optionsPeriod.find((opt) => { - opt.value === - (formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0); - })} - defaultValue={optionsPeriod.find( - (opt) => - opt.value === - (formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0) - )} - /> -
- )} - {period.type === "RANGE" && ( -
- ( - { - onChange({ - startDate, - endDate, - }); - }} - /> - )} - /> -
- )} - {period.suffix ?  {period.suffix} : null} -
+ value={watchPeriodTypeUiValue} + onValueChange={(val) => { + formMethods.setValue( + "periodType", + getPeriodTypeFromUiValue({ + value: val as IPeriodType, + rollingExcludeUnavailableDays: formMethods.getValues("rollingExcludeUnavailableDays"), + }), + { + shouldDirty: true, + } ); - })} + }}> + {(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.ROLLING : true) && ( + { + formMethods.setValue("periodCountCalendarDays", opt?.value === 1, { + shouldDirty: true, + }); + }} + /> + )} + {(periodTypeLocked.disabled ? watchPeriodTypeUiValue === PeriodType.RANGE : true) && ( + + )}
diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index f09bf48b978df0..90f70015020c6e 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -158,6 +158,7 @@ export const EventSetupTab = ( const [animationRef] = useAutoAnimate(); + const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled"); const validLocations = formMethods.getValues("locations").filter((location) => { const eventLocation = getEventLocationType(location.type); if (!eventLocation) { @@ -444,6 +445,8 @@ export const EventSetupTab = ( data-testid="add-location" StartIcon="plus" color="minimal" + disabled={seatsEnabled} + tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined} onClick={() => setShowEmptyLocationSelect(true)}> {t("add_location")} diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index f76603cf3a5d11..a5dc455b19dffa 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -33,6 +33,7 @@ export const mapMemberToChildrenOption = ( membership: member.membership, eventTypeSlugs: member.eventTypes ?? [], avatar: member.avatar, + profile: member.profile, }, value: `${member.id ?? ""}`, label: `${member.name || member.email || ""}${!member.username ? ` (${pendingString})` : ""}`, diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 86bca3a7000fa8..548089af1a379c 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -323,7 +323,7 @@ function EventTypeSingleLayout({