Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a46375fed | ||
|
|
07de77ddc6 | ||
|
|
cf45443726 | ||
|
|
629b9391dd | ||
|
|
25da11c497 | ||
|
|
003e2630e7 | ||
|
|
4eef7138c1 | ||
|
|
253d0174be | ||
|
|
5ee5c117c4 | ||
|
|
0f268bbef7 | ||
|
|
d342e3f9e0 | ||
|
|
7f1e20fe19 | ||
|
|
3078423848 | ||
|
|
9953d73444 | ||
|
|
100136aae7 | ||
|
|
3c6e9aaa63 | ||
|
|
8fa39f2ae5 | ||
|
|
3aca72fb06 | ||
|
|
c46905e9a7 | ||
|
|
5ce96c7704 | ||
|
|
23eb77b8ac | ||
|
|
e63edc9d36 | ||
|
|
d33681eddd | ||
|
|
e7816cfb0c | ||
|
|
a268d8743d | ||
|
|
45db5c0d5e | ||
|
|
583661a15d | ||
|
|
461d5e0535 | ||
|
|
099d830e7e | ||
|
|
add59896f5 | ||
|
|
03b97fdb51 | ||
|
|
4c576fbd57 | ||
|
|
06ce1988f3 | ||
|
|
c059d79a51 | ||
|
|
b91c767d0c | ||
|
|
bb29edd30f | ||
|
|
b9a8b613bb | ||
|
|
76377d2d2c | ||
|
|
f4f09eaf9e | ||
|
|
89c08868f6 | ||
|
|
ebab3b701d | ||
|
|
9ffa4b8477 | ||
|
|
c656063b06 | ||
|
|
5b7f2a6ffc | ||
|
|
63c7e4b55a | ||
|
|
24e5226e3e | ||
|
|
9f43c5f1e7 | ||
|
|
b48e3c7bb4 | ||
|
|
a561b805fd | ||
|
|
d21c91c519 | ||
|
|
41e565fcf8 | ||
|
|
2f946fc5da | ||
|
|
06bb7055ac | ||
|
|
1a8492b9c2 | ||
|
|
b8ba95dbb0 | ||
|
|
40df4cf511 | ||
|
|
58cabe974c | ||
|
|
199d95b35f | ||
|
|
c6febad364 | ||
|
|
0ddcc068ce | ||
|
|
3ff3a97c78 | ||
|
|
baaa1fa71e | ||
|
|
27d331ad2d | ||
|
|
9a1a86ed3a | ||
|
|
7902c6157d | ||
|
|
92d0fc6eb4 | ||
|
|
4d0b417925 | ||
|
|
9947b5ec85 | ||
|
|
fa06f38d47 | ||
|
|
99a6b1c7eb | ||
|
|
e3861ad22a | ||
|
|
37caa57798 | ||
|
|
6e66f7f8f8 | ||
|
|
62dd5ea95b | ||
|
|
365dfe9178 | ||
|
|
4d89da99a7 | ||
|
|
6954ad1ea5 | ||
|
|
825573da8e | ||
|
|
2c517f4878 | ||
|
|
8508a6f1a0 | ||
|
|
db03e9db20 | ||
|
|
e6a16f5b5c | ||
|
|
b94aa603c5 | ||
|
|
31e6b88951 | ||
|
|
fbff4db2a6 | ||
|
|
ba61731689 | ||
|
|
e16662e655 | ||
|
|
53c37a9ddf | ||
|
|
64ef2703a5 | ||
|
|
a997edd8db | ||
|
|
bd73eba528 | ||
|
|
0d53c2069e | ||
|
|
3b559525a8 | ||
|
|
33e1abf4a7 | ||
|
|
059c6c37ba | ||
|
|
14bc489d70 | ||
|
|
e8b36d5b0b | ||
|
|
5983c960a6 | ||
|
|
40305b9c5a | ||
|
|
dcc62c6db0 | ||
|
|
07f14e4e14 | ||
|
|
f940f6bd87 | ||
|
|
ca5dcfd1e8 | ||
|
|
324728d4ec | ||
|
|
2f7cc05cad |
+7
-3
@@ -15,10 +15,14 @@
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
#
|
||||
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
|
||||
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
||||
# @see https://console.cal.com
|
||||
|
||||
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||
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="https://goblin.cal.com"
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
title: ""
|
||||
labels: 🐛 bug
|
||||
description: "Please make sure you have checked for any duplicate issues."
|
||||
title: "(bug)"
|
||||
labels: ["🐛 bug"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://go.cal.com/discord
|
||||
about: Ask a general question about the project on our discord community
|
||||
url: https://github.com/calcom/cal.com/discussions
|
||||
about: Need help selfhosting or ask a general question about the project? Open a discussion
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
---
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
about: Suggest a feature or idea
|
||||
title: ""
|
||||
labels: ["✨ feature", "🚨 needs approval"],
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternate-solution-description
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
id: cal-info
|
||||
attributes:
|
||||
value: |
|
||||
### How we code at Cal.com
|
||||
labels: ["✨ feature", "🚨 needs approval"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Requirement/Document
|
||||
|
||||
<!--
|
||||
Is there any type of document that could support that feature?
|
||||
-->
|
||||
|
||||
(Share it here.)
|
||||
|
||||
---
|
||||
##### House rules
|
||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
|
||||
@@ -69,12 +69,6 @@ jobs:
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Production Builds
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
|
||||
@@ -10,6 +10,8 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build API v2
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
|
||||
@@ -3,6 +3,8 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check-types:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: E2E
|
||||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }}
|
||||
API_PORT: ${{ vars.CI_API_V2_PORT }}
|
||||
CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
IS_E2E: true
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
name: E2E API v2
|
||||
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
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
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/yarn-playwright-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
yarn test:e2e
|
||||
EXIT_CODE=$?
|
||||
echo "yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-api-v2
|
||||
path: test-results
|
||||
@@ -1,6 +1,9 @@
|
||||
name: E2E App Store Tests
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: E2E Embed React tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: E2E
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Integration
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Lint
|
||||
on:
|
||||
workflow_call:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
@@ -16,13 +19,6 @@ jobs:
|
||||
- name: Merge lint reports
|
||||
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
report-json: "lint-results/eslint_report.json"
|
||||
only-pr-files: false
|
||||
|
||||
- name: Upload ESLint report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: "Next.js Bundle Analysis Annotation"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
annotate:
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Download base branch bundle stats
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
if: success()
|
||||
with:
|
||||
workflow: nextjs-bundle-analysis.yml
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
path: apps/web/.next/analyze/base
|
||||
|
||||
# And here's the second place - this runs after we have both the current and
|
||||
# base branch bundle stats, and will compare them to determine what changed.
|
||||
# There are two configurable arguments that come from package.json:
|
||||
#
|
||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||
# it's set to 350kb here by default, as informed by the following piece:
|
||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||
#
|
||||
# - red-status-percentage: sets the percent size increase where you get a red
|
||||
# status indicator, defaults to 20%
|
||||
#
|
||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||
# entry in your package.json file.
|
||||
|
||||
- name: Compare with base branch bundle
|
||||
if: success()
|
||||
run: |
|
||||
cd apps/web
|
||||
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||
|
||||
- name: Get comment body
|
||||
id: get-comment-body
|
||||
if: success() && github.event.number
|
||||
run: |
|
||||
cd apps/web
|
||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v2
|
||||
if: success() && github.event.number
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||
|
||||
- name: Create Comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
|
||||
- name: Update Comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
@@ -63,65 +67,3 @@ jobs:
|
||||
with:
|
||||
name: bundle
|
||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||
|
||||
- name: Download base branch bundle stats
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
if: success()
|
||||
with:
|
||||
workflow: nextjs-bundle-analysis.yml
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
path: apps/web/.next/analyze/base
|
||||
|
||||
# And here's the second place - this runs after we have both the current and
|
||||
# base branch bundle stats, and will compare them to determine what changed.
|
||||
# There are two configurable arguments that come from package.json:
|
||||
#
|
||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||
# it's set to 350kb here by default, as informed by the following piece:
|
||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||
#
|
||||
# - red-status-percentage: sets the percent size increase where you get a red
|
||||
# status indicator, defaults to 20%
|
||||
#
|
||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||
# entry in your package.json file.
|
||||
- name: Compare with base branch bundle
|
||||
if: success()
|
||||
run: |
|
||||
cd apps/web
|
||||
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||
|
||||
- name: Get comment body
|
||||
id: get-comment-body
|
||||
if: success() && github.event.number
|
||||
run: |
|
||||
cd apps/web
|
||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v2
|
||||
if: success() && github.event.number
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||
|
||||
- name: Create Comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
|
||||
- name: Update Comment
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
- gh-actions-test-branch
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -39,6 +43,8 @@ jobs:
|
||||
needs: [changes]
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
name: Check for E2E label
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||
steps:
|
||||
@@ -141,6 +147,13 @@ jobs:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-api-v2:
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-api-v2.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
@@ -169,7 +182,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-api-v2, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Pre-release checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
app-store:
|
||||
- 'apps/web/**'
|
||||
- 'packages/app-store/**'
|
||||
- 'playwright.config.ts'
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: E2E App Store tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: E2E embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
|
||||
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
|
||||
@@ -2,6 +2,9 @@ name: Production Builds
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
name: Unit
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_run:
|
||||
workflows: [Crowdin Action]
|
||||
types: [completed]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
name: Unit
|
||||
@@ -13,7 +12,11 @@ jobs:
|
||||
- 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
|
||||
- run: yarn test
|
||||
# We could add different timezones here that we need to run our tests in
|
||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
||||
- name: Run API v2 tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
yarn test
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Yarn install
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Yarn install & cache
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -18,9 +18,12 @@ export const dateNotInPast = function (date: Date) {
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
const licenseKeyService = await LicenseKeyService.create();
|
||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||
|
||||
if (!hasValidLicense && IS_PRODUCTION) {
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
}
|
||||
// Check if the apiKey query param is provided.
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||
|
||||
@@ -19,6 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
accepted: true,
|
||||
team: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
},
|
||||
|
||||
@@ -47,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 'End time of the Event'
|
||||
* rescheduleUid:
|
||||
* type: string
|
||||
* format: UID
|
||||
* description: 'Uid of the booking to reschedule'
|
||||
* responses:
|
||||
* type: object
|
||||
* required:
|
||||
|
||||
@@ -278,8 +278,8 @@ async function postHandler(req: NextApiRequest) {
|
||||
|
||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||
...parsedBody,
|
||||
userId,
|
||||
users: { connect: { id: userId } },
|
||||
userId: !!parsedBody.teamId ? null : userId,
|
||||
users: !!parsedBody.teamId ? undefined : { connect: { id: userId } },
|
||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||
};
|
||||
@@ -291,7 +291,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
await checkUserMembership(req);
|
||||
}
|
||||
|
||||
if (isSystemWideAdmin && parsedBody.userId) {
|
||||
if (isSystemWideAdmin && parsedBody.userId && !parsedBody.teamId) {
|
||||
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
|
||||
* ownerId:
|
||||
* type: number
|
||||
* description: ID of the team owner - only admins can set this.
|
||||
* parentId:
|
||||
* type: number
|
||||
* description: ID of the parent organization.
|
||||
* tags:
|
||||
* - teams
|
||||
* responses:
|
||||
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
}
|
||||
|
||||
// Check if parentId is related to this user
|
||||
// Check if parentId is related to this user and is an organization
|
||||
if (data.parentId) {
|
||||
const parentTeam = await prisma.team.findFirst({
|
||||
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
|
||||
if (!parentTeam)
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
|
||||
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
|
||||
});
|
||||
|
||||
if (parentTeam.parentId)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "parentId must be of an organization, not a team.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
...cloneData,
|
||||
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
return {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
|
||||
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Booking } from "@calcom/prisma";
|
||||
|
||||
import handler from "../../../pages/api/bookings/_post";
|
||||
|
||||
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
||||
|
||||
describe("Success", () => {
|
||||
describe("Regular event-type", () => {
|
||||
let createdBooking: Booking;
|
||||
test("Creates one single booking", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
||||
|
||||
await handler(req, res);
|
||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||
|
||||
createdBooking = JSON.parse(res._getData());
|
||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("Reschedule created booking", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
name: "testReschedule",
|
||||
start: dayjs().add(2, "day").format(),
|
||||
end: dayjs().add(3, "day").format(),
|
||||
eventTypeId: 2,
|
||||
email: "test@example.com",
|
||||
location: "Cal.com Video",
|
||||
timeZone: "America/Montevideo",
|
||||
language: "en",
|
||||
customInputs: [],
|
||||
metadata: {},
|
||||
userId: 4,
|
||||
rescheduleUid: createdBooking.uid,
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||
|
||||
await handler(req, res);
|
||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
||||
const previousBooking = await prisma.booking.findUnique({
|
||||
where: { uid: createdBooking.uid },
|
||||
});
|
||||
expect(previousBooking?.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recurring event-type", () => {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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 { buildEventType } from "@calcom/lib/test/builder";
|
||||
|
||||
import handler from "../../../pages/api/event-types/_post";
|
||||
import checkParentEventOwnership from "../../../pages/api/event-types/_utils/checkParentEventOwnership";
|
||||
import checkTeamEventEditPermission from "../../../pages/api/event-types/_utils/checkTeamEventEditPermission";
|
||||
import checkUserMembership from "../../../pages/api/event-types/_utils/checkUserMembership";
|
||||
import ensureOnlyMembersAsHosts from "../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts";
|
||||
import { canUserAccessTeamWithRole } from "../../../pages/api/teams/[teamId]/_auth-middleware";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
|
||||
vi.mock("../../../pages/api/teams/[teamId]/_auth-middleware", () => ({
|
||||
canUserAccessTeamWithRole: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../pages/api/event-types/_utils/checkUserMembership", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../pages/api/event-types/_utils/checkParentEventOwnership", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../pages/api/event-types/_utils/checkTeamEventEditPermission", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/event-types", () => {
|
||||
describe("Errors", () => {
|
||||
test("should throw 401 if a non-admin user tries to create an event type with userId", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
title: "Tennis class",
|
||||
slug: "tennis-class-{{$guid}}",
|
||||
length: 60,
|
||||
hidden: true,
|
||||
userId: memberUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `userId`");
|
||||
});
|
||||
test("should throw 401 if not system-wide admin and user cannot access teamId with required roles", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
title: "Tennis class",
|
||||
slug: "tennis-class-{{$guid}}",
|
||||
length: 60,
|
||||
hidden: true,
|
||||
teamId: 9999,
|
||||
},
|
||||
});
|
||||
req.userId = memberUserId;
|
||||
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `teamId`");
|
||||
});
|
||||
test("should throw 400 if system-wide admin but neither userId nor teamId provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
title: "Tennis class",
|
||||
slug: "tennis-class-{{$guid}}",
|
||||
length: 60,
|
||||
hidden: true,
|
||||
},
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res._getData()).message).toBe("`userId` or `teamId` required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success", () => {
|
||||
test("should call checkParentEventOwnership and checkUserMembership if parentId is present", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
title: "Tennis class",
|
||||
slug: "tennis-class-{{$guid}}",
|
||||
length: 60,
|
||||
hidden: true,
|
||||
userId: memberUserId,
|
||||
parentId: 9999,
|
||||
teamId: 9999,
|
||||
},
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUserId;
|
||||
|
||||
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
|
||||
|
||||
await handler(req, res);
|
||||
expect(checkParentEventOwnership).toHaveBeenCalled();
|
||||
expect(checkUserMembership).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should create event type successfully if all conditions are met", async () => {
|
||||
const eventTypeTest = buildEventType();
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
title: "test title",
|
||||
slug: "test-slug",
|
||||
length: 60,
|
||||
hidden: true,
|
||||
userId: memberUserId,
|
||||
parentId: 9999,
|
||||
teamId: 9999,
|
||||
},
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUserId;
|
||||
|
||||
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true);
|
||||
vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined);
|
||||
vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined);
|
||||
vi.mocked(checkTeamEventEditPermission).mockImplementationOnce(async () => undefined);
|
||||
vi.mocked(ensureOnlyMembersAsHosts).mockImplementationOnce(async () => undefined);
|
||||
|
||||
prismaMock.eventType.create.mockResolvedValue(eventTypeTest);
|
||||
|
||||
await handler(req, res);
|
||||
const data = JSON.parse(res._getData());
|
||||
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledTimes(1);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(data.message).toBe("Event type created successfully");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@ 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 { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
@@ -20,20 +20,21 @@ afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
|
||||
return {
|
||||
default: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/lib/utils/isAdmin", () => {
|
||||
return {
|
||||
isAdminGuard: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@calcom/prisma");
|
||||
vi.mock("~/lib/utils/isAdmin", () => ({
|
||||
isAdminGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Verify API key", () => {
|
||||
it("It should throw an error if the api key is not valid", async () => {
|
||||
let service: LicenseKeyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = await LicenseKeyService.create();
|
||||
|
||||
vi.spyOn(service, "checkLicense");
|
||||
});
|
||||
|
||||
it("should throw an error if the api key is not valid", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
@@ -43,7 +44,7 @@ describe("Verify API key", () => {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(checkLicense).mockResolvedValue(false);
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(false);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
@@ -53,9 +54,11 @@ describe("Verify API key", () => {
|
||||
await middleware.fn(req, res, serverNext);
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
it("It should throw an error if no api key is provided", async () => {
|
||||
|
||||
it("should throw an error if no api key is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
@@ -65,7 +68,7 @@ describe("Verify API key", () => {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
@@ -75,10 +78,11 @@ describe("Verify API key", () => {
|
||||
await middleware.fn(req, res, serverNext);
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("It should set correct permissions for system-wide admin", async () => {
|
||||
it("should set correct permissions for system-wide admin", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
@@ -97,7 +101,7 @@ describe("Verify API key", () => {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
@@ -107,11 +111,12 @@ describe("Verify API key", () => {
|
||||
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 () => {
|
||||
it("should set correct permissions for org-level admin", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
@@ -130,7 +135,7 @@ describe("Verify API key", () => {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
@@ -140,6 +145,7 @@ describe("Verify API key", () => {
|
||||
await middleware.fn(req, res, serverNext);
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
|
||||
expect(req.isSystemWideAdmin).toBe(false);
|
||||
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import handler from "../../../pages/api/selected-calendars/_post";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("POST /api/selected-calendars", () => {
|
||||
describe("Errors", () => {
|
||||
test("Returns 403 if non-admin user tries to set userId in body", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
integration: "google",
|
||||
externalId: "ext123",
|
||||
userId: 444444,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = 333333;
|
||||
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(HttpError);
|
||||
expect((e as HttpError).statusCode).toBe(403);
|
||||
expect((e as HttpError).message).toBe("ADMIN required for userId");
|
||||
}
|
||||
});
|
||||
|
||||
test("Returns 400 if request body is invalid", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
integration: "google",
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = 333333;
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success", () => {
|
||||
test("Creates selected calendar if user is admin and sets bodyUserId", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
query: {
|
||||
apiKey: "validApiKey",
|
||||
},
|
||||
body: {
|
||||
integration: "google",
|
||||
externalId: "ext123",
|
||||
userId: 444444,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = 333333;
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||
id: 444444,
|
||||
} as any);
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
integration: "google",
|
||||
externalId: "ext123",
|
||||
userId: 444444,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const responseData = JSON.parse(res._getData());
|
||||
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||
});
|
||||
|
||||
test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
query: {
|
||||
apiKey: "validApiKey",
|
||||
},
|
||||
body: {
|
||||
integration: "google",
|
||||
externalId: "ext123",
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = 333333;
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
integration: "google",
|
||||
externalId: "ext123",
|
||||
userId: 333333,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const responseData = JSON.parse(res._getData());
|
||||
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe("isAdmin guard", () => {
|
||||
expect(scope).toBe(ScopeOfAdmin.SystemWide);
|
||||
});
|
||||
|
||||
it("Returns org-wide admin when user is set as such", async () => {
|
||||
it("Returns org-wide admin when user is set as such & admin API access is granted", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
@@ -67,10 +67,21 @@ describe("isAdmin guard", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("Returns no admin when user is set as org admin but admin API access is revoked", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
const { isAdmin } = await isAdminGuard(req);
|
||||
expect(isAdmin).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Stripe Billing
|
||||
STRIPE_PRICE_ID_STARTER=
|
||||
STRIPE_PRICE_ID_STARTER_OVERAGE=
|
||||
STRIPE_PRICE_ID_ESSENTIALS=
|
||||
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE=
|
||||
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/user-attachments/assets/ce7c2ecf-6097-469a-8512-c846a9fb665d" height="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "yarn dev:build && jest --runInBand --config ./jest-e2e.json",
|
||||
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||
@@ -26,12 +26,13 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries-0.0.20": "npm:@calcom/platform-libraries@0.0.20",
|
||||
"@calcom/platform-libraries-0.0.23": "npm:@calcom/platform-libraries@0.0.23",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
|
||||
@@ -11,10 +11,11 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
@@ -26,21 +27,20 @@ import { AppController } from "./app.controller";
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
|
||||
RedisModule,
|
||||
BullModule.forRoot({
|
||||
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
name: "short",
|
||||
ttl: seconds(10),
|
||||
limit: 3,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
ttl: seconds(30),
|
||||
limit: 10,
|
||||
name: "long",
|
||||
ttl: seconds(60), // Time to live for the long period in seconds
|
||||
limit: 120, // Maximum number of requests within the long ttl
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
@@ -57,6 +57,10 @@ import { AppController } from "./app.controller";
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
@@ -19,7 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.20";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.23";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
@@ -90,7 +91,10 @@ describe("Bookings Endpoints", () => {
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {};
|
||||
const bookingMetadata = {
|
||||
timeFormat: "12",
|
||||
meetingType: "organizer-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
@@ -102,7 +106,7 @@ describe("Bookings Endpoints", () => {
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body = {
|
||||
const body: CreateBookingInput = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
@@ -135,6 +139,64 @@ describe("Bookings Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("should reschedule a booking", () => {
|
||||
it("should reschedule with updated start time, end time & metadata", async () => {
|
||||
const newBookingStart = "2040-05-21T12:30:00.000Z";
|
||||
const newBookingEnd = "2040-05-21T13:30:00.000Z";
|
||||
const bookingEventTypeId = eventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const newBookingMetadata = {
|
||||
timeFormat: "24",
|
||||
meetingType: "attendee-phone",
|
||||
};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
rescheduleUid: createdBooking.uid,
|
||||
start: newBookingStart,
|
||||
end: newBookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: newBookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
expect(responseBody.data.uid).toBeDefined();
|
||||
expect(responseBody.data.startTime).toEqual(newBookingStart);
|
||||
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
|
||||
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
|
||||
expect(responseBody.data.metadata).toEqual(newBookingMetadata);
|
||||
|
||||
createdBooking = responseBody.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -150,17 +150,21 @@ export class BookingsController {
|
||||
@Req() req: BookingRequest,
|
||||
@Body() body: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
fromReschedule: booking.fromReschedule,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
@@ -177,14 +181,22 @@ export class BookingsController {
|
||||
@Param("bookingId") bookingId: string,
|
||||
@Body() _: CancelBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse> {
|
||||
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
if (bookingId) {
|
||||
try {
|
||||
req.body.id = parseInt(bookingId);
|
||||
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
bookingId: res.bookingId,
|
||||
bookingUid: res.bookingUid,
|
||||
onlyRemovedAttendee: res.onlyRemovedAttendee,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err);
|
||||
@@ -228,7 +240,14 @@ export class BookingsController {
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
createdBookings.forEach(async (booking) => {
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -253,7 +272,15 @@ export class BookingsController {
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
||||
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||
const now = new Date();
|
||||
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
|
||||
now.setSeconds(now.getSeconds() + 10);
|
||||
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
|
||||
uid: instantMeeting.bookingUid,
|
||||
startTime: now,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -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-0.0.20";
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.23";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -107,15 +107,16 @@ describe("Platform Calendars Endpoints", () => {
|
||||
.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();
|
||||
});
|
||||
// TODO: Uncomment this once CI is ready to run proper Office365 tests
|
||||
// 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())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import {
|
||||
@@ -196,7 +197,7 @@ export class CalendarsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteCalendarCredentials(
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: { id: number },
|
||||
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
|
||||
const { id: credentialId } = body;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsInt } from "class-validator";
|
||||
|
||||
export class DeleteCalendarCredentialsInputBodyDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: 10,
|
||||
description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint",
|
||||
type: "integer",
|
||||
required: true,
|
||||
})
|
||||
readonly id!: number;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.20";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.23";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.23";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import {
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -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-0.0.20";
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.23";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.20";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.20";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.23";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.23";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.23";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
|
||||
@@ -22,4 +22,7 @@ export class MeOutput {
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId!: number | null;
|
||||
}
|
||||
|
||||
+31
@@ -66,6 +66,37 @@ describe("Schedules Endpoints", () => {
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not create an invalid schedule", async () => {
|
||||
const scheduleName = "schedule-name";
|
||||
const scheduleTimeZone = "Europe/Rome";
|
||||
const isDefault = true;
|
||||
|
||||
const body = {
|
||||
name: scheduleName,
|
||||
timeZone: scheduleTimeZone,
|
||||
isDefault,
|
||||
availabilities: [
|
||||
{
|
||||
days: ["Monday"],
|
||||
endTime: "11:15",
|
||||
startTime: "10:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(400)
|
||||
.then(async (response) => {
|
||||
expect(response.body.status).toEqual("error");
|
||||
expect(response.body.error.message).toEqual(
|
||||
"Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: 11:15"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a default schedule", async () => {
|
||||
const scheduleName = "schedule-name";
|
||||
const scheduleTimeZone = "Europe/Rome";
|
||||
|
||||
+11
-1
@@ -19,8 +19,18 @@ export class CreateAvailabilityInput_2024_04_15 {
|
||||
}
|
||||
|
||||
function transformStringToDate(value: string, key: string): Date {
|
||||
// note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z
|
||||
if (!value) {
|
||||
throw new BadRequestException(
|
||||
`Missing ${key}. Expected value is in ISO8061 format e.g. 2025-0412T13:17:56.324Z`
|
||||
);
|
||||
}
|
||||
|
||||
const dateTimeParts = value.split("T");
|
||||
if (dateTimeParts.length !== 2) {
|
||||
throw new BadRequestException(
|
||||
`Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
|
||||
const parts = timePart.split(":");
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries-0.0.20";
|
||||
} from "@calcom/platform-libraries-0.0.23";
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
|
||||
@@ -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-0.0.20";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.23";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
|
||||
@@ -0,0 +1,123 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
describe("PlatformPlanGuard", () => {
|
||||
let guard: PlatformPlanGuard;
|
||||
let reflector: Reflector;
|
||||
let organizationsRepository: OrganizationsRepository;
|
||||
let redisService: RedisService;
|
||||
|
||||
const mockContext = createMockExecutionContext({
|
||||
params: { teamId: "1", orgId: "1" },
|
||||
user: { id: "1" },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
reflector = new Reflector();
|
||||
organizationsRepository = createMock<OrganizationsRepository>();
|
||||
redisService = createMock<RedisService>({
|
||||
redis: {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return true", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: true,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
platformBilling: {
|
||||
subscriptionId: "sub_123",
|
||||
plan: "SCALE",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the organization does not exist", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the organization is not platform", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: false,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
platformBilling: undefined,
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the organization has no subscription", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: true,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
platformBilling: {
|
||||
subscriptionId: null,
|
||||
plan: "STARTER",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the user has a lower plan than required", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: true,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
platformBilling: {
|
||||
subscriptionId: "sub_123",
|
||||
plan: "STARTER",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the result is cached in Redis", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the result is cached in Redis", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||
return createMock<ExecutionContext>({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
params: context.params,
|
||||
user: context.user,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class PlatformPlanGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const teamId = request.params.teamId as string;
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as GetUserReturnType;
|
||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:platformbilling:${minimumPlan}`;
|
||||
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
|
||||
if (user && orgId) {
|
||||
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||
const isPlatform = team?.isPlatform;
|
||||
const hasSubscription = team?.platformBilling?.subscriptionId;
|
||||
|
||||
if (!team) {
|
||||
canAccess = false;
|
||||
} else if (!isPlatform) {
|
||||
canAccess = true;
|
||||
} else if (!hasSubscription) {
|
||||
canAccess = false;
|
||||
} else {
|
||||
canAccess = hasMinimumPlan({
|
||||
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
||||
minimumPlan: minimumPlan,
|
||||
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
}
|
||||
}
|
||||
|
||||
type HasMinimumPlanProp = {
|
||||
currentPlan: PlatformPlanType;
|
||||
minimumPlan: PlatformPlanType;
|
||||
plans: PlatformPlanType[];
|
||||
};
|
||||
|
||||
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
|
||||
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
|
||||
|
||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||
);
|
||||
}
|
||||
|
||||
return currentPlanIndex >= minimumPlanIndex;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
type CachedData = {
|
||||
org?: Team;
|
||||
canAccess?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
constructor(
|
||||
private organizationsRepository: OrganizationsRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization && !org?.isPlatform) {
|
||||
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
||||
Number(organizationId)
|
||||
);
|
||||
if (!adminAPIAccessIsEnabledInOrg) {
|
||||
throw new ForbiddenException(
|
||||
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||
);
|
||||
}
|
||||
}
|
||||
canAccess = true;
|
||||
org &&
|
||||
(await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
return canAccess;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { MockedRedisService } from "test/mocks/mock-redis-service";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||
|
||||
describe("ApiAuthStrategy", () => {
|
||||
@@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => {
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const validApiKeyEmail = "api-key-user-email@example.com";
|
||||
const validAccessTokenEmail = "access-token-user-email@example.com";
|
||||
const validOAuthEmail = "oauth-user@example.com";
|
||||
|
||||
let validApiKeyUser: User;
|
||||
let validAccessTokenUser: User;
|
||||
let validOAuthUser: User;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [
|
||||
MockedRedisService,
|
||||
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
validApiKeyUser = await userRepositoryFixture.create({
|
||||
email: validApiKeyEmail,
|
||||
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
|
||||
validAccessTokenUser = await userRepositoryFixture.create({
|
||||
email: validAccessTokenEmail,
|
||||
});
|
||||
|
||||
validOAuthUser = await userRepositoryFixture.create({
|
||||
email: validOAuthEmail,
|
||||
});
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: validOAuthEmail,
|
||||
user: { connect: { id: validOAuthUser.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
|
||||
oAuthClient.id
|
||||
);
|
||||
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
get: (key: string) =>
|
||||
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const user = await strategy.accessTokenStrategy(accessToken);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toEqual(validAccessTokenUser.id);
|
||||
});
|
||||
|
||||
it("should return user associated with valid api key", async () => {
|
||||
@@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => {
|
||||
now.setDate(now.getDate() + 1);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
|
||||
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
authorization: `Bearer cal_test_${keyString}`,
|
||||
},
|
||||
get: (key: string) =>
|
||||
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const user = await strategy.apiKeyStrategy(keyString);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) expect(user.id).toEqual(validApiKeyUser.id);
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toEqual(validApiKeyUser.id);
|
||||
});
|
||||
|
||||
it("should return user associated with valid OAuth client", async () => {
|
||||
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toEqual(validOAuthUser.id);
|
||||
});
|
||||
|
||||
it("should throw 401 if api key is invalid", async () => {
|
||||
@@ -158,7 +162,55 @@ describe("ApiAuthStrategy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if Authorization header does not contain auth token", async () => {
|
||||
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
|
||||
[X_CAL_SECRET_KEY]: `secret`,
|
||||
},
|
||||
get: (key: string) =>
|
||||
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
try {
|
||||
await strategy.authenticate(request);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
expect(error.getStatus()).toEqual(401);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if OAuth secret is invalid", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
|
||||
[X_CAL_SECRET_KEY]: `gibberish`,
|
||||
},
|
||||
get: (key: string) =>
|
||||
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
try {
|
||||
await strategy.authenticate(request);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
expect(error.getStatus()).toEqual(401);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
|
||||
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
|
||||
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await userRepositoryFixture.delete(validApiKeyUser.id);
|
||||
await userRepositoryFixture.delete(validAccessTokenUser.id);
|
||||
await userRepositoryFixture.delete(validOAuthUser.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
module.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||
@@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
private readonly oauthFlowService: OAuthFlowService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly apiKeyRepository: ApiKeyRepository
|
||||
private readonly apiKeyRepository: ApiKeyRepository,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly profilesRepository: ProfilesRepository
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticate(request: Request) {
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!authString) {
|
||||
return this.error(new UnauthorizedException("No Authorization header provided"));
|
||||
try {
|
||||
const { params } = request;
|
||||
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
|
||||
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret) {
|
||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
const requestOrigin = request.get("Origin");
|
||||
return await this.authenticateBearerToken(bearerToken, requestOrigin);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
}
|
||||
return this.error(
|
||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
|
||||
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
|
||||
return this.success(user);
|
||||
}
|
||||
|
||||
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
if (!client) {
|
||||
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
||||
}
|
||||
|
||||
const requestOrigin = request.get("Origin");
|
||||
if (client.secret !== oAuthClientSecret) {
|
||||
throw new UnauthorizedException("Invalid client secret");
|
||||
}
|
||||
|
||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||
|
||||
if (!platformCreatorId) {
|
||||
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
|
||||
try {
|
||||
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? await this.apiKeyStrategy(authString)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
@@ -6,11 +7,24 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
MembershipsModule,
|
||||
OrganizationsModule,
|
||||
BullModule.registerQueue({
|
||||
name: "billing",
|
||||
limiter: {
|
||||
max: 1,
|
||||
duration: 1000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
|
||||
exports: [BillingService, BillingRepository],
|
||||
controllers: [BillingController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Process, Processor } from "@nestjs/bull";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Job } from "bull";
|
||||
|
||||
export const INCREMENT_JOB = "increment";
|
||||
export const BILLING_QUEUE = "billing";
|
||||
export type IncrementJobDataType = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export type DecrementJobDataType = IncrementJobDataType;
|
||||
|
||||
@Processor(BILLING_QUEUE)
|
||||
export class BillingProcessor {
|
||||
private readonly logger = new Logger(BillingProcessor.name);
|
||||
|
||||
constructor(
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Process(INCREMENT_JOB)
|
||||
async handleIncrement(job: Job<IncrementJobDataType>) {
|
||||
const { userId } = job.data;
|
||||
try {
|
||||
// get the platform organization of the managed user
|
||||
const team = await this.teamsRepository.findPlatformOrgFromUserId(userId);
|
||||
const teamId = team.id;
|
||||
if (!team.id) {
|
||||
this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, {
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, {
|
||||
teamId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
if (!stripeSubscription?.id) {
|
||||
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
|
||||
teamId,
|
||||
subscriptionId: billingSubscription.subscriptionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const meteredItem = stripeSubscription.items.data.find(
|
||||
(item) => item.price?.recurring?.usage_type === "metered"
|
||||
);
|
||||
// no metered item found to increase usage, return early
|
||||
if (!meteredItem) {
|
||||
this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, {
|
||||
teamId,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
this.logger.log("Increased organization usage for subscription", {
|
||||
subscriptionId: billingSubscription.subscriptionId,
|
||||
teamId,
|
||||
userId,
|
||||
itemId: meteredItem.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to increase usage for Organization", {
|
||||
userId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,38 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BillingConfigService {
|
||||
private readonly config: Map<PlatformPlan, string>;
|
||||
private readonly config: Map<
|
||||
PlatformPlan,
|
||||
{
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.config = new Map<PlatformPlan, string>();
|
||||
this.config = new Map<
|
||||
PlatformPlan,
|
||||
{
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
|
||||
for (const key of planKeys) {
|
||||
this.config.set(
|
||||
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
|
||||
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
|
||||
);
|
||||
this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], {
|
||||
base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "",
|
||||
overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(plan: PlatformPlan): string | undefined {
|
||||
get(plan: PlatformPlan):
|
||||
| {
|
||||
base: string;
|
||||
overage: string;
|
||||
}
|
||||
| undefined {
|
||||
return this.config.get(plan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Queue } from "bull";
|
||||
import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
export class BillingService implements OnModuleDestroy {
|
||||
private logger = new Logger("BillingService");
|
||||
private readonly webAppUrl: string;
|
||||
|
||||
@@ -19,9 +22,10 @@ export class BillingService {
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
||||
) {
|
||||
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
@@ -58,7 +62,10 @@ export class BillingService {
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: this.billingConfigService.get(plan),
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
},
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
@@ -99,46 +106,56 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
async increaseUsageForTeam(teamId: number) {
|
||||
try {
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error("Team did not have stripe subscription associated to it", {
|
||||
teamId,
|
||||
});
|
||||
return void 0;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* Adds a job to the queue to increment usage of a stripe subscription.
|
||||
* we delay the job until the booking starts.
|
||||
* the delay ensure we can adapt to cancel / reschedule.
|
||||
*/
|
||||
async increaseUsageByUserId(
|
||||
userId: number,
|
||||
booking: {
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) {
|
||||
const { uid, startTime, fromReschedule } = booking;
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const item = stripeSubscription.items.data[0];
|
||||
// legacy plans are licensed, we cannot create usage records against them
|
||||
if (item.price?.recurring?.usage_type === "licensed") {
|
||||
return void 0;
|
||||
}
|
||||
const delay = startTime.getTime() - Date.now();
|
||||
if (fromReschedule) {
|
||||
// cancel the usage increment job for the booking that is being rescheduled
|
||||
await this.cancelUsageByBookingUid(fromReschedule);
|
||||
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
|
||||
}
|
||||
await this.billingQueue.add(
|
||||
INCREMENT_JOB,
|
||||
{
|
||||
userId,
|
||||
} satisfies IncrementJobDataType,
|
||||
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
|
||||
);
|
||||
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
} catch (error) {
|
||||
// don't fail the request, log it.
|
||||
this.logger.error("Failed to increase usage for team", {
|
||||
teamId: teamId,
|
||||
error,
|
||||
});
|
||||
/**
|
||||
*
|
||||
* Cancels the usage increment job for a booking when it is cancelled.
|
||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||
*/
|
||||
async cancelUsageByBookingUid(bookingUid: string) {
|
||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
|
||||
}
|
||||
}
|
||||
|
||||
async increaseUsageByClientId(clientId: string) {
|
||||
if (this.configService.get("e2e")) {
|
||||
return void 0;
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
||||
|
||||
return this.increaseUsageForTeam(team?.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ export enum PlatformPlan {
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.20";
|
||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.23";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
|
||||
@@ -6,9 +6,17 @@ import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
|
||||
@Module({
|
||||
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule, UsersModule],
|
||||
imports: [
|
||||
OAuthClientModule,
|
||||
BillingModule,
|
||||
PlatformEndpointsModule,
|
||||
TimezoneModule,
|
||||
UsersModule,
|
||||
WebhooksModule,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import {
|
||||
EventTypeWebhookOutputResponseDto,
|
||||
EventTypeWebhooksOutputResponseDto,
|
||||
} from "@/modules/webhooks/outputs/event-type-webhook.output";
|
||||
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { EventType, Webhook } from "@calcom/prisma/client";
|
||||
|
||||
describe("EventTypes WebhooksController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
const userEmail = "event-types-webhook-controller-e2e@api.com";
|
||||
let user: UserWithProfile;
|
||||
let otherUser: UserWithProfile;
|
||||
let eventType: EventType;
|
||||
let eventType2: EventType;
|
||||
let otherEventType: EventType;
|
||||
|
||||
let eventTypeRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let webhookRepositoryFixture: WebhookRepositoryFixture;
|
||||
|
||||
let webhook: EventTypeWebhookOutputResponseDto["data"];
|
||||
let webhook2: Webhook;
|
||||
let otherWebhook: Webhook;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
|
||||
eventTypeRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
otherUser = await userRepositoryFixture.create({
|
||||
email: "other-user-webhook-controller@api.com",
|
||||
username: "other-user-webhook-controller@api.com",
|
||||
});
|
||||
|
||||
eventType = await eventTypeRepositoryFixture.create(
|
||||
{
|
||||
title: "Event Type 1",
|
||||
slug: "webhook-event-type-1",
|
||||
length: 60,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
eventType2 = await eventTypeRepositoryFixture.create(
|
||||
{
|
||||
title: "Event Type 2",
|
||||
slug: "webhook-event-type-2",
|
||||
length: 60,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
otherEventType = await eventTypeRepositoryFixture.create(
|
||||
{
|
||||
title: "Other Event Type ",
|
||||
slug: "other-webhook-event-type",
|
||||
length: 60,
|
||||
},
|
||||
otherUser.id
|
||||
);
|
||||
|
||||
otherWebhook = await webhookRepositoryFixture.create({
|
||||
id: "2mdfnn24",
|
||||
subscriberUrl: "https://example.com",
|
||||
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
});
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
userRepositoryFixture.deleteByEmail(user.email);
|
||||
userRepositoryFixture.deleteByEmail(otherUser.email);
|
||||
webhookRepositoryFixture.delete(otherWebhook.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("/webhooks (POST)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/event-types/${eventType.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
eventTypeId: eventType.id,
|
||||
},
|
||||
} satisfies EventTypeWebhookOutputResponseDto);
|
||||
webhook = res.body.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("/webhooks (POST)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(201)
|
||||
.then(async (res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
eventTypeId: eventType2.id,
|
||||
},
|
||||
} satisfies EventTypeWebhookOutputResponseDto);
|
||||
webhook2 = res.body.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/event-types/${otherEventType.id}/webhooks`)
|
||||
.send({
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: true,
|
||||
payloadTemplate: "string",
|
||||
} satisfies CreateWebhookInputDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||
.send({
|
||||
active: false,
|
||||
} satisfies UpdateWebhookInputDto)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body.data.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||
.send({
|
||||
active: false,
|
||||
} satisfies UpdateWebhookInputDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: false,
|
||||
payloadTemplate: "string",
|
||||
eventTypeId: eventType.id,
|
||||
},
|
||||
} satisfies EventTypeWebhookOutputResponseDto);
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/event-types/${eventType.id}/webhooks/90284`).expect(404);
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to the eventType", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types/${eventType.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("/webhooks (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types/${eventType.id}/webhooks`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
|
||||
responseBody.data.forEach((webhook) => {
|
||||
expect(webhook.eventTypeId).toBe(eventType.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
|
||||
responseBody.data.forEach((webhook) => {
|
||||
expect(webhook.eventTypeId).toBe(eventType2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
subscriberUrl: "https://example.com",
|
||||
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||
active: false,
|
||||
payloadTemplate: "string",
|
||||
eventTypeId: eventType.id,
|
||||
},
|
||||
} satisfies EventTypeWebhookOutputResponseDto);
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks (DELETE)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
expect(res.body).toMatchObject({
|
||||
status: "success",
|
||||
data: "1 webhooks deleted",
|
||||
} satisfies DeleteManyWebhooksOutputResponseDto);
|
||||
});
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/event-types/${eventType.id}/webhooks/1234453`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
|
||||
import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard";
|
||||
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||
import {
|
||||
EventTypeWebhookOutputResponseDto,
|
||||
EventTypeWebhookOutputDto,
|
||||
EventTypeWebhooksOutputResponseDto,
|
||||
} from "@/modules/webhooks/outputs/event-type-webhook.output";
|
||||
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
|
||||
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
|
||||
import { EventTypeWebhooksService } from "@/modules/webhooks/services/event-type-webhooks.service";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/event-types/:eventTypeId/webhooks",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard)
|
||||
@ApiTags("Users' EventTypes Webhooks")
|
||||
export class EventTypeWebhooksController {
|
||||
constructor(
|
||||
private readonly webhooksService: WebhooksService,
|
||||
private readonly eventTypeWebhooksService: EventTypeWebhooksService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create a webhook for an event-type" })
|
||||
async createEventTypeWebhook(
|
||||
@Body() body: CreateWebhookInputDto,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<EventTypeWebhookOutputResponseDto> {
|
||||
const webhook = await this.eventTypeWebhooksService.createEventTypeWebhook(
|
||||
eventTypeId,
|
||||
new WebhookInputPipe().transform(body)
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:webhookId")
|
||||
@ApiOperation({ summary: "Update a webhook of an event-type" })
|
||||
async updateEventTypeWebhook(
|
||||
@Body() body: UpdateWebhookInputDto,
|
||||
@Param("webhookId") webhookId: string
|
||||
): Promise<EventTypeWebhookOutputResponseDto> {
|
||||
const webhook = await this.webhooksService.updateWebhook(
|
||||
webhookId,
|
||||
new PartialWebhookInputPipe().transform(body)
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:webhookId")
|
||||
@ApiOperation({ summary: "Get a webhook of an event-type" })
|
||||
async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all webhooks of an event-type" })
|
||||
async getEventTypeWebhooks(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Query() pagination: SkipTakePagination
|
||||
): Promise<EventTypeWebhooksOutputResponseDto> {
|
||||
const webhooks = await this.eventTypeWebhooksService.getEventTypeWebhooksPaginated(
|
||||
eventTypeId,
|
||||
pagination.skip ?? 0,
|
||||
pagination.take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: webhooks.map((webhook) =>
|
||||
plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:webhookId")
|
||||
@ApiOperation({ summary: "Delete a webhook of an event-type" })
|
||||
async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||
await this.webhooksService.deleteWebhook(webhook.id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/")
|
||||
@ApiOperation({ summary: "Delete all webhooks of an event-type" })
|
||||
async deleteAllEventTypeWebhooks(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<DeleteManyWebhooksOutputResponseDto> {
|
||||
const data = await this.eventTypeWebhooksService.deleteAllEventTypeWebhooks(eventTypeId);
|
||||
return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` };
|
||||
}
|
||||
}
|
||||
+269
-3
@@ -14,10 +14,11 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let postResponseData: CreateUserResponse;
|
||||
|
||||
const platformAdminEmail = "platform-sensei@mail.com";
|
||||
let platformAdmin: User;
|
||||
|
||||
const userEmail = "oauth-client-user@gmail.com";
|
||||
const userTimeZone = "Europe/Rome";
|
||||
|
||||
@@ -98,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: userEmail,
|
||||
user: { connect: { id: platformAdmin.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
@@ -188,10 +204,16 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
|
||||
expect(defaultEventTypes?.length).toEqual(4);
|
||||
expect(
|
||||
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length)
|
||||
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length)
|
||||
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
@@ -277,6 +299,250 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(platformAdmin.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User team even-types", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient1: PlatformOAuthClient;
|
||||
let oAuthClient2: PlatformOAuthClient;
|
||||
|
||||
let organization: Team;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
let owner: User;
|
||||
|
||||
let managedEventType1: EventType;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let postResponseData: CreateUserResponse;
|
||||
|
||||
const userEmail = "oauth-client-users-user@gmail.com";
|
||||
const userTimeZone = "Europe/Rome";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: "Testy Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
owner = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${owner.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: owner.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
oAuthClient1 = await createOAuthClient(organization.id);
|
||||
oAuthClient2 = await createOAuthClient(organization.id);
|
||||
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: "Testy org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: { connect: { id: oAuthClient1.id } },
|
||||
});
|
||||
|
||||
team2 = await teamRepositoryFixture.create({
|
||||
name: "Testy org team 2",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: { connect: { id: oAuthClient2.id } },
|
||||
});
|
||||
|
||||
// note(Lauris): team1 team event-types
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
managedEventType1 = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "MANAGED",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Managed Event Type",
|
||||
slug: "managed-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// note(Lauris): team2 team event-types
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team2.id },
|
||||
},
|
||||
title: "Collective Event Type team 2",
|
||||
slug: "collective-event-type-team-2",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "MANAGED",
|
||||
team: {
|
||||
connect: { id: team2.id },
|
||||
},
|
||||
title: "Managed Event Type team 2",
|
||||
slug: "managed-event-type-team-2",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
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(oAuthClient1).toBeDefined();
|
||||
});
|
||||
|
||||
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmail,
|
||||
timeZone: userTimeZone,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient1.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient1.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<{
|
||||
user: Omit<User, "password">;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> = response.body;
|
||||
|
||||
postResponseData = responseBody.data;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
await userHasCorrectEventTypes(responseBody.data.user.id);
|
||||
await teamHasCorrectEventTypes(team1.id);
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(5);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): default event types
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function teamHasCorrectEventTypes(teamId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(2);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
expect(collective).toBeTruthy();
|
||||
expect(collective?.hosts).toBeDefined();
|
||||
expect(collective?.hosts?.length).toEqual(1);
|
||||
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient1.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.delete(owner.id);
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -1,11 +1,11 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -37,7 +37,7 @@ import { Pagination } from "@calcom/platform-types";
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@DocsTags("Managed users")
|
||||
export class OAuthClientUsersController {
|
||||
private readonly logger = new Logger("UserController");
|
||||
@@ -78,6 +78,7 @@ export class OAuthClientUsersController {
|
||||
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
||||
);
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
console.log("asap createUser client", JSON.stringify(client, null, 2));
|
||||
|
||||
const isPlatformManaged = true;
|
||||
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
|
||||
|
||||
+2
-1
@@ -163,7 +163,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("User is not in an organization", () => {
|
||||
describe("User is not part of an organization", () => {
|
||||
it(`/GET`, () => {
|
||||
return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403);
|
||||
});
|
||||
@@ -371,6 +371,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
afterAll(async () => {
|
||||
await teamFixtures.delete(org.id);
|
||||
await usersFixtures.delete(user.id);
|
||||
await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-2
@@ -15,6 +15,8 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
@@ -56,8 +58,9 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let usersRepositoryFixtures: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let organization: Team;
|
||||
@@ -81,13 +84,21 @@ describe("OAuthFlow Endpoints", () => {
|
||||
await app.init();
|
||||
|
||||
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersRepositoryFixtures.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organization" });
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: userEmail,
|
||||
user: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
||||
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
||||
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
|
||||
@@ -137,7 +137,7 @@ export class OAuthFlowController {
|
||||
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async refreshAccessToken(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
import { AppModule } from "@/app.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team } from "@prisma/client";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
|
||||
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard";
|
||||
|
||||
describe("OAuthClientCredentialsGuard", () => {
|
||||
let guard: OAuthClientCredentialsGuard;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let oauthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule, OAuthClientModule],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<OAuthClientCredentialsGuard>(OAuthClientCredentialsGuard);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(guard).toBeDefined();
|
||||
expect(oauthClient).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return true if client ID and secret are valid", async () => {
|
||||
const mockContext = createMockExecutionContext(
|
||||
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
|
||||
{ clientId: oauthClient.id }
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if client ID is invalid", async () => {
|
||||
const mockContext = createMockExecutionContext(
|
||||
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
|
||||
{ clientId: "invalid id" }
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return false if secret key is invalid", async () => {
|
||||
const mockContext = createMockExecutionContext(
|
||||
{ [X_CAL_SECRET_KEY]: "invalid secret" },
|
||||
{ clientId: oauthClient.id }
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oauthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
});
|
||||
|
||||
function createMockExecutionContext(
|
||||
headers: Record<string, string>,
|
||||
params: Record<string, string>
|
||||
): ExecutionContext {
|
||||
return createMock<ExecutionContext>({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers,
|
||||
params,
|
||||
get: (headerName: string) => headers[headerName],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientCredentialsGuard implements CanActivate {
|
||||
constructor(private readonly oauthRepository: OAuthClientRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const { params } = request;
|
||||
|
||||
const oauthClientId = params.clientId;
|
||||
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
|
||||
|
||||
if (!oauthClientId) {
|
||||
throw new UnauthorizedException("Missing client ID");
|
||||
}
|
||||
if (!oauthClientSecret) {
|
||||
throw new UnauthorizedException("Missing client secret");
|
||||
}
|
||||
|
||||
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
|
||||
|
||||
if (!client || client.secret !== oauthClientSecret) {
|
||||
throw new UnauthorizedException("Invalid client credentials");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
|
||||
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
|
||||
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
@@ -36,12 +36,12 @@ import { Global, Module } from "@nestjs/common";
|
||||
],
|
||||
providers: [
|
||||
OAuthClientRepository,
|
||||
OAuthClientCredentialsGuard,
|
||||
TokensRepository,
|
||||
OAuthFlowService,
|
||||
OAuthClientUsersService,
|
||||
OrganizationsTeamsService,
|
||||
],
|
||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
|
||||
exports: [OAuthClientRepository],
|
||||
})
|
||||
export class OAuthClientModule {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.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 +8,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-0.0.20";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.23";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
@@ -15,7 +16,8 @@ export class OAuthClientUsersService {
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
private readonly schedulesService: SchedulesService_2024_04_15
|
||||
private readonly schedulesService: SchedulesService_2024_04_15,
|
||||
private readonly organizationsTeamsService: OrganizationsTeamsService
|
||||
) {}
|
||||
|
||||
async createOauthClientUser(
|
||||
@@ -78,6 +80,8 @@ export class OAuthClientUsersService {
|
||||
user.defaultScheduleId = defaultSchedule.id;
|
||||
}
|
||||
|
||||
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
|
||||
+29
-8
@@ -1,7 +1,10 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
@@ -32,6 +35,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
SkipTakePagination,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -40,13 +44,13 @@ import {
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Event Types")
|
||||
export class OrganizationsEventTypesController {
|
||||
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/event-types")
|
||||
async createTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@@ -68,7 +72,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types/:eventTypeId")
|
||||
async getTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@@ -86,10 +91,22 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { eventSlug } = queryParams;
|
||||
if (eventSlug) {
|
||||
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType ? [eventType] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||
|
||||
return {
|
||||
@@ -99,6 +116,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/event-types")
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -114,7 +133,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||
async updateTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@@ -136,7 +156,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Delete("/teams/:teamId/event-types/:eventTypeId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteTeamEventType(
|
||||
|
||||
+39
-26
@@ -10,6 +10,7 @@ import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -30,7 +31,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
@@ -63,7 +64,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
@@ -248,17 +249,15 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
schedulingType: "collective",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
mandatory: false,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -274,6 +273,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("COLLECTIVE");
|
||||
evaluateHost(body.hosts[0], data.hosts[0]);
|
||||
evaluateHost(body.hosts[1], data.hosts[1]);
|
||||
|
||||
@@ -389,7 +389,6 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
|
||||
expect(data.length).toEqual(2);
|
||||
|
||||
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
@@ -431,8 +430,6 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -489,22 +486,27 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate1EventTypes[0].title).toEqual(newTitle);
|
||||
expect(teammate2EventTypes.length).toEqual(0);
|
||||
expect(managedTeamEventTypes.length).toEqual(1);
|
||||
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
|
||||
|
||||
const responseTeamEvent = responseBody.data[0];
|
||||
expect(responseTeamEvent.title).toEqual(newTitle);
|
||||
expect(responseTeamEvent.assignAllTeamMembers).toEqual(false);
|
||||
|
||||
const responseTeammate1Event = responseBody.data[1];
|
||||
expect(responseTeammate1Event.title).toEqual(newTitle);
|
||||
|
||||
expect(teammate1EventTypes[0].title).toEqual(newTitle);
|
||||
expect(
|
||||
teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title
|
||||
).toEqual(newTitle);
|
||||
|
||||
const responseTeamEvent = responseBody.data.find(
|
||||
(eventType) => eventType.schedulingType === "MANAGED"
|
||||
);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.title).toEqual(newTitle);
|
||||
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(false);
|
||||
|
||||
const responseTeammate1Event = responseBody.data.find(
|
||||
(eventType) => eventType.ownerId === teammate1.id
|
||||
);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate1Event?.title).toEqual(newTitle);
|
||||
|
||||
managedEventType = responseBody.data[0];
|
||||
});
|
||||
});
|
||||
@@ -537,19 +539,28 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(managedTeamEventTypes.length).toEqual(1);
|
||||
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
|
||||
|
||||
const responseTeamEvent = responseBody.data[0];
|
||||
const responseTeamEvent = responseBody.data.find(
|
||||
(eventType) => eventType.schedulingType === "MANAGED"
|
||||
);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.teamId).toEqual(team.id);
|
||||
expect(responseTeamEvent.assignAllTeamMembers).toEqual(true);
|
||||
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true);
|
||||
|
||||
const responseTeammate1Event = responseBody.data[1];
|
||||
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
|
||||
const responseTeammate1Event = responseBody.data.find(
|
||||
(eventType) => eventType.ownerId === teammate1.id
|
||||
);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
const responseTeammate2Event = responseBody.data[2];
|
||||
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
|
||||
const responseTeammate2Event = responseBody.data.find(
|
||||
(eventType) => eventType.ownerId === teammate2.id
|
||||
);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
managedEventType = responseTeamEvent;
|
||||
if (responseTeamEvent) {
|
||||
managedEventType = responseTeamEvent;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -589,7 +600,9 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammate2.email);
|
||||
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(falseTestTeam.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await organizationsRepositoryFixture.delete(falseTestOrg.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
@@ -24,7 +25,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
@@ -51,7 +52,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
@@ -210,7 +211,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
@@ -228,7 +229,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
|
||||
+9
-1
@@ -1,8 +1,11 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
|
||||
@@ -39,12 +42,13 @@ import { Membership } from "@calcom/prisma/client";
|
||||
path: "/v2/organizations/:orgId/memberships",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Organizations Memberships")
|
||||
export class OrganizationsMembershipsController {
|
||||
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllMemberships(
|
||||
@@ -66,6 +70,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createMembership(
|
||||
@@ -80,6 +85,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Get("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -91,6 +97,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -107,6 +114,7 @@ export class OrganizationsMembershipsController {
|
||||
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateMembership(
|
||||
|
||||
+10
-1
@@ -1,7 +1,10 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
@@ -38,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Organizations Schedules")
|
||||
export class OrganizationsSchedulesController {
|
||||
constructor(
|
||||
@@ -47,6 +50,7 @@ export class OrganizationsSchedulesController {
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/schedules")
|
||||
async getOrganizationSchedules(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -63,6 +67,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Post("/users/:userId/schedules")
|
||||
async createUserSchedule(
|
||||
@@ -78,6 +83,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules/:scheduleId")
|
||||
async getUserSchedule(
|
||||
@@ -93,6 +99,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules")
|
||||
async getUserSchedules(
|
||||
@@ -107,6 +114,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Patch("/users/:userId/schedules/:scheduleId")
|
||||
async updateUserSchedule(
|
||||
@@ -123,6 +131,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Delete("/users/:userId/schedules/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+5
-4
@@ -8,6 +8,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -31,7 +32,7 @@ describe("Organizations Schedules Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "mr-robot@schedules-api.com";
|
||||
@@ -48,7 +49,7 @@ describe("Organizations Schedules Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -110,7 +111,7 @@ describe("Organizations Schedules Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
@@ -146,7 +147,7 @@ describe("Organizations Schedules Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -25,7 +26,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
@@ -59,7 +60,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -335,7 +336,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await organizationsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await teamsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -1,6 +1,9 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
@@ -38,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsMembershipsController {
|
||||
constructor(
|
||||
@@ -50,6 +53,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllOrgTeamMemberships(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -75,6 +79,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -93,6 +98,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
|
||||
@@ -113,6 +119,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
|
||||
@@ -135,6 +142,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a membership of an organization's team" })
|
||||
|
||||
+209
-12
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { OrgMeTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -10,20 +11,22 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -31,6 +34,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let team: Team;
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
let teamCreatedViaApi2: Team;
|
||||
|
||||
const userEmail = "org-admin-teams-controller-e2e@api.com";
|
||||
let user: User;
|
||||
@@ -44,7 +48,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -84,7 +88,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(teamsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
@@ -146,6 +150,18 @@ describe("Organizations Team Endpoints", () => {
|
||||
teamCreatedViaApi.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all the teams of the authenticated org member", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/me`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgMeTeamOutputDto[]> = response.body;
|
||||
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)).toBeDefined();
|
||||
expect(responseBody.data.some((t) => t.accepted)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,11 +203,35 @@ describe("Organizations Team Endpoints", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
|
||||
});
|
||||
|
||||
it("should create the team of the org without auto-accepting creator", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: "Team II created via API",
|
||||
autoAcceptCreator: false,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi2 = responseBody.data;
|
||||
expect(teamCreatedViaApi2.name).toEqual("Team II created via API");
|
||||
expect(teamCreatedViaApi2.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
teamCreatedViaApi2.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -202,7 +242,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -222,7 +262,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -262,7 +302,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(teamsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
@@ -305,7 +345,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -316,7 +356,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -336,7 +376,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -436,3 +476,160 @@ describe("Organizations Team Endpoints", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("Platform teams", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let orgRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
|
||||
let oAuthClient1: PlatformOAuthClient;
|
||||
let oAuthClient2: PlatformOAuthClient;
|
||||
let org: Team;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
|
||||
const userEmail = "platform-org-member-teams-owner-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await orgRepositoryFixture.create({
|
||||
name: "Platform Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
oAuthClient1 = await createOAuthClient(org.id);
|
||||
oAuthClient2 = await createOAuthClient(org.id);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
return await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(teamsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create first oAuth client team", async () => {
|
||||
const teamName = "Platform team created via API";
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient1.id)
|
||||
.send({
|
||||
name: teamName,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
team1 = responseBody.data;
|
||||
expect(team1.name).toEqual(teamName);
|
||||
expect(team1.parentId).toEqual(org.id);
|
||||
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id);
|
||||
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create second oAuth client team", async () => {
|
||||
const teamName = "Platform team II created via API";
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient2.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient2.secret)
|
||||
.send({
|
||||
name: teamName,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
team2 = responseBody.data;
|
||||
expect(team2.name).toEqual(teamName);
|
||||
expect(team2.parentId).toEqual(org.id);
|
||||
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team2.id);
|
||||
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all the platform teams correctly tied to OAuth clients", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(team1.id);
|
||||
expect(responseBody.data[1].id).toEqual(team2.id);
|
||||
|
||||
const oAuthClientTeams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient1.id);
|
||||
expect(oAuthClientTeams.length).toEqual(1);
|
||||
const oAuthClientTeam = oAuthClientTeams[0];
|
||||
expect(oAuthClientTeam.id).toEqual(team1.id);
|
||||
expect(oAuthClientTeam.name).toEqual(team1.name);
|
||||
|
||||
const oAuthClient2Teams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient2.id);
|
||||
expect(oAuthClient2Teams.length).toEqual(1);
|
||||
const oAuthClientTeam2 = oAuthClient2Teams[0];
|
||||
expect(oAuthClientTeam2.id).toEqual(team2.id);
|
||||
expect(oAuthClientTeam2.name).toEqual(team2.name);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team1.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+50
-7
@@ -1,14 +1,19 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||
import {
|
||||
OrgTeamOutputDto,
|
||||
OrgMeTeamOutputDto,
|
||||
OrgMeTeamsOutputResponseDto,
|
||||
OrgTeamOutputResponseDto,
|
||||
OrgTeamsOutputResponseDto,
|
||||
} from "@/modules/organizations/outputs/organization-team.output";
|
||||
@@ -25,11 +30,13 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -37,15 +44,15 @@ import { Team } from "@calcom/prisma/client";
|
||||
path: "/v2/organizations/:orgId/teams",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsController {
|
||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@@ -58,8 +65,37 @@ export class OrganizationsTeamsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/me")
|
||||
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getMyTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<OrgMeTeamsOutputResponseDto> {
|
||||
const { skip, take } = queryParams;
|
||||
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
|
||||
orgId,
|
||||
user.id,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: teams.map((team) =>
|
||||
plainToClass(
|
||||
OrgMeTeamOutputDto,
|
||||
{ ...team, accepted: team.members.find((member) => member.userId === user.id)?.accepted ?? false },
|
||||
{ strategy: "excludeAll" }
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/:teamId")
|
||||
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||
@@ -71,6 +107,7 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/:teamId")
|
||||
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||
async deleteTeam(
|
||||
@@ -86,12 +123,13 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:teamId")
|
||||
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||
async updateTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
@Body() body: UpdateOrgTeamDto
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||
return {
|
||||
@@ -102,13 +140,18 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@Post()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Create a team for an organization." })
|
||||
async createTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgTeamDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Headers(X_CAL_CLIENT_ID) oAuthClientId?: string
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
|
||||
const team = oAuthClientId
|
||||
? await this.organizationsTeamsService.createPlatformOrgTeam(orgId, oAuthClientId, body, user)
|
||||
: await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
|
||||
+8
-1
@@ -1,8 +1,11 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
@@ -39,7 +42,7 @@ import { Team } from "@calcom/prisma/client";
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@UseGuards(IsOrgGuard)
|
||||
@DocsTags("Organizations Users")
|
||||
export class OrganizationsUsersController {
|
||||
@@ -47,6 +50,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Get()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getOrganizationsUsers(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrganizationsUsersInput
|
||||
@@ -66,6 +70,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Post()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetOrg() org: Team,
|
||||
@@ -85,6 +90,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Patch("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async updateOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -101,6 +107,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Delete("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async deleteOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
|
||||
+172
-7
@@ -8,28 +8,29 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
import { User, Team, EventType } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Users Endpoints", () => {
|
||||
describe("Member role", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const userEmail = "member1@org.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -40,7 +41,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -70,7 +71,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
},
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -113,7 +114,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "admin1@org.com";
|
||||
@@ -148,7 +149,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -344,4 +345,168 @@ describe("Organizations Users Endpoints", () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Member event-types", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const authEmail = "auth@org.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let managedEventType: EventType;
|
||||
let createdUser: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
authEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test org 4",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: "Test org 4 team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
username: authEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: authEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "MANAGED",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Managed Event Type",
|
||||
slug: "managed-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a new org user with team event-types", async () => {
|
||||
const newOrgUser = {
|
||||
email: "new-org-member-d@org.com",
|
||||
organizationRole: "MEMBER",
|
||||
autoAccept: true,
|
||||
};
|
||||
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users`)
|
||||
.send({
|
||||
email: newOrgUser.email,
|
||||
})
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
userHasCorrectEventTypes(userData.id);
|
||||
createdUser = userData;
|
||||
teamHasCorrectEventTypes(team.id);
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(1);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
}
|
||||
|
||||
async function teamHasCorrectEventTypes(teamId: number) {
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||
|
||||
expect(eventTypes?.length).toEqual(2);
|
||||
|
||||
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
|
||||
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
expect(collective).toBeTruthy();
|
||||
expect(collective?.hosts).toBeDefined();
|
||||
expect(collective?.hosts?.length).toEqual(1);
|
||||
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
// await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(createdUser.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,4 +72,8 @@ export class CreateOrgTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly weekStart?: string = "Sunday";
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly autoAcceptCreator?: boolean = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
export class UpdateOrgTeamDto {
|
||||
@IsString()
|
||||
@Length(1)
|
||||
readonly name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly calVideoLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly appLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly appIconLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly hideBranding?: boolean = false;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly isPrivate?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly hideBookATeamMember?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly brandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly darkBrandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly timeFormat?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly weekStart?: string = "Sunday";
|
||||
}
|
||||
@@ -70,6 +70,26 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findPlatformOrgFromUserId(userId: number) {
|
||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
orgProfiles: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
isPlatform: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPlatform: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgUser(organizationId: number, userId: number) {
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
@@ -82,4 +102,15 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetchOrgAdminApiStatus(organizationId: number) {
|
||||
return this.dbRead.prisma.organizationSettings.findUnique({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class OrgTeamOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly id!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
readonly parentId?: number;
|
||||
|
||||
@IsString()
|
||||
@Length(1)
|
||||
@Expose()
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly calVideoLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly appLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly appIconLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly hideBranding?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly isOrganization?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly isPrivate?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly hideBookATeamMember?: boolean = false;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly metadata?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly brandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly darkBrandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly timeFormat?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly weekStart?: string = "Sunday";
|
||||
readonly accepted!: boolean;
|
||||
}
|
||||
|
||||
export class OrgTeamsOutputResponseDto {
|
||||
@@ -129,6 +22,17 @@ export class OrgTeamsOutputResponseDto {
|
||||
data!: OrgTeamOutputDto[];
|
||||
}
|
||||
|
||||
export class OrgMeTeamsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgTeamOutputDto)
|
||||
data!: OrgTeamOutputDto[];
|
||||
}
|
||||
|
||||
export class OrgTeamOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user