Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbff4db2a6 | ||
|
|
ba61731689 | ||
|
|
e16662e655 | ||
|
|
53c37a9ddf | ||
|
|
64ef2703a5 | ||
|
|
a997edd8db | ||
|
|
bd73eba528 | ||
|
|
0d53c2069e | ||
|
|
3b559525a8 | ||
|
|
33e1abf4a7 | ||
|
|
059c6c37ba | ||
|
|
14bc489d70 | ||
|
|
e8b36d5b0b | ||
|
|
5983c960a6 | ||
|
|
40305b9c5a | ||
|
|
dcc62c6db0 | ||
|
|
07f14e4e14 | ||
|
|
f940f6bd87 | ||
|
|
ca5dcfd1e8 | ||
|
|
324728d4ec | ||
|
|
2f7cc05cad | ||
|
|
84be4dba8d | ||
|
|
17655cd1c8 | ||
|
|
010f5349f6 | ||
|
|
01dead9a1d | ||
|
|
900ea37b43 | ||
|
|
c209c22b98 | ||
|
|
b8610dc2d1 | ||
|
|
28f84d3570 | ||
|
|
b46a902a3d | ||
|
|
de2f51e104 | ||
|
|
35110892c1 | ||
|
|
c870df7027 | ||
|
|
a4b0fdba7b | ||
|
|
b530d31b35 | ||
|
|
a69f9c5fa3 | ||
|
|
73b8569a16 | ||
|
|
5becc95fc4 | ||
|
|
a74522af3d | ||
|
|
8b23e41954 | ||
|
|
94873ea3cb | ||
|
|
0c0cac28fd | ||
|
|
769ca9002e | ||
|
|
03366d2337 | ||
|
|
159fe3a87d | ||
|
|
5b97d3bf52 | ||
|
|
7cdecf57e0 | ||
|
|
1c8d0eda45 | ||
|
|
7ee3f9409c | ||
|
|
0d1dd036eb | ||
|
|
2af27170cd | ||
|
|
218f6d6eaf | ||
|
|
f0464e72c7 | ||
|
|
2ad3592eb3 | ||
|
|
22fc47f69f | ||
|
|
af3d2e6879 | ||
|
|
58e61cb18e | ||
|
|
9e3f8a2dc0 | ||
|
|
e960b8ad10 | ||
|
|
b7bfd77f95 | ||
|
|
2707adbb7b | ||
|
|
5ef1b22c87 | ||
|
|
7e29db7a1b | ||
|
|
41cde8e384 | ||
|
|
9fec77e345 | ||
|
|
7046f6f80a | ||
|
|
3f4a0eff7f | ||
|
|
d2768c4dc5 | ||
|
|
470e0f53d8 | ||
|
|
003af9a69d | ||
|
|
283ca9fc74 | ||
|
|
13ec930a61 | ||
|
|
cada4a58ac | ||
|
|
77519a077c | ||
|
|
1a5a539106 | ||
|
|
539a2ea301 | ||
|
|
c8b577fb7a | ||
|
|
7f97d27c20 | ||
|
|
9ff10877e7 | ||
|
|
eb0768917c | ||
|
|
ec65cd6333 | ||
|
|
563141b295 | ||
|
|
7b24972900 | ||
|
|
6d2ec71df3 | ||
|
|
492304fe1d | ||
|
|
c7c5cdf133 |
+3
-3
@@ -15,10 +15,10 @@
|
||||
# - 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=
|
||||
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report any issues with the platform
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Actual Results
|
||||
|
||||
- What's happening right now that is different from what is expected
|
||||
|
||||
### Expected Results
|
||||
|
||||
- This is an ideal result that the system should get after the tests are performed
|
||||
|
||||
### Technical details
|
||||
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
### Evidence
|
||||
|
||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Bug report
|
||||
description: "Please make sure you have checked for any duplicate issues."
|
||||
title: "(bug)"
|
||||
labels: ["🐛 bug"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
value: |
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other-information
|
||||
attributes:
|
||||
label: Other information
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
options:
|
||||
- label: app.cal.com
|
||||
- label: Self-hosted Cal
|
||||
- type: textarea
|
||||
id: desktop-version
|
||||
attributes:
|
||||
label: Desktop (please complete the following information)
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: [e.g. iOS]
|
||||
- **Browser**: [e.g. chrome, safari]
|
||||
- **Version**: [e.g. 22]
|
||||
value: |
|
||||
- OS:
|
||||
- Node:
|
||||
- npm:
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: nodejs-version
|
||||
attributes:
|
||||
value: |
|
||||
#### Node.JS version
|
||||
|
||||
[e.g. v18.15.0]
|
||||
- type: markdown
|
||||
id: anything-else
|
||||
attributes:
|
||||
value: |
|
||||
#### Anything else?
|
||||
|
||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||
- Anything else that you think could be an issue?
|
||||
@@ -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,55 +1,41 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature or idea
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
title: ""
|
||||
labels: feature
|
||||
assignees: ""
|
||||
---
|
||||
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
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
-1
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
|
||||
|
||||
To develop locally:
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
||||
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
||||
|
||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
|
||||
<!-- RORADMAP -->
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||
|
||||
> [!NOTE]
|
||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||
|
||||
| | AGPLv3 | EE |
|
||||
|---|---|---|
|
||||
| Self-host for commercial purposes | ✅ | ✅ |
|
||||
| Clone privately | ✅ | ✅ |
|
||||
| Fork publicly | ✅ | ✅ |
|
||||
| Requires CLA | ✅ | ✅ |
|
||||
| Official Support| ❌ | ✅ |
|
||||
| Derivative work privately | ❌ | ✅ |
|
||||
| SSO | ❌ | ✅ |
|
||||
| Admin Panel | ❌ | ✅ |
|
||||
| Impersonation | ❌ | ✅ |
|
||||
| Managed Event Types | ❌ | ✅ |
|
||||
| Organizations | ❌ | ✅ |
|
||||
| Payments | ❌ | ✅ |
|
||||
| Platform | ❌ | ✅ |
|
||||
| Teams | ❌ | ✅ |
|
||||
| Users | ❌ | ✅ |
|
||||
| Video | ❌ | ✅ |
|
||||
| Workflows | ❌ | ✅ |
|
||||
|
||||
> [!TIP]
|
||||
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
||||
|
||||
## Repo Activity
|
||||
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
language: data.language,
|
||||
teamId: data.teamId,
|
||||
usernameOrEmail: data.usernameOrEmail,
|
||||
isOrg: data.isOrg,
|
||||
});
|
||||
|
||||
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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 handler from "../../../pages/api/attendees/_post";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("POST /api/attendees", () => {
|
||||
describe("Errors", () => {
|
||||
test("Returns 403 if user is not admin and has no booking", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
bookingId: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
prismaMock.booking.findFirst.mockResolvedValue(null);
|
||||
|
||||
req.userId = 123;
|
||||
// req.isAdmin = false;
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res._getData()).message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("Returns 200 if user is admin", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
bookingId: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
const attendeeData = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
timeZone: "UTC",
|
||||
bookingId: 1,
|
||||
};
|
||||
|
||||
prismaMock.attendee.create.mockResolvedValue(attendeeData);
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = 123;
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
|
||||
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
|
||||
});
|
||||
|
||||
test("Returns 200 if user is not admin but has a booking", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
bookingId: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
const userBooking = { id: 1 };
|
||||
|
||||
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
|
||||
|
||||
const attendeeData = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
timeZone: "UTC",
|
||||
bookingId: 1,
|
||||
};
|
||||
|
||||
prismaMock.attendee.create.mockResolvedValue(attendeeData);
|
||||
|
||||
req.userId = 123;
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
|
||||
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
|
||||
});
|
||||
|
||||
test("Returns 400 if request body is invalid", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
// Missing required fields
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = 123;
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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,24 +18,21 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
|
||||
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
|
||||
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
|
||||
"@calcom/platform-libraries-0.0.17": "npm:@calcom/platform-libraries@0.0.17",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
|
||||
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
|
||||
"@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",
|
||||
@@ -46,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,6 +11,7 @@ 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";
|
||||
@@ -26,7 +27,18 @@ import { AppController } from "./app.controller";
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
|
||||
RedisModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
useFactory: async (redisService: RedisService) => ({
|
||||
redis: {
|
||||
host: redisService.redis.options.host,
|
||||
port: redisService.redis.options.port,
|
||||
},
|
||||
}),
|
||||
inject: [RedisService],
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
|
||||
@@ -19,7 +19,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.2";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
|
||||
@@ -35,20 +35,18 @@ import { NextApiRequest } from "next/types";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||
import {
|
||||
getAllUserBookings,
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { handleMarkNoShow } from "@calcom/platform-libraries-0.0.17";
|
||||
handleMarkNoShow,
|
||||
getAllUserBookings,
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -152,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,
|
||||
@@ -179,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);
|
||||
@@ -230,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,
|
||||
@@ -255,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.2";
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
|
||||
AppleCalendarService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
id: true,
|
||||
appId: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
key: true,
|
||||
invalid: true,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getCalendarCredentials(credentialId: number, userId: number) {
|
||||
return await this.dbRead.prisma.credential.findFirst({
|
||||
where: {
|
||||
id: credentialId,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
...credentialForCalendarRepositorySelect,
|
||||
app: {
|
||||
select: {
|
||||
slug: true,
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCredentials(credentialId: number) {
|
||||
return await this.dbWrite.prisma.credential.delete({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+32
-9
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
@@ -18,6 +19,7 @@ import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.reposit
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
@@ -105,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())
|
||||
@@ -189,6 +192,26 @@ describe("Platform Calendars Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||
const body = {
|
||||
id: 10,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: Promise<DeletedCalendarCredentialsOutputResponseDto> = response.body;
|
||||
|
||||
expect((await responseBody).status).toEqual(SUCCESS_STATUS);
|
||||
expect((await responseBody).data).toBeDefined();
|
||||
expect((await responseBody).data.id).toEqual(body.id);
|
||||
expect((await responseBody).data.userId).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
@@ -1,5 +1,11 @@
|
||||
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 {
|
||||
DeletedCalendarCredentialsOutputResponseDto,
|
||||
DeletedCalendarCredentialsOutputDto,
|
||||
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
@@ -27,6 +33,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -50,7 +57,8 @@ export class CalendarsController {
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
private readonly appleCalendarService: AppleCalendarService
|
||||
private readonly appleCalendarService: AppleCalendarService,
|
||||
private readonly calendarsRepository: CalendarsRepository
|
||||
) {}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -183,4 +191,29 @@ export class CalendarsController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteCalendarCredentials(
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
|
||||
const { id: credentialId } = body;
|
||||
await this.calendarsService.checkCalendarCredentials(credentialId, user.id);
|
||||
|
||||
const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials(
|
||||
credentialId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(
|
||||
DeletedCalendarCredentialsOutputDto,
|
||||
{ id, type, userId, teamId, appId, invalid },
|
||||
{ strategy: "excludeAll" }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeletedCalendarCredentialsOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly id!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly type!: string;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly userId!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly teamId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly appId!: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly invalid!: boolean | null;
|
||||
}
|
||||
|
||||
export class DeletedCalendarCredentialsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => DeletedCalendarCredentialsOutputDto)
|
||||
data!: DeletedCalendarCredentialsOutputDto;
|
||||
}
|
||||
@@ -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.14";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import {
|
||||
CredentialsRepository,
|
||||
@@ -17,8 +18,7 @@ import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@@ -30,6 +30,7 @@ export class CalendarsService {
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
@@ -137,4 +138,11 @@ export class CalendarsService {
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async checkCalendarCredentials(credentialId: number, userId: number) {
|
||||
const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId);
|
||||
if (!credential) {
|
||||
throw new NotFoundException("Calendar credentials not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import {
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ import {
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
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.2";
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+7
-2
@@ -9,8 +9,12 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
createEventType,
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +60,7 @@ export class EventTypesService_2024_04_15 {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
organization: { isOrgAdmin },
|
||||
profile: { id: profileId },
|
||||
|
||||
+90
-2
@@ -9,10 +9,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, Schedule } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
@@ -63,12 +64,18 @@ describe("Event types Endpoints", () => {
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "event-types-test-e2e@api.com";
|
||||
const falseTestEmail = "false-event-types@api.com";
|
||||
const name = "bob-the-builder";
|
||||
const username = name;
|
||||
let eventType: EventTypeOutput_2024_06_14;
|
||||
let user: User;
|
||||
let falseTestUser: User;
|
||||
let firstSchedule: Schedule;
|
||||
let secondSchedule: Schedule;
|
||||
let falseTestSchedule: Schedule;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -91,6 +98,7 @@ describe("Event types Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
@@ -100,6 +108,30 @@ describe("Event types Endpoints", () => {
|
||||
username,
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
email: falseTestEmail,
|
||||
name: "false-test",
|
||||
username: falseTestEmail,
|
||||
});
|
||||
|
||||
firstSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: user.id,
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
secondSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: user.id,
|
||||
name: "chill",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
falseTestSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: falseTestUser.id,
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
@@ -123,6 +155,40 @@ describe("Event types Endpoints", () => {
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with schedule user does not own", async () => {
|
||||
const scheduleId = falseTestSchedule.id;
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
bookingFields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language you want to learn",
|
||||
slug: "select-language",
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
scheduleId,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
@@ -145,6 +211,7 @@ describe("Event types Endpoints", () => {
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
scheduleId: firstSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -162,6 +229,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
@@ -172,6 +240,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -191,11 +260,25 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(updatedEventType.ownerId).toEqual(user.id);
|
||||
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow to update event type with scheduleId user does not own", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
scheduleId: falseTestSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
@@ -272,7 +355,7 @@ describe("Event types Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
it("should delete event type", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
|
||||
});
|
||||
|
||||
@@ -289,6 +372,11 @@ describe("Event types Endpoints", () => {
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(falseTestUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
-1
@@ -77,7 +77,6 @@ export class EventTypesController_2024_06_14 {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
async getEventTypes(
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||
): Promise<GetEventTypesOutput_2024_06_14> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
@@ -20,8 +21,14 @@ import { Module } from "@nestjs/common";
|
||||
OutputEventTypesService_2024_06_14,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
controllers: [EventTypesController_2024_06_14],
|
||||
exports: [EventTypesService_2024_06_14, EventTypesRepository_2024_06_14],
|
||||
exports: [
|
||||
EventTypesService_2024_06_14,
|
||||
EventTypesRepository_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
],
|
||||
})
|
||||
export class EventTypesModule_2024_06_14 {}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.13";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+23
-6
@@ -2,6 +2,7 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/con
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -9,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";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.13";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
@@ -30,7 +31,8 @@ export class EventTypesService_2024_06_14 {
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11
|
||||
) {}
|
||||
|
||||
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
|
||||
@@ -61,6 +63,7 @@ export class EventTypesService_2024_06_14 {
|
||||
if (existsWithSlug) {
|
||||
throw new BadRequestException("User already has an event type with this slug.");
|
||||
}
|
||||
await this.checkUserOwnsSchedule(userId, body.scheduleId);
|
||||
}
|
||||
|
||||
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
|
||||
@@ -94,6 +97,7 @@ export class EventTypesService_2024_06_14 {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
organization: { isOrgAdmin },
|
||||
profile: { id: profileId },
|
||||
@@ -202,7 +206,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
|
||||
await updateEventType({
|
||||
@@ -224,12 +228,13 @@ export class EventTypesService_2024_06_14 {
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
}
|
||||
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
|
||||
const existingEventType = await this.getUserEventType(userId, eventTypeId);
|
||||
if (!existingEventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
|
||||
await this.checkUserOwnsSchedule(userId, scheduleId);
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
@@ -254,4 +259,16 @@ export class EventTypesService_2024_06_14 {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserOwnsSchedule(userId: number, scheduleId: number | null | undefined) {
|
||||
if (!scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = await this.schedulesRepository.getScheduleByIdAndUserId(scheduleId, userId);
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.13";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -29,12 +29,14 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
|
||||
const { locations, bookingFields, ...rest } = inputEventType;
|
||||
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
schedule: scheduleId,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
|
||||
+4
-8
@@ -9,7 +9,7 @@ import {
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
} from "@calcom/platform-libraries-0.0.13";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
@@ -41,7 +41,7 @@ type Input = Pick<
|
||||
| "recurringEvent"
|
||||
| "metadata"
|
||||
| "users"
|
||||
| "schedule"
|
||||
| "scheduleId"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -68,13 +68,13 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
successRedirectUrl,
|
||||
seatsShowAvailabilityCount,
|
||||
isInstantEvent,
|
||||
scheduleId,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
|
||||
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
const schedule = await this.getSchedule(databaseEventType);
|
||||
const users = this.transformUsers(databaseEventType.users);
|
||||
|
||||
return {
|
||||
@@ -104,7 +104,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
seatsShowAvailabilityCount,
|
||||
isInstantEvent,
|
||||
users,
|
||||
schedule,
|
||||
scheduleId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,10 +128,6 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return EventTypeMetaDataSchema.parse(metadata);
|
||||
}
|
||||
|
||||
async getSchedule(databaseEventType: Input) {
|
||||
return databaseEventType.schedule || null;
|
||||
}
|
||||
|
||||
transformUsers(users: User[]) {
|
||||
return users.map((user) => {
|
||||
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalController } from "@/ee/gcal/gcal.controller";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
@@ -21,6 +22,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
GCalService,
|
||||
CalendarsService,
|
||||
UsersRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [GcalController],
|
||||
})
|
||||
|
||||
@@ -103,6 +103,21 @@ describe("Me Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user associated with access token given badly formatted timezone", async () => {
|
||||
const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" };
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch("/v2/me")
|
||||
.send(bodyWithBadlyFormattedTimeZone)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
expect(responseBody.data.timeZone).toEqual("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid timezone", async () => {
|
||||
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { userSchemaResponse } from "@calcom/platform-types";
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly schedulesRepository: SchedulesService_2024_04_15
|
||||
private readonly schedulesService: SchedulesService_2024_04_15
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -45,7 +45,7 @@ export class MeController {
|
||||
): Promise<UpdateMeOutput> {
|
||||
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
|
||||
if (bodySchedule.timeZone && user.defaultScheduleId) {
|
||||
await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
|
||||
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
|
||||
timeZone: bodySchedule.timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
+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(":");
|
||||
|
||||
@@ -96,6 +96,17 @@ export class SchedulesRepository_2024_06_11 {
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async getScheduleByIdAndUserId(scheduleId: number, userId: number) {
|
||||
const schedule = await this.dbRead.prisma.schedule.findUnique({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
userId: number,
|
||||
scheduleId: number,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries-0.0.4";
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
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.2";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export enum Locales {
|
||||
AR = "ar",
|
||||
CA = "ca",
|
||||
DE = "de",
|
||||
ES = "es",
|
||||
EU = "eu",
|
||||
HE = "he",
|
||||
ID = "id",
|
||||
JA = "ja",
|
||||
LV = "lv",
|
||||
PL = "pl",
|
||||
RO = "ro",
|
||||
SR = "sr",
|
||||
TH = "th",
|
||||
VI = "vi",
|
||||
AZ = "az",
|
||||
CS = "cs",
|
||||
EL = "el",
|
||||
ES_419 = "es-419",
|
||||
FI = "fi",
|
||||
HR = "hr",
|
||||
IT = "it",
|
||||
KM = "km",
|
||||
NL = "nl",
|
||||
PT = "pt",
|
||||
RU = "ru",
|
||||
SV = "sv",
|
||||
TR = "tr",
|
||||
ZH_CN = "zh-CN",
|
||||
BG = "bg",
|
||||
DA = "da",
|
||||
EN = "en",
|
||||
ET = "et",
|
||||
FR = "fr",
|
||||
HU = "hu",
|
||||
IW = "iw",
|
||||
KO = "ko",
|
||||
NO = "no",
|
||||
PT_BR = "pt-BR",
|
||||
SK = "sk",
|
||||
TA = "ta",
|
||||
UK = "uk",
|
||||
ZH_TW = "zh-TW",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
|
||||
import { Membership } from "@calcom/prisma/client";
|
||||
|
||||
export type GetMembershipReturnType = Membership;
|
||||
|
||||
export const GetMembership = createParamDecorator<
|
||||
keyof GetMembershipReturnType | (keyof GetMembershipReturnType)[],
|
||||
ExecutionContext
|
||||
>((data, ctx) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const membership = request.membership as GetMembershipReturnType;
|
||||
|
||||
if (!membership) {
|
||||
throw new Error("GetMembership decorator : Membership not found");
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.reduce((prev, curr) => {
|
||||
return {
|
||||
...prev,
|
||||
[curr]: membership[curr],
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return membership[data];
|
||||
}
|
||||
|
||||
return membership;
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Membership } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsMembershipInOrg implements CanActivate {
|
||||
constructor(private organizationsMembershipRepository: OrganizationsMembershipRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { membership: Membership }>();
|
||||
const membershipId: string = request.params.membershipId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!membershipId) {
|
||||
throw new ForbiddenException("No membership id found in request params.");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||
Number(orgId),
|
||||
Number(membershipId)
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException(`Membership (${membershipId}) not found.`);
|
||||
}
|
||||
|
||||
request.membership = membership;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -37,9 +37,9 @@ describe("ApiAuthStrategy", () => {
|
||||
const validAccessTokenEmail = "access-token-user-email@example.com";
|
||||
let validApiKeyUser: User;
|
||||
let validAccessTokenUser: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
let module: TestingModule;
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: true,
|
||||
@@ -177,4 +177,10 @@ describe("ApiAuthStrategy", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
|
||||
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
|
||||
module.close();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
|
||||
import { EmailService } from "./email.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
public async sendSignupToOrganizationEmail({
|
||||
usernameOrEmail,
|
||||
orgName,
|
||||
orgId,
|
||||
locale,
|
||||
inviterName,
|
||||
}: {
|
||||
usernameOrEmail: string;
|
||||
orgName: string;
|
||||
orgId: number;
|
||||
locale: string | null;
|
||||
inviterName: string;
|
||||
}) {
|
||||
const translation = await getTranslation(locale || "en", "common");
|
||||
|
||||
await sendSignupToOrganizationEmail({
|
||||
usernameOrEmail,
|
||||
team: { name: orgName, parent: null },
|
||||
inviterName: inviterName,
|
||||
isOrg: true,
|
||||
teamId: orgId,
|
||||
translation,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class MembershipsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
@@ -47,4 +49,17 @@ export class MembershipsRepository {
|
||||
|
||||
return !!adminMembership;
|
||||
}
|
||||
|
||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||
const membership = await this.dbRead.prisma.membership.create({
|
||||
data: {
|
||||
role,
|
||||
teamId,
|
||||
userId,
|
||||
accepted,
|
||||
},
|
||||
});
|
||||
|
||||
return membership;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import {
|
||||
CreateUserResponse,
|
||||
UserReturned,
|
||||
@@ -141,6 +142,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
timeZone: userTimeZone,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -163,6 +165,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
@@ -234,7 +237,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
|
||||
it(`/PUT/:id`, async () => {
|
||||
const userUpdatedEmail = "pineapple-pizza@gmail.com";
|
||||
const body: UpdateManagedUserInput = { email: userUpdatedEmail };
|
||||
const body: UpdateManagedUserInput = { email: userUpdatedEmail, locale: Locales.PT_BR };
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
@@ -248,6 +251,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail));
|
||||
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
|
||||
});
|
||||
|
||||
it(`/DELETE/:id`, () => {
|
||||
|
||||
+2
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
@@ -191,6 +192,7 @@ export class OAuthClientUsersController {
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
locale: user.locale as Locales,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -1,5 +1,7 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsEnum, IsOptional } from "class-validator";
|
||||
|
||||
export class ManagedUserOutput {
|
||||
@ApiProperty({ example: 1 })
|
||||
@@ -26,4 +28,9 @@ export class ManagedUserOutput {
|
||||
|
||||
@ApiProperty({ example: null })
|
||||
defaultScheduleId!: number | null;
|
||||
|
||||
@IsEnum(Locales)
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: Locales.EN, enum: Locales })
|
||||
locale?: Locales;
|
||||
}
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.15";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
@@ -59,7 +59,11 @@ export class OAuthClientUsersService {
|
||||
})
|
||||
)[0];
|
||||
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
|
||||
await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined });
|
||||
const updatedUser = await this.userRepository.update(user.id, {
|
||||
name: body.name ?? user.username ?? undefined,
|
||||
locale: body.locale,
|
||||
});
|
||||
user.locale = updatedUser.locale;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output";
|
||||
import { DeleteTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output";
|
||||
import { GetTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output";
|
||||
import { GetTeamEventTypesOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output";
|
||||
import { UpdateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Body,
|
||||
Patch,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
SkipTakePagination,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Event Types")
|
||||
export class OrganizationsEventTypesController {
|
||||
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Post("/teams/:teamId/event-types")
|
||||
async createTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
): Promise<CreateTeamEventTypeOutput> {
|
||||
const eventType = await this.organizationsEventTypesService.createTeamEventType(
|
||||
user,
|
||||
teamId,
|
||||
orgId,
|
||||
bodyEventType
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Get("/teams/:teamId/event-types/:eventTypeId")
|
||||
async getTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("eventTypeId") eventTypeId: number
|
||||
): Promise<GetTeamEventTypeOutput> {
|
||||
const eventType = await this.organizationsEventTypesService.getTeamEventType(teamId, eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/teams/event-types")
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetTeamEventTypesOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const eventTypes = await this.organizationsEventTypesService.getTeamsEventTypes(orgId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||
async updateTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
): Promise<UpdateTeamEventTypeOutput> {
|
||||
const eventType = await this.organizationsEventTypesService.updateTeamEventType(
|
||||
eventTypeId,
|
||||
teamId,
|
||||
bodyEventType,
|
||||
user
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Delete("/teams/:teamId/event-types/:eventTypeId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("eventTypeId") eventTypeId: number
|
||||
): Promise<DeleteTeamEventTypeOutput> {
|
||||
const eventType = await this.organizationsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
id: eventTypeId,
|
||||
title: eventType.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+610
@@ -0,0 +1,610 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
Host,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Event Types Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let falseTestOrg: Team;
|
||||
let falseTestTeam: Team;
|
||||
|
||||
const userEmail = "org-admin-event-types-controller-e222e@api.com";
|
||||
let userAdmin: User;
|
||||
|
||||
const teammate1Email = "teammate111@team.com";
|
||||
const teammate2Email = "teammate221@team.com";
|
||||
const falseTestUserEmail = "false-user@false-team.com";
|
||||
let teammate1: User;
|
||||
let teammate2: User;
|
||||
let falseTestUser: User;
|
||||
|
||||
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
|
||||
let managedEventType: TeamEventTypeOutput_2024_06_14;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
userAdmin = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
teammate1 = await userRepositoryFixture.create({
|
||||
email: teammate1Email,
|
||||
username: teammate1Email,
|
||||
});
|
||||
|
||||
teammate2 = await userRepositoryFixture.create({
|
||||
email: teammate2Email,
|
||||
username: teammate2Email,
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
email: falseTestUserEmail,
|
||||
username: falseTestUserEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
falseTestOrg = await organizationsRepositoryFixture.create({
|
||||
name: "False test org",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: "Test org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
falseTestTeam = await teamsRepositoryFixture.create({
|
||||
name: "Outside org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: falseTestOrg.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userAdmin.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userAdmin.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: userAdmin.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammate1.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammate2.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: falseTestUser.id } },
|
||||
team: { connect: { id: falseTestTeam.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(userAdmin).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not be able to create event-type for team outside org", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation",
|
||||
slug: "coding-consultation",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not be able to create event-type for user outside org", async () => {
|
||||
const userId = falseTestUser.id;
|
||||
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation",
|
||||
slug: "coding-consultation",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should create a collective team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation collective",
|
||||
slug: "coding-consultation collective",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
bookingFields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language is your codebase in",
|
||||
slug: "select-language",
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
mandatory: false,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
evaluateHost(body.hosts[0], data.hosts[0]);
|
||||
evaluateHost(body.hosts[1], data.hosts[1]);
|
||||
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a managed team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation managed",
|
||||
slug: "coding-consultation-managed",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "MANAGED",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
mandatory: false,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
|
||||
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
|
||||
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate2EventTypes.length).toEqual(1);
|
||||
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
|
||||
1
|
||||
);
|
||||
|
||||
const responseTeamEvent = responseBody.data[0];
|
||||
expect(responseTeamEvent?.teamId).toEqual(team.id);
|
||||
|
||||
const responseTeammate1Event = responseBody.data[1];
|
||||
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
|
||||
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
const responseTeammate2Event = responseBody.data[2];
|
||||
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
|
||||
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
managedEventType = responseTeamEvent;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get an event-type of team outside org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not get a non existing event-type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get a team event-type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(collectiveEventType.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
|
||||
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
|
||||
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get event-types of team outside org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get team event-types", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
|
||||
expect(data.length).toEqual(2);
|
||||
|
||||
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED");
|
||||
|
||||
expect(eventTypeCollective?.title).toEqual(collectiveEventType.title);
|
||||
expect(eventTypeCollective?.hosts.length).toEqual(2);
|
||||
|
||||
expect(eventTypeManaged?.title).toEqual(managedEventType.title);
|
||||
expect(eventTypeManaged?.hosts.length).toEqual(2);
|
||||
evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]);
|
||||
evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to update event-type for incorrect team", async () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Clean code consultation",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not be able to update non existing event-type", async () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Clean code consultation",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should update collective event-type", async () => {
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
hosts: newHosts,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const eventType = responseBody.data;
|
||||
expect(eventType.title).toEqual(collectiveEventType.title);
|
||||
expect(eventType.hosts.length).toEqual(1);
|
||||
evaluateHost(eventType.hosts[0], newHosts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update managed event-type", async () => {
|
||||
const newTitle = "Coding consultation managed updated";
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
hosts: newHosts,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
|
||||
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
|
||||
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
const managedTeamEventTypes = teamEventTypes.filter(
|
||||
(eventType) => eventType.schedulingType === "MANAGED"
|
||||
);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate1EventTypes[0].title).toEqual(newTitle);
|
||||
expect(teammate2EventTypes.length).toEqual(0);
|
||||
expect(managedTeamEventTypes.length).toEqual(1);
|
||||
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
|
||||
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];
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign all members to managed event-type", async () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
|
||||
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
|
||||
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
const managedTeamEventTypes = teamEventTypes.filter(
|
||||
(eventType) => eventType.schedulingType === "MANAGED"
|
||||
);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate2EventTypes.length).toEqual(1);
|
||||
expect(managedTeamEventTypes.length).toEqual(1);
|
||||
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
|
||||
|
||||
const responseTeamEvent = responseBody.data.find(
|
||||
(eventType) => eventType.schedulingType === "MANAGED"
|
||||
);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.teamId).toEqual(team.id);
|
||||
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true);
|
||||
|
||||
const responseTeammate1Event = responseBody.data.find(
|
||||
(eventType) => eventType.ownerId === teammate1.id
|
||||
);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
const responseTeammate2Event = responseBody.data.find(
|
||||
(eventType) => eventType.ownerId === teammate2.id
|
||||
);
|
||||
expect(responseTeammate1Event).toBeDefined();
|
||||
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
|
||||
|
||||
if (responseTeamEvent) {
|
||||
managedEventType = responseTeamEvent;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not delete event-type of team outside org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete event-type not part of the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/99999`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete collective event-type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it("should delete managed event-type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
expect(expected.priority).toEqual(received?.priority);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammate1.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammate2.email);
|
||||
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
{
|
||||
type: "array",
|
||||
items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
},
|
||||
],
|
||||
})
|
||||
@Type(() => TeamEventTypeOutput_2024_06_14)
|
||||
data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class DeleteTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@Type(() => TeamEventTypeOutput_2024_06_14)
|
||||
data!: Pick<TeamEventTypeOutput_2024_06_14, "id" | "title">;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class GetTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@Type(() => TeamEventTypeOutput_2024_06_14)
|
||||
data!: TeamEventTypeOutput_2024_06_14;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class GetTeamEventTypesOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamEventTypeOutput_2024_06_14)
|
||||
data!: TeamEventTypeOutput_2024_06_14[];
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
{
|
||||
type: "array",
|
||||
items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
|
||||
},
|
||||
],
|
||||
})
|
||||
@Type(() => TeamEventTypeOutput_2024_06_14)
|
||||
data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
|
||||
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Memberships Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: Membership;
|
||||
|
||||
const userEmail = "org-admin-membership-controller-e2e@api.com";
|
||||
const userEmail2 = "org-member-membership-controller-e2e@api.com";
|
||||
|
||||
const invitedUserEmail = "org-member-invited-membership-controller-e2e@api.com";
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
});
|
||||
|
||||
userToInviteViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership2 = await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all the memberships of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership.id);
|
||||
expect(responseBody.data[1].id).toEqual(membership2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all the memberships of the org paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[0].userId).toEqual(user2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if org does not exist", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/120494059/memberships`).expect(403);
|
||||
});
|
||||
|
||||
it("should get the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membership.id);
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
.send({
|
||||
userId: userToInviteViaApi.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgMembershipDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(org.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.send({
|
||||
role: "OWNER",
|
||||
} satisfies UpdateOrgMembershipDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the membership of the org we created via api", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get the membership of the org we just deleted", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should fail if the membership does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships/123132145`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organizations Memberships Endpoints", () => {
|
||||
describe("User Authentication - User is Org Member", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
const userEmail = "org-member-memberships-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should deny get all the memberships of the org", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/memberships`).expect(403);
|
||||
});
|
||||
|
||||
it("should deny get all the memberships of the org paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny get the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny create the membership for the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
.send({
|
||||
role: "OWNER",
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
} satisfies CreateOrgMembershipDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny update the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/memberships/${membership.id}`)
|
||||
.send({
|
||||
role: "MEMBER",
|
||||
} satisfies Partial<CreateOrgMembershipDto>)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny delete the membership of the org we created via api", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/memberships/${membership.id}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
|
||||
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
|
||||
import { CreateOrgMembershipOutput } from "@/modules/organizations/outputs/organization-membership/create-membership.output";
|
||||
import { DeleteOrgMembership } from "@/modules/organizations/outputs/organization-membership/delete-membership.output";
|
||||
import { GetAllOrgMemberships } from "@/modules/organizations/outputs/organization-membership/get-all-memberships.output";
|
||||
import { GetOrgMembership } from "@/modules/organizations/outputs/organization-membership/get-membership.output";
|
||||
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
|
||||
import { UpdateOrgMembership } from "@/modules/organizations/outputs/organization-membership/update-membership.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Membership } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/memberships",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Memberships")
|
||||
export class OrganizationsMembershipsController {
|
||||
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllMemberships(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetAllOrgMemberships> {
|
||||
const { skip, take } = queryParams;
|
||||
const memberships = await this.organizationsMembershipService.getPaginatedOrgMemberships(
|
||||
orgId,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: memberships.map((membership) =>
|
||||
plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgMembershipDto
|
||||
): Promise<CreateOrgMembershipOutput> {
|
||||
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Get("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getUserSchedule(@GetMembership() membership: Membership): Promise<GetOrgMembership> {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<DeleteOrgMembership> {
|
||||
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number,
|
||||
@Body() body: UpdateOrgMembershipDto
|
||||
): Promise<UpdateOrgMembership> {
|
||||
const membership = await this.organizationsMembershipService.updateOrgMembership(
|
||||
orgId,
|
||||
membershipId,
|
||||
body
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-17
@@ -21,8 +21,6 @@ import {
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsNumber, Min, Max, IsOptional } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -34,21 +32,7 @@ import {
|
||||
UpdateScheduleInput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
class SkipTakePagination {
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(250)
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
}
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Memberships Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: Membership;
|
||||
|
||||
const userEmail = "org-admin-membership-teams-controller-e2e@api.com";
|
||||
const userEmail2 = "org-member-membership-teams-controller-e2e@api.com";
|
||||
const nonOrgUserEmail = "non-org-member-membership-teams-controller-e2e@api.com";
|
||||
|
||||
const invitedUserEmail = "org-member-invited-membership-teams-controller-e2e@api.com";
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
let nonOrgUser: User;
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
});
|
||||
|
||||
userToInviteViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: "Org Team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: "Non Org Team",
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
membership2 = await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: userToInviteViaApi.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: nonOrgUser.id } },
|
||||
team: { connect: { id: nonOrgTeam.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user2.id}`,
|
||||
username: userEmail2,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userToInviteViaApi.id}`,
|
||||
username: invitedUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userToInviteViaApi.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all the memberships of the org's team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership.id);
|
||||
expect(responseBody.data[1].id).toEqual(membership2.id);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get all the memberships of team which is not in the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get all the memberships of the org's team paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[0].userId).toEqual(user2.id);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if org does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/120494059/teams/${orgTeam.id}/memberships`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should get the membership of the org's team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membership.id);
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get the membership of a team not in the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships/${membership.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should fail to create the membership of a team not in the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
|
||||
.send({
|
||||
userId: userToInviteViaApi.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should create the membership of the org's team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.send({
|
||||
userId: userToInviteViaApi.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to create the membership of the org's team for a non org user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.send({
|
||||
userId: nonOrgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(422);
|
||||
});
|
||||
|
||||
it("should update the membership of the org's team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.send({
|
||||
role: "OWNER",
|
||||
} satisfies UpdateOrgTeamMembershipDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the membership of the org's team we created via api", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get the membership of the org's team we just deleted", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should fail if the membership does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/123132145`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await organizationsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import {
|
||||
OrgTeamMembershipOutputDto,
|
||||
OrgTeamMembershipsOutputResponseDto,
|
||||
OrgTeamMembershipOutputResponseDto,
|
||||
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsMembershipsController {
|
||||
constructor(
|
||||
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllOrgTeamMemberships(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<OrgTeamMembershipsOutputResponseDto> {
|
||||
const { skip, take } = queryParams;
|
||||
const orgTeamMemberships = await this.organizationsTeamsMembershipsService.getPaginatedOrgTeamMemberships(
|
||||
orgId,
|
||||
teamId,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: orgTeamMemberships.map((membership) =>
|
||||
plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:membershipId")
|
||||
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<OrgTeamMembershipOutputResponseDto> {
|
||||
const orgTeamMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
|
||||
orgId,
|
||||
teamId,
|
||||
membershipId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
|
||||
async deleteOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<OrgTeamMembershipOutputResponseDto> {
|
||||
const membership = await this.organizationsTeamsMembershipsService.deleteOrgTeamMembership(
|
||||
orgId,
|
||||
teamId,
|
||||
membershipId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
|
||||
async updateOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number,
|
||||
@Body() data: UpdateOrgTeamMembershipDto
|
||||
): Promise<OrgTeamMembershipOutputResponseDto> {
|
||||
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
|
||||
orgId,
|
||||
teamId,
|
||||
membershipId,
|
||||
data
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a membership of an organization's team" })
|
||||
async createOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateOrgTeamMembershipDto
|
||||
): Promise<OrgTeamMembershipOutputResponseDto> {
|
||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(data.userId));
|
||||
|
||||
if (!user) {
|
||||
throw new UnprocessableEntityException("User is not part of the Organization");
|
||||
}
|
||||
|
||||
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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";
|
||||
@@ -135,12 +136,28 @@ describe("Organizations Team Endpoints", () => {
|
||||
name: "Team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi = responseBody.data;
|
||||
expect(teamCreatedViaApi.name).toEqual("Team created via API");
|
||||
expect(teamCreatedViaApi.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
teamCreatedViaApi.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+51
-12
@@ -1,13 +1,21 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
|
||||
import {
|
||||
OrgMeTeamOutputDto,
|
||||
OrgMeTeamsOutputResponseDto,
|
||||
OrgTeamOutputDto,
|
||||
OrgTeamOutputResponseDto,
|
||||
OrgTeamsOutputResponseDto,
|
||||
} from "@/modules/organizations/outputs/organization-team.output";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
@@ -20,11 +28,11 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
@@ -37,12 +45,12 @@ export class OrganizationsTeamsController {
|
||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards()
|
||||
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||
@Roles("ORG_ADMIN")
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
|
||||
): Promise<OrgTeamsOutputResponseDto> {
|
||||
const { skip, take } = queryParams;
|
||||
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
@@ -51,10 +59,38 @@ export class OrganizationsTeamsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/me")
|
||||
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
|
||||
@Roles("ORG_MEMBER")
|
||||
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")
|
||||
@Get("/:teamId")
|
||||
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
@@ -64,10 +100,11 @@ export class OrganizationsTeamsController {
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Delete("/:teamId")
|
||||
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||
async deleteTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -78,11 +115,12 @@ export class OrganizationsTeamsController {
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Patch("/:teamId")
|
||||
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||
async updateTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -91,13 +129,14 @@ export class OrganizationsTeamsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
@ApiOperation({ summary: "Create a team for an organization." })
|
||||
async createTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
|
||||
@Body() body: CreateOrgTeamDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
|
||||
import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input";
|
||||
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
|
||||
import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
|
||||
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Body,
|
||||
UseInterceptors,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ClassSerializerInterceptor } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/users",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(IsOrgGuard)
|
||||
@DocsTags("Organizations Users")
|
||||
export class OrganizationsUsersController {
|
||||
constructor(private readonly organizationsUsersService: OrganizationsUsersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles("ORG_ADMIN")
|
||||
async getOrganizationsUsers(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrganizationsUsersInput
|
||||
): Promise<GetOrganizationUsersOutput> {
|
||||
const users = await this.organizationsUsersService.getUsers(
|
||||
orgId,
|
||||
query.emails,
|
||||
query.skip ?? 0,
|
||||
query.take ?? 250
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles("ORG_ADMIN")
|
||||
async createOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetOrg() org: Team,
|
||||
@Body() input: CreateOrganizationUserInput,
|
||||
@GetUser() inviter: UserWithProfile
|
||||
): Promise<GetOrganizationUserOutput> {
|
||||
const user = await this.organizationsUsersService.createUser(
|
||||
org,
|
||||
input,
|
||||
inviter.name ?? inviter.username ?? inviter.email
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async updateOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@GetOrg() org: Team,
|
||||
@Body() input: UpdateOrganizationUserInput
|
||||
): Promise<GetOrganizationUserOutput> {
|
||||
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async deleteOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetOrganizationUserOutput> {
|
||||
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Users Endpoints", () => {
|
||||
describe("Member role", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const userEmail = "member1@org.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test org 3",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not be able to find org users", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`).expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to create a new org user", async () => {
|
||||
return request(app.getHttpServer()).post(`/v2/organizations/${org.id}/users`).expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to update an org user", async () => {
|
||||
return request(app.getHttpServer()).patch(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to delete an org user", async () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// await membershipFixtures.delete(membership.id);
|
||||
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
describe("Admin role", () => {
|
||||
let app: INestApplication;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "admin1@org.com";
|
||||
const nonMemberEmail = "non-member@test.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let createdUser: User;
|
||||
|
||||
const orgMembersData = [
|
||||
{
|
||||
email: "member1@org.com",
|
||||
username: "member1@org.com",
|
||||
},
|
||||
{
|
||||
email: "member2@org.com",
|
||||
username: "member2@org.com",
|
||||
},
|
||||
{
|
||||
email: "member3@org.com",
|
||||
username: "member3@org.com",
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test org 2",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await userRepositoryFixture.create({
|
||||
email: nonMemberEmail,
|
||||
username: "non-member",
|
||||
});
|
||||
|
||||
const orgMembers = await Promise.all(
|
||||
orgMembersData.map((member) =>
|
||||
userRepositoryFixture.create({
|
||||
email: member.email,
|
||||
username: member.username,
|
||||
organization: { connect: { id: org.id } },
|
||||
})
|
||||
)
|
||||
);
|
||||
// create profiles of orgMember like they would be when being invied to the org
|
||||
await Promise.all(
|
||||
orgMembers.map((member) =>
|
||||
profileRepositoryFixture.create({
|
||||
uid: `usr-${member.id}`,
|
||||
username: member.username ?? `usr-${member.id}`,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: member.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
await Promise.all(
|
||||
orgMembers.map((member) => membershipFixtures.addUserToOrg(member, org, "MEMBER", true))
|
||||
);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all org users", async () => {
|
||||
const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(4);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should only get users with the specified email", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
emails: userEmail,
|
||||
})
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(1);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
|
||||
});
|
||||
|
||||
it("should get users within the specified emails array", async () => {
|
||||
const orgMemberEmail = orgMembersData[0].email;
|
||||
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
emails: [userEmail, orgMemberEmail],
|
||||
})
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(2);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
|
||||
expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1);
|
||||
});
|
||||
|
||||
it("should update an org user", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/users/${user.id}`)
|
||||
.send({
|
||||
theme: "light",
|
||||
})
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data as User;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("should create a new org user", async () => {
|
||||
const newOrgUser = {
|
||||
email: "new-org-member-b@org.com",
|
||||
organizationRole: "MEMBER",
|
||||
autoAccept: true,
|
||||
};
|
||||
|
||||
const emailSpy = jest
|
||||
.spyOn(EmailService.prototype, "sendSignupToOrganizationEmail")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users`)
|
||||
.send({
|
||||
email: newOrgUser.email,
|
||||
})
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
expect(emailSpy).toHaveBeenCalledWith({
|
||||
usernameOrEmail: newOrgUser.email,
|
||||
orgName: org.name,
|
||||
orgId: org.id,
|
||||
inviterName: "admin1@org.com",
|
||||
locale: null,
|
||||
});
|
||||
createdUser = userData;
|
||||
});
|
||||
|
||||
it("should delete an org user", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/users/${createdUser.id}`)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data as User;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.id).toBe(createdUser.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// await membershipFixtures.delete(membership.id);
|
||||
await Promise.all([
|
||||
userRepositoryFixture.deleteByEmail(user.email),
|
||||
userRepositoryFixture.deleteByEmail(nonMemberEmail),
|
||||
...orgMembersData.map((member) => userRepositoryFixture.deleteByEmail(member.email)),
|
||||
]);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class CreateOrgMembershipDto {
|
||||
@IsInt()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly accepted?: boolean = false;
|
||||
|
||||
@IsEnum(MembershipRole)
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
readonly role: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly disableImpersonation?: boolean = false;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class CreateOrgTeamMembershipDto {
|
||||
@IsInt()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly accepted?: boolean = false;
|
||||
|
||||
@IsEnum(MembershipRole)
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
readonly role: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly disableImpersonation?: boolean = false;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator";
|
||||
|
||||
export class CreateOrganizationUserInput extends CreateUserInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale = "en";
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MembershipRole)
|
||||
organizationRole: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoAccept = true;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
||||
|
||||
export class GetOrganizationsUsersInput extends GetUsersInput {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
|
||||
|
||||
export class UpdateOrgMembershipDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly accepted?: boolean = false;
|
||||
|
||||
@IsEnum(MembershipRole)
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
readonly role?: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly disableImpersonation?: boolean = false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user