Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a46375fed | ||
|
|
07de77ddc6 | ||
|
|
cf45443726 | ||
|
|
629b9391dd | ||
|
|
25da11c497 | ||
|
|
003e2630e7 | ||
|
|
4eef7138c1 | ||
|
|
253d0174be | ||
|
|
5ee5c117c4 | ||
|
|
0f268bbef7 | ||
|
|
d342e3f9e0 | ||
|
|
7f1e20fe19 | ||
|
|
3078423848 | ||
|
|
9953d73444 | ||
|
|
100136aae7 | ||
|
|
3c6e9aaa63 | ||
|
|
8fa39f2ae5 | ||
|
|
3aca72fb06 | ||
|
|
c46905e9a7 | ||
|
|
5ce96c7704 | ||
|
|
23eb77b8ac | ||
|
|
e63edc9d36 | ||
|
|
d33681eddd | ||
|
|
e7816cfb0c | ||
|
|
a268d8743d | ||
|
|
45db5c0d5e | ||
|
|
583661a15d | ||
|
|
461d5e0535 | ||
|
|
099d830e7e | ||
|
|
add59896f5 | ||
|
|
03b97fdb51 | ||
|
|
4c576fbd57 | ||
|
|
06ce1988f3 | ||
|
|
c059d79a51 | ||
|
|
b91c767d0c | ||
|
|
bb29edd30f | ||
|
|
b9a8b613bb | ||
|
|
76377d2d2c | ||
|
|
f4f09eaf9e | ||
|
|
89c08868f6 | ||
|
|
ebab3b701d | ||
|
|
9ffa4b8477 | ||
|
|
c656063b06 | ||
|
|
5b7f2a6ffc | ||
|
|
63c7e4b55a | ||
|
|
24e5226e3e | ||
|
|
9f43c5f1e7 | ||
|
|
b48e3c7bb4 | ||
|
|
a561b805fd | ||
|
|
d21c91c519 | ||
|
|
41e565fcf8 | ||
|
|
2f946fc5da | ||
|
|
06bb7055ac | ||
|
|
1a8492b9c2 | ||
|
|
b8ba95dbb0 | ||
|
|
40df4cf511 | ||
|
|
58cabe974c | ||
|
|
199d95b35f | ||
|
|
c6febad364 | ||
|
|
0ddcc068ce | ||
|
|
3ff3a97c78 | ||
|
|
baaa1fa71e | ||
|
|
27d331ad2d | ||
|
|
9a1a86ed3a | ||
|
|
7902c6157d | ||
|
|
92d0fc6eb4 | ||
|
|
4d0b417925 | ||
|
|
9947b5ec85 | ||
|
|
fa06f38d47 | ||
|
|
99a6b1c7eb | ||
|
|
e3861ad22a | ||
|
|
37caa57798 | ||
|
|
6e66f7f8f8 | ||
|
|
62dd5ea95b | ||
|
|
365dfe9178 | ||
|
|
4d89da99a7 | ||
|
|
6954ad1ea5 | ||
|
|
825573da8e | ||
|
|
2c517f4878 | ||
|
|
8508a6f1a0 | ||
|
|
db03e9db20 | ||
|
|
e6a16f5b5c | ||
|
|
b94aa603c5 | ||
|
|
31e6b88951 | ||
|
|
fbff4db2a6 | ||
|
|
ba61731689 | ||
|
|
e16662e655 | ||
|
|
53c37a9ddf | ||
|
|
64ef2703a5 | ||
|
|
a997edd8db | ||
|
|
bd73eba528 | ||
|
|
0d53c2069e | ||
|
|
3b559525a8 | ||
|
|
33e1abf4a7 | ||
|
|
059c6c37ba | ||
|
|
14bc489d70 | ||
|
|
e8b36d5b0b | ||
|
|
5983c960a6 | ||
|
|
40305b9c5a | ||
|
|
dcc62c6db0 | ||
|
|
07f14e4e14 | ||
|
|
f940f6bd87 | ||
|
|
ca5dcfd1e8 | ||
|
|
324728d4ec | ||
|
|
2f7cc05cad | ||
|
|
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 | ||
|
|
0dd3b25624 | ||
|
|
a633e35c66 | ||
|
|
c618e69d8e | ||
|
|
844dd7e3af | ||
|
|
b6c512e5d7 | ||
|
|
6e0035689a | ||
|
|
58540af9ce | ||
|
|
21f30fc47a | ||
|
|
92845d3adc | ||
|
|
8ee27d3997 | ||
|
|
1263519973 | ||
|
|
caf7943c91 | ||
|
|
fed3987028 | ||
|
|
ad61d48706 |
No files matched your search
+7
-3
@@ -15,10 +15,14 @@
|
|||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - 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.
|
# 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.
|
||||||
# @see https://console.cal.com
|
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
|
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||||
|
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||||
|
CAL_SIGNATURE_TOKEN=
|
||||||
|
# The route to the Cal.com License API
|
||||||
|
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# - DATABASE ************************************************************************************************
|
# - 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
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://go.cal.com/discord
|
url: https://github.com/calcom/cal.com/discussions
|
||||||
about: Ask a general question about the project on our discord community
|
about: Need help selfhosting or ask a general question about the project? Open a discussion
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest a feature or idea
|
about: Suggest a feature or idea
|
||||||
title: ""
|
title: ""
|
||||||
labels: feature
|
labels: ["✨ feature", "🚨 needs approval"]
|
||||||
assignees: ""
|
assignees: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,3 +53,4 @@ assignees: ""
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||||
|
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||||
@@ -69,12 +69,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
analyze:
|
|
||||||
name: Analyze Build
|
|
||||||
needs: [build]
|
|
||||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ name: Production Builds
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build API v2
|
name: Build API v2
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
check-types:
|
check-types:
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
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
|
name: E2E App Store Tests
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E Embed React tests and booking flow (for non-embed as well)
|
name: E2E Embed React tests and booking flow (for non-embed as well)
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E
|
name: E2E
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Integration
|
name: Integration
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
@@ -16,13 +19,6 @@ jobs:
|
|||||||
- name: Merge lint reports
|
- name: Merge lint reports
|
||||||
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
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
|
- name: Upload ESLint report
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: "Next.js Bundle Analysis Annotation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
annotate:
|
||||||
|
if: always()
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- uses: ./.github/actions/cache-build
|
||||||
|
- name: Download base branch bundle stats
|
||||||
|
uses: dawidd6/action-download-artifact@v2
|
||||||
|
if: success()
|
||||||
|
with:
|
||||||
|
workflow: nextjs-bundle-analysis.yml
|
||||||
|
branch: ${{ github.event.pull_request.base.ref }}
|
||||||
|
path: apps/web/.next/analyze/base
|
||||||
|
|
||||||
|
# And here's the second place - this runs after we have both the current and
|
||||||
|
# base branch bundle stats, and will compare them to determine what changed.
|
||||||
|
# There are two configurable arguments that come from package.json:
|
||||||
|
#
|
||||||
|
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||||
|
# it's set to 350kb here by default, as informed by the following piece:
|
||||||
|
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||||
|
#
|
||||||
|
# - red-status-percentage: sets the percent size increase where you get a red
|
||||||
|
# status indicator, defaults to 20%
|
||||||
|
#
|
||||||
|
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||||
|
# entry in your package.json file.
|
||||||
|
|
||||||
|
- name: Compare with base branch bundle
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||||
|
|
||||||
|
- name: Get comment body
|
||||||
|
id: get-comment-body
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Find Comment
|
||||||
|
uses: peter-evans/find-comment@v2
|
||||||
|
if: success() && github.event.number
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||||
|
|
||||||
|
- name: Create Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
|
||||||
|
- name: Update Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
edit-mode: replace
|
||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
@@ -63,65 +67,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: bundle
|
name: bundle
|
||||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||||
|
|
||||||
- name: Download base branch bundle stats
|
|
||||||
uses: dawidd6/action-download-artifact@v2
|
|
||||||
if: success()
|
|
||||||
with:
|
|
||||||
workflow: nextjs-bundle-analysis.yml
|
|
||||||
branch: ${{ github.event.pull_request.base.ref }}
|
|
||||||
path: apps/web/.next/analyze/base
|
|
||||||
|
|
||||||
# And here's the second place - this runs after we have both the current and
|
|
||||||
# base branch bundle stats, and will compare them to determine what changed.
|
|
||||||
# There are two configurable arguments that come from package.json:
|
|
||||||
#
|
|
||||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
|
||||||
# it's set to 350kb here by default, as informed by the following piece:
|
|
||||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
|
||||||
#
|
|
||||||
# - red-status-percentage: sets the percent size increase where you get a red
|
|
||||||
# status indicator, defaults to 20%
|
|
||||||
#
|
|
||||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
|
||||||
# entry in your package.json file.
|
|
||||||
- name: Compare with base branch bundle
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
|
||||||
|
|
||||||
- name: Get comment body
|
|
||||||
id: get-comment-body
|
|
||||||
if: success() && github.event.number
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
|
||||||
body="${body//'%'/'%25'}"
|
|
||||||
body="${body//$'\n'/'%0A'}"
|
|
||||||
body="${body//$'\r'/'%0D'}"
|
|
||||||
echo ::set-output name=body::$body
|
|
||||||
|
|
||||||
- name: Find Comment
|
|
||||||
uses: peter-evans/find-comment@v2
|
|
||||||
if: success() && github.event.number
|
|
||||||
id: fc
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
|
||||||
|
|
||||||
- name: Create Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
|
|
||||||
- name: Update Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
|
||||||
edit-mode: replace
|
|
||||||
@@ -8,6 +8,10 @@ on:
|
|||||||
- gh-actions-test-branch
|
- gh-actions-test-branch
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -39,6 +43,8 @@ jobs:
|
|||||||
needs: [changes]
|
needs: [changes]
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
name: Check for E2E label
|
name: Check for E2E label
|
||||||
|
permissions:
|
||||||
|
pull-requests: read
|
||||||
outputs:
|
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')) }}
|
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:
|
steps:
|
||||||
@@ -141,6 +147,13 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e.yml
|
uses: ./.github/workflows/e2e.yml
|
||||||
secrets: inherit
|
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:
|
e2e-app-store:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
@@ -169,7 +182,7 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
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()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
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:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
name: Unit
|
name: Unit
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
workflow_run:
|
permissions:
|
||||||
workflows: [Crowdin Action]
|
contents: read
|
||||||
types: [completed]
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Unit
|
name: Unit
|
||||||
@@ -13,7 +12,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- 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
|
- run: yarn test
|
||||||
# We could add different timezones here that we need to run our tests in
|
# We could add different timezones here that we need to run our tests in
|
||||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
- 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:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
name: Yarn install & cache
|
name: Yarn install & cache
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
|
|||||||
|
|
||||||
To develop locally:
|
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
|
own GitHub account and then
|
||||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
2. Create a new branch:
|
2. Create a new branch:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://cal.com"><strong>Learn more »</strong></a>
|
<a href="https://cal.com"><strong>Learn more »</strong></a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://go.cal.com/discord">Discord</a>
|
<a href="https://github.com/calcom/cal.com/discussions">Discussions</a>
|
||||||
·
|
·
|
||||||
<a href="https://cal.com">Website</a>
|
<a href="https://cal.com">Website</a>
|
||||||
·
|
·
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
|
|
||||||
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||||
<a href="https://discord.com/channels/1125390093386010654/1125398231585661040"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
|
||||||
<a href="https://contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
<a href="https://contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||||
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -392,7 +390,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.
|
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
|
## Repo Activity
|
||||||
|
|
||||||
@@ -431,7 +459,7 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
|
|||||||
|
|
||||||
### Translations
|
### Translations
|
||||||
|
|
||||||
Don't code but still want to contribute? Join our [discord](https://go.cal.com/discord) and join the [#Translate channel](https://discord.com/channels/1125390093386010654/1125398231585661040) and let us know what language you want to translate.
|
Don't code but still want to contribute? Join our [Discussions](https://github.com/calcom/cal.com/discussions) and join the [#Translate channel](https://github.com/calcom/cal.com/discussions/categories/translations) and let us know what language you want to translate.
|
||||||
|
|
||||||
                                             {
|
|||||||
|
|
||||||
// This verifies the apiKey and sets the user if it is valid.
|
// This verifies the apiKey and sets the user if it is valid.
|
||||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
const hasValidLicense = await checkLicense(prisma);
|
const licenseKeyService = await LicenseKeyService.create();
|
||||||
if (!hasValidLicense && IS_PRODUCTION)
|
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||||
|
|
||||||
|
if (!hasValidLicense && IS_PRODUCTION) {
|
||||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||||
|
}
|
||||||
// Check if the apiKey query param is provided.
|
// Check if the apiKey query param is provided.
|
||||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey 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_"
|
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
team: {
|
team: {
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
|
organizationSettings: {
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: 'End time of the Event'
|
* description: 'End time of the Event'
|
||||||
|
* rescheduleUid:
|
||||||
|
* type: string
|
||||||
|
* format: UID
|
||||||
|
* description: 'Uid of the booking to reschedule'
|
||||||
* responses:
|
* responses:
|
||||||
* type: object
|
* type: object
|
||||||
* required:
|
* required:
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
|
|
||||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
userId,
|
userId: !!parsedBody.teamId ? null : userId,
|
||||||
users: { connect: { id: userId } },
|
users: !!parsedBody.teamId ? undefined : { connect: { id: userId } },
|
||||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||||
};
|
};
|
||||||
@@ -291,7 +291,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
await checkUserMembership(req);
|
await checkUserMembership(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSystemWideAdmin && parsedBody.userId) {
|
if (isSystemWideAdmin && parsedBody.userId && !parsedBody.teamId) {
|
||||||
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
teamId: data.teamId,
|
teamId: data.teamId,
|
||||||
usernameOrEmail: data.usernameOrEmail,
|
usernameOrEmail: data.usernameOrEmail,
|
||||||
isOrg: data.isOrg,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
|
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
|
|||||||
* ownerId:
|
* ownerId:
|
||||||
* type: number
|
* type: number
|
||||||
* description: ID of the team owner - only admins can set this.
|
* description: ID of the team owner - only admins can set this.
|
||||||
|
* parentId:
|
||||||
|
* type: number
|
||||||
|
* description: ID of the parent organization.
|
||||||
* tags:
|
* tags:
|
||||||
* - teams
|
* - teams
|
||||||
* responses:
|
* responses:
|
||||||
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if parentId is related to this user
|
// Check if parentId is related to this user and is an organization
|
||||||
if (data.parentId) {
|
if (data.parentId) {
|
||||||
const parentTeam = await prisma.team.findFirst({
|
const parentTeam = await prisma.team.findFirst({
|
||||||
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||||
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (!parentTeam)
|
if (!parentTeam)
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
|
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentTeam.parentId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "parentId must be of an organization, not a team.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!IS_TEAM_BILLING_ENABLED) {
|
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
|
||||||
const team = await prisma.team.create({
|
const team = await prisma.team.create({
|
||||||
data: {
|
data: {
|
||||||
...cloneData,
|
...cloneData,
|
||||||
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
return {
|
return {
|
||||||
team: schemaTeamReadPublic.parse(team),
|
team: schemaTeamReadPublic.parse(team),
|
||||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||||
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
|
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
|||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { Booking } from "@calcom/prisma";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
describe("Success", () => {
|
describe("Success", () => {
|
||||||
describe("Regular event-type", () => {
|
describe("Regular event-type", () => {
|
||||||
|
let createdBooking: Booking;
|
||||||
test("Creates one single booking", async () => {
|
test("Creates one single booking", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
createdBooking = JSON.parse(res._getData());
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Reschedule created booking", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "testReschedule",
|
||||||
|
start: dayjs().add(2, "day").format(),
|
||||||
|
end: dayjs().add(3, "day").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
rescheduleUid: createdBooking.uid,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
||||||
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
|
where: { uid: createdBooking.uid },
|
||||||
|
});
|
||||||
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../pages/api/event-types/_post";
|
||||||
|
import checkParentEventOwnership from "../../../pages/api/event-types/_utils/checkParentEventOwnership";
|
||||||
|
import checkTeamEventEditPermission from "../../../pages/api/event-types/_utils/checkTeamEventEditPermission";
|
||||||
|
import checkUserMembership from "../../../pages/api/event-types/_utils/checkUserMembership";
|
||||||
|
import ensureOnlyMembersAsHosts from "../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts";
|
||||||
|
import { canUserAccessTeamWithRole } from "../../../pages/api/teams/[teamId]/_auth-middleware";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const adminUserId = 1;
|
||||||
|
const memberUserId = 10;
|
||||||
|
|
||||||
|
vi.mock("../../../pages/api/teams/[teamId]/_auth-middleware", () => ({
|
||||||
|
canUserAccessTeamWithRole: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../pages/api/event-types/_utils/checkUserMembership", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../pages/api/event-types/_utils/checkParentEventOwnership", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../pages/api/event-types/_utils/checkTeamEventEditPermission", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/event-types", () => {
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should throw 401 if a non-admin user tries to create an event type with userId", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: "Tennis class",
|
||||||
|
slug: "tennis-class-{{$guid}}",
|
||||||
|
length: 60,
|
||||||
|
hidden: true,
|
||||||
|
userId: memberUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `userId`");
|
||||||
|
});
|
||||||
|
test("should throw 401 if not system-wide admin and user cannot access teamId with required roles", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: "Tennis class",
|
||||||
|
slug: "tennis-class-{{$guid}}",
|
||||||
|
length: 60,
|
||||||
|
hidden: true,
|
||||||
|
teamId: 9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
req.userId = memberUserId;
|
||||||
|
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `teamId`");
|
||||||
|
});
|
||||||
|
test("should throw 400 if system-wide admin but neither userId nor teamId provided", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: "Tennis class",
|
||||||
|
slug: "tennis-class-{{$guid}}",
|
||||||
|
length: 60,
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("`userId` or `teamId` required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should call checkParentEventOwnership and checkUserMembership if parentId is present", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: "Tennis class",
|
||||||
|
slug: "tennis-class-{{$guid}}",
|
||||||
|
length: 60,
|
||||||
|
hidden: true,
|
||||||
|
userId: memberUserId,
|
||||||
|
parentId: 9999,
|
||||||
|
teamId: 9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUserId;
|
||||||
|
|
||||||
|
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
expect(checkParentEventOwnership).toHaveBeenCalled();
|
||||||
|
expect(checkUserMembership).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create event type successfully if all conditions are met", async () => {
|
||||||
|
const eventTypeTest = buildEventType();
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: "test title",
|
||||||
|
slug: "test-slug",
|
||||||
|
length: 60,
|
||||||
|
hidden: true,
|
||||||
|
userId: memberUserId,
|
||||||
|
parentId: 9999,
|
||||||
|
teamId: 9999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUserId;
|
||||||
|
|
||||||
|
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true);
|
||||||
|
vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined);
|
||||||
|
vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined);
|
||||||
|
vi.mocked(checkTeamEventEditPermission).mockImplementationOnce(async () => undefined);
|
||||||
|
vi.mocked(ensureOnlyMembersAsHosts).mockImplementationOnce(async () => undefined);
|
||||||
|
|
||||||
|
prismaMock.eventType.create.mockResolvedValue(eventTypeTest);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
const data = JSON.parse(res._getData());
|
||||||
|
|
||||||
|
expect(prismaMock.eventType.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(data.message).toBe("Event type created successfully");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,9 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
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 prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||||
@@ -20,20 +20,21 @@ afterEach(() => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
|
vi.mock("@calcom/prisma");
|
||||||
return {
|
vi.mock("~/lib/utils/isAdmin", () => ({
|
||||||
default: vi.fn(),
|
isAdminGuard: vi.fn(),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("~/lib/utils/isAdmin", () => {
|
|
||||||
return {
|
|
||||||
isAdminGuard: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Verify API key", () => {
|
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>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -43,7 +44,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(false);
|
vi.mocked(service.checkLicense).mockResolvedValue(false);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -53,9 +54,11 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(res.statusCode).toBe(401);
|
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>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -65,7 +68,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -75,10 +78,11 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(res.statusCode).toBe(401);
|
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>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -97,7 +101,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -107,11 +111,12 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(req.isSystemWideAdmin).toBe(true);
|
expect(req.isSystemWideAdmin).toBe(true);
|
||||||
expect(req.isOrganizationOwnerOrAdmin).toBe(false);
|
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>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -130,7 +135,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -140,6 +145,7 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(req.isSystemWideAdmin).toBe(false);
|
expect(req.isSystemWideAdmin).toBe(false);
|
||||||
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import handler from "../../../pages/api/selected-calendars/_post";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
describe("POST /api/selected-calendars", () => {
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("Returns 403 if non-admin user tries to set userId in body", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(HttpError);
|
||||||
|
expect((e as HttpError).statusCode).toBe(403);
|
||||||
|
expect((e as HttpError).message).toBe("ADMIN required for userId");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Returns 400 if request body is invalid", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Success", () => {
|
||||||
|
test("Creates selected calendar if user is admin and sets bodyUserId", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
apiKey: "validApiKey",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||||
|
id: 444444,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||||
|
credentialId: 1,
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||||
|
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
apiKey: "validApiKey",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
|
||||||
|
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||||
|
credentialId: 1,
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 333333,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||||
|
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ describe("isAdmin guard", () => {
|
|||||||
expect(scope).toBe(ScopeOfAdmin.SystemWide);
|
expect(scope).toBe(ScopeOfAdmin.SystemWide);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Returns org-wide admin when user is set as such", async () => {
|
it("Returns org-wide admin when user is set as such & admin API access is granted", async () => {
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -67,10 +67,21 @@ describe("isAdmin guard", () => {
|
|||||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
req.userId = adminUser.id;
|
req.userId = adminUser.id;
|
||||||
|
|
||||||
const { isAdmin, scope } = await isAdminGuard(req);
|
const { isAdmin, scope } = await isAdminGuard(req);
|
||||||
|
|
||||||
expect(isAdmin).toBe(true);
|
expect(isAdmin).toBe(true);
|
||||||
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
|
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Returns no admin when user is set as org admin but admin API access is revoked", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
|
||||||
|
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
const { isAdmin } = await isAdminGuard(req);
|
||||||
|
expect(isAdmin).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN=
|
|||||||
|
|
||||||
# Stripe Billing
|
# Stripe Billing
|
||||||
STRIPE_PRICE_ID_STARTER=
|
STRIPE_PRICE_ID_STARTER=
|
||||||
|
STRIPE_PRICE_ID_STARTER_OVERAGE=
|
||||||
STRIPE_PRICE_ID_ESSENTIALS=
|
STRIPE_PRICE_ID_ESSENTIALS=
|
||||||
|
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
|
||||||
STRIPE_PRICE_ID_ENTERPRISE=
|
STRIPE_PRICE_ID_ENTERPRISE=
|
||||||
|
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
|
||||||
STRIPE_API_KEY=
|
STRIPE_API_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/user-attachments/assets/ce7c2ecf-6097-469a-8512-c846a9fb665d" height="200" alt="Nest Logo" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||||
|
"reporters": ["default", "jest-summarizing-reporter"]
|
||||||
}
|
}
|
||||||
@@ -18,23 +18,21 @@
|
|||||||
"test:watch": "yarn dev:build && jest --watch",
|
"test:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"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: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",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
|
|
||||||
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
|
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@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.23": "npm:@calcom/platform-libraries@0.0.23",
|
||||||
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
|
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
|
"@nestjs/bull": "^10.1.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
@@ -45,6 +43,7 @@
|
|||||||
"@nestjs/throttler": "^5.1.2",
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@sentry/node": "^8.8.0",
|
"@sentry/node": "^8.8.0",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"bull": "^4.12.4",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { BullModule } from "@nestjs/bull";
|
||||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||||
|
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
@@ -26,21 +27,20 @@ import { AppController } from "./app.controller";
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
BullModule.forRoot({
|
||||||
|
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||||
|
}),
|
||||||
ThrottlerModule.forRootAsync({
|
ThrottlerModule.forRootAsync({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
inject: [RedisService],
|
inject: [RedisService],
|
||||||
useFactory: (redisService: RedisService) => ({
|
useFactory: (redisService: RedisService) => ({
|
||||||
throttlers: [
|
throttlers: [
|
||||||
{
|
{
|
||||||
name: "short",
|
name: "long",
|
||||||
ttl: seconds(10),
|
ttl: seconds(60), // Time to live for the long period in seconds
|
||||||
limit: 3,
|
limit: 120, // Maximum number of requests within the long ttl
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "medium",
|
|
||||||
ttl: seconds(30),
|
|
||||||
limit: 10,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||||
@@ -57,6 +57,10 @@ import { AppController } from "./app.controller";
|
|||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ResponseInterceptor,
|
useClass: ResponseInterceptor,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
@@ -19,7 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
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.23";
|
||||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Bookings Endpoints", () => {
|
describe("Bookings Endpoints", () => {
|
||||||
@@ -90,7 +91,10 @@ describe("Bookings Endpoints", () => {
|
|||||||
const bookingTimeZone = "Europe/London";
|
const bookingTimeZone = "Europe/London";
|
||||||
const bookingLanguage = "en";
|
const bookingLanguage = "en";
|
||||||
const bookingHashedLink = "";
|
const bookingHashedLink = "";
|
||||||
const bookingMetadata = {};
|
const bookingMetadata = {
|
||||||
|
timeFormat: "12",
|
||||||
|
meetingType: "organizer-phone",
|
||||||
|
};
|
||||||
const bookingResponses = {
|
const bookingResponses = {
|
||||||
name: "tester",
|
name: "tester",
|
||||||
email: "tester@example.com",
|
email: "tester@example.com",
|
||||||
@@ -102,7 +106,7 @@ describe("Bookings Endpoints", () => {
|
|||||||
guests: [],
|
guests: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = {
|
const body: CreateBookingInput = {
|
||||||
start: bookingStart,
|
start: bookingStart,
|
||||||
end: bookingEnd,
|
end: bookingEnd,
|
||||||
eventTypeId: bookingEventTypeId,
|
eventTypeId: bookingEventTypeId,
|
||||||
@@ -135,11 +139,68 @@ describe("Bookings Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("should reschedule a booking", () => {
|
||||||
|
it("should reschedule with updated start time, end time & metadata", async () => {
|
||||||
|
const newBookingStart = "2040-05-21T12:30:00.000Z";
|
||||||
|
const newBookingEnd = "2040-05-21T13:30:00.000Z";
|
||||||
|
const bookingEventTypeId = eventTypeId;
|
||||||
|
const bookingTimeZone = "Europe/London";
|
||||||
|
const bookingLanguage = "en";
|
||||||
|
const bookingHashedLink = "";
|
||||||
|
const newBookingMetadata = {
|
||||||
|
timeFormat: "24",
|
||||||
|
meetingType: "attendee-phone",
|
||||||
|
};
|
||||||
|
const bookingResponses = {
|
||||||
|
name: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
notes: "test",
|
||||||
|
guests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateBookingInput = {
|
||||||
|
rescheduleUid: createdBooking.uid,
|
||||||
|
start: newBookingStart,
|
||||||
|
end: newBookingEnd,
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: newBookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||||
|
response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
|
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
|
||||||
|
expect(responseBody.data.id).toBeDefined();
|
||||||
|
expect(responseBody.data.uid).toBeDefined();
|
||||||
|
expect(responseBody.data.startTime).toEqual(newBookingStart);
|
||||||
|
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
|
||||||
|
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
|
||||||
|
expect(responseBody.data.metadata).toEqual(newBookingMetadata);
|
||||||
|
|
||||||
|
createdBooking = responseBody.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should get bookings", async () => {
|
it("should get bookings", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?filters[status]=upcoming")
|
.get("/v2/bookings?filters[status]=upcoming")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
|
|
||||||
const responseBody: GetBookingsOutput = response.body;
|
const responseBody: GetBookingsOutput = response.body;
|
||||||
const fetchedBooking = responseBody.data.bookings[0];
|
const fetchedBooking = responseBody.data.bookings[0];
|
||||||
|
|
||||||
@@ -240,7 +301,8 @@ describe("Bookings Endpoints", () => {
|
|||||||
// });
|
// });
|
||||||
// });
|
// });
|
||||||
|
|
||||||
it("should cancel a booking", async () => {
|
// cancelling a booking hangs the test for some reason
|
||||||
|
it.skip("should cancel a booking", async () => {
|
||||||
const bookingId = createdBooking.id;
|
const bookingId = createdBooking.id;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||||
|
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||||
|
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
@@ -32,20 +34,19 @@ import { Request } from "express";
|
|||||||
import { NextApiRequest } from "next/types";
|
import { NextApiRequest } from "next/types";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { BOOKING_READ, SUCCESS_STATUS } 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 {
|
import {
|
||||||
handleNewBooking,
|
handleNewBooking,
|
||||||
BookingResponse,
|
BookingResponse,
|
||||||
HttpError,
|
HttpError,
|
||||||
handleNewRecurringBooking,
|
handleNewRecurringBooking,
|
||||||
handleInstantMeeting,
|
handleInstantMeeting,
|
||||||
} from "@calcom/platform-libraries-0.0.2";
|
handleMarkNoShow,
|
||||||
|
getAllUserBookings,
|
||||||
|
getBookingInfo,
|
||||||
|
handleCancelBooking,
|
||||||
|
getBookingForReschedule,
|
||||||
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
@@ -149,17 +150,21 @@ export class BookingsController {
|
|||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Body() body: CreateBookingInput,
|
@Body() body: CreateBookingInput,
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||||
): Promise<ApiResponse<unknown>> {
|
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const oAuthClientId = clientId?.toString();
|
||||||
|
|
||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
req.headers["x-cal-force-slug"] = orgSlug;
|
req.headers["x-cal-force-slug"] = orgSlug;
|
||||||
try {
|
try {
|
||||||
const booking = await handleNewBooking(
|
const booking = await handleNewBooking(
|
||||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||||
);
|
);
|
||||||
|
if (booking.userId && booking.uid && booking.startTime) {
|
||||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||||
|
uid: booking.uid,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
fromReschedule: booking.fromReschedule,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: booking,
|
data: booking,
|
||||||
@@ -176,14 +181,22 @@ export class BookingsController {
|
|||||||
@Param("bookingId") bookingId: string,
|
@Param("bookingId") bookingId: string,
|
||||||
@Body() _: CancelBookingInput,
|
@Body() _: CancelBookingInput,
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const oAuthClientId = clientId?.toString();
|
||||||
if (bookingId) {
|
if (bookingId) {
|
||||||
try {
|
try {
|
||||||
req.body.id = parseInt(bookingId);
|
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 {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
|
data: {
|
||||||
|
bookingId: res.bookingId,
|
||||||
|
bookingUid: res.bookingUid,
|
||||||
|
onlyRemovedAttendee: res.onlyRemovedAttendee,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleBookingErrors(err);
|
this.handleBookingErrors(err);
|
||||||
@@ -194,6 +207,27 @@ export class BookingsController {
|
|||||||
throw new InternalServerErrorException("Could not cancel booking.");
|
throw new InternalServerErrorException("Could not cancel booking.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/mark-no-show")
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
async markNoShow(
|
||||||
|
@Body() body: MarkNoShowInput,
|
||||||
|
@Param("bookingUid") bookingUid: string
|
||||||
|
): Promise<MarkNoShowOutput> {
|
||||||
|
try {
|
||||||
|
const markNoShowResponse = await handleMarkNoShow({
|
||||||
|
bookingUid: bookingUid,
|
||||||
|
attendees: body.attendees,
|
||||||
|
noShowHost: body.noShowHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||||
|
} catch (err) {
|
||||||
|
this.handleBookingErrors(err, "no-show");
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException("Could not mark no show.");
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/recurring")
|
@Post("/recurring")
|
||||||
async createRecurringBooking(
|
async createRecurringBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@@ -206,7 +240,14 @@ export class BookingsController {
|
|||||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
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 {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -231,7 +272,15 @@ export class BookingsController {
|
|||||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
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 {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -287,8 +336,14 @@ export class BookingsController {
|
|||||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
private handleBookingErrors(
|
||||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
err: Error | HttpError | unknown,
|
||||||
|
type?: "recurring" | `instant` | "no-show"
|
||||||
|
): void {
|
||||||
|
const errMsg =
|
||||||
|
type === "no-show"
|
||||||
|
? `Error while marking no-show.`
|
||||||
|
: `Error while creating ${type ? type + " " : ""}booking.`;
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
const httpError = err as HttpError;
|
const httpError = err as HttpError;
|
||||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
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.23";
|
||||||
|
|
||||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowInput {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HandleMarkNoShowData {
|
||||||
|
@IsString()
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
messageKey?: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: HandleMarkNoShowData,
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => HandleMarkNoShowData)
|
||||||
|
data!: HandleMarkNoShowData;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
|
|||||||
AppleCalendarService,
|
AppleCalendarService,
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
AppsRepository,
|
AppsRepository,
|
||||||
|
CalendarsRepository,
|
||||||
],
|
],
|
||||||
controllers: [CalendarsController],
|
controllers: [CalendarsController],
|
||||||
exports: [CalendarsService],
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-25
@@ -1,5 +1,6 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
@@ -16,7 +17,9 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
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 {
|
import {
|
||||||
GOOGLE_CALENDAR,
|
GOOGLE_CALENDAR,
|
||||||
OFFICE_365_CALENDAR,
|
OFFICE_365_CALENDAR,
|
||||||
@@ -27,20 +30,6 @@ import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platfo
|
|||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
class CalendarsServiceMock {
|
|
||||||
async getCalendars() {
|
|
||||||
return {
|
|
||||||
connectedCalendars: [
|
|
||||||
{
|
|
||||||
integration: {
|
|
||||||
type: "google_calendar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Platform Calendars Endpoints", () => {
|
describe("Platform Calendars Endpoints", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@@ -66,8 +55,7 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
})
|
})
|
||||||
.overrideProvider(CalendarsService)
|
|
||||||
.useClass(CalendarsServiceMock)
|
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -85,6 +73,9 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
accessTokenSecret = tokens.accessToken;
|
accessTokenSecret = tokens.accessToken;
|
||||||
refreshTokenSecret = tokens.refreshToken;
|
refreshTokenSecret = tokens.refreshToken;
|
||||||
await app.init();
|
await app.init();
|
||||||
|
jest
|
||||||
|
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||||
|
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
@@ -116,15 +107,16 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.expect(401);
|
.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 () => {
|
// TODO: Uncomment this once CI is ready to run proper Office365 tests
|
||||||
const response = await request(app.getHttpServer())
|
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
// const response = await request(app.getHttpServer())
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||||
.set("Origin", CLIENT_REDIRECT_URI)
|
// .set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
.expect(200);
|
// .set("Origin", CLIENT_REDIRECT_URI)
|
||||||
const data = response.body.data;
|
// .expect(200);
|
||||||
expect(data.authUrl).toBeDefined();
|
// 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 () => {
|
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())
|
const response = await request(app.getHttpServer())
|
||||||
@@ -200,6 +192,26 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.expect(200);
|
.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 () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.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 { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.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 { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||||
@@ -27,6 +33,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -50,7 +57,8 @@ export class CalendarsController {
|
|||||||
private readonly calendarsService: CalendarsService,
|
private readonly calendarsService: CalendarsService,
|
||||||
private readonly outlookService: OutlookService,
|
private readonly outlookService: OutlookService,
|
||||||
private readonly googleCalendarService: GoogleCalendarService,
|
private readonly googleCalendarService: GoogleCalendarService,
|
||||||
private readonly appleCalendarService: AppleCalendarService
|
private readonly appleCalendarService: AppleCalendarService,
|
||||||
|
private readonly calendarsRepository: CalendarsRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(ApiAuthGuard)
|
@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 { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
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.23";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import {
|
import {
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
@@ -17,8 +18,7 @@ import { User } from "@prisma/client";
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
|
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.23";
|
||||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
|
|
||||||
import { Calendar } from "@calcom/platform-types";
|
import { Calendar } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export class CalendarsService {
|
|||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly credentialsRepository: CredentialsRepository,
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
private readonly appsRepository: AppsRepository,
|
private readonly appsRepository: AppsRepository,
|
||||||
|
private readonly calendarsRepository: CalendarsRepository,
|
||||||
private readonly dbRead: PrismaReadService,
|
private readonly dbRead: PrismaReadService,
|
||||||
private readonly dbWrite: PrismaWriteService,
|
private readonly dbWrite: PrismaWriteService,
|
||||||
private readonly config: ConfigService
|
private readonly config: ConfigService
|
||||||
@@ -137,4 +138,11 @@ export class CalendarsService {
|
|||||||
|
|
||||||
return { client_id, client_secret };
|
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,
|
EventTypesPublic,
|
||||||
eventTypeBookingFields,
|
eventTypeBookingFields,
|
||||||
eventTypeLocations,
|
eventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.2";
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Event types Endpoints", () => {
|
describe("Event types Endpoints", () => {
|
||||||
|
|||||||
+1
-2
@@ -39,8 +39,7 @@ import {
|
|||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
|
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||||
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
|
import { getEventTypeById } from "@calcom/platform-libraries-0.0.23";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
+7
-2
@@ -9,8 +9,12 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
|
|||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
|
import {
|
||||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
|
createEventType,
|
||||||
|
updateEventType,
|
||||||
|
EventTypesPublic,
|
||||||
|
getEventTypesPublic,
|
||||||
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { EventType } from "@calcom/prisma/client";
|
import { EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,6 +60,7 @@ export class EventTypesService_2024_04_15 {
|
|||||||
const profileId = user.movedToProfile?.id || null;
|
const profileId = user.movedToProfile?.id || null;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
organization: { isOrgAdmin },
|
organization: { isOrgAdmin },
|
||||||
profile: { id: profileId },
|
profile: { id: profileId },
|
||||||
|
|||||||
+90
-2
@@ -9,10 +9,11 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
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 * as request from "supertest";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
@@ -63,12 +64,18 @@ describe("Event types Endpoints", () => {
|
|||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
|
||||||
|
|
||||||
const userEmail = "event-types-test-e2e@api.com";
|
const userEmail = "event-types-test-e2e@api.com";
|
||||||
|
const falseTestEmail = "false-event-types@api.com";
|
||||||
const name = "bob-the-builder";
|
const name = "bob-the-builder";
|
||||||
const username = name;
|
const username = name;
|
||||||
let eventType: EventTypeOutput_2024_06_14;
|
let eventType: EventTypeOutput_2024_06_14;
|
||||||
let user: User;
|
let user: User;
|
||||||
|
let falseTestUser: User;
|
||||||
|
let firstSchedule: Schedule;
|
||||||
|
let secondSchedule: Schedule;
|
||||||
|
let falseTestSchedule: Schedule;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -91,6 +98,7 @@ describe("Event types Endpoints", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
@@ -100,6 +108,30 @@ describe("Event types Endpoints", () => {
|
|||||||
username,
|
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();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,6 +155,40 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(user).toBeDefined();
|
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 () => {
|
it("should create an event type", async () => {
|
||||||
const body: CreateEventTypeInput_2024_06_14 = {
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
title: "Coding class",
|
title: "Coding class",
|
||||||
@@ -145,6 +211,7 @@ describe("Event types Endpoints", () => {
|
|||||||
options: ["javascript", "python", "cobol"],
|
options: ["javascript", "python", "cobol"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
scheduleId: firstSchedule.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -162,6 +229,7 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(createdEventType.locations).toEqual(body.locations);
|
expect(createdEventType.locations).toEqual(body.locations);
|
||||||
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
||||||
expect(createdEventType.ownerId).toEqual(user.id);
|
expect(createdEventType.ownerId).toEqual(user.id);
|
||||||
|
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||||
|
|
||||||
eventType = responseBody.data;
|
eventType = responseBody.data;
|
||||||
});
|
});
|
||||||
@@ -172,6 +240,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
|
scheduleId: secondSchedule.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -191,11 +260,25 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||||
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
|
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||||
expect(updatedEventType.ownerId).toEqual(user.id);
|
expect(updatedEventType.ownerId).toEqual(user.id);
|
||||||
|
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
|
||||||
|
|
||||||
eventType.title = newTitle;
|
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 () => {
|
it(`/GET/:id`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/${eventType.id}`)
|
.get(`/api/v2/event-types/${eventType.id}`)
|
||||||
@@ -272,7 +355,7 @@ describe("Event types Endpoints", () => {
|
|||||||
.expect(404);
|
.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);
|
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,6 +372,11 @@ describe("Event types Endpoints", () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
// 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();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
-1
@@ -77,7 +77,6 @@ export class EventTypesController_2024_06_14 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@Permissions([EVENT_TYPE_READ])
|
|
||||||
async getEventTypes(
|
async getEventTypes(
|
||||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||||
): Promise<GetEventTypesOutput_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 { 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 { 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 { 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 { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||||
@@ -20,8 +21,14 @@ import { Module } from "@nestjs/common";
|
|||||||
OutputEventTypesService_2024_06_14,
|
OutputEventTypesService_2024_06_14,
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
UsersService,
|
UsersService,
|
||||||
|
SchedulesRepository_2024_06_11,
|
||||||
],
|
],
|
||||||
controllers: [EventTypesController_2024_06_14],
|
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 {}
|
export class EventTypesModule_2024_06_14 {}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
getEventTypeById,
|
getEventTypeById,
|
||||||
transformApiEventTypeBookingFields,
|
transformApiEventTypeBookingFields,
|
||||||
transformApiEventTypeLocations,
|
transformApiEventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.13";
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
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 { 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 { 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 { 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 { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
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 { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
import { createEventType, updateEventType } from "@calcom/platform-libraries";
|
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.23";
|
||||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
|
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.23";
|
||||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.13";
|
import { dynamicEvent } from "@calcom/platform-libraries-0.0.23";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
UpdateEventTypeInput_2024_06_14,
|
UpdateEventTypeInput_2024_06_14,
|
||||||
@@ -30,7 +31,8 @@ export class EventTypesService_2024_06_14 {
|
|||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
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) {
|
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
|
||||||
@@ -61,6 +63,7 @@ export class EventTypesService_2024_06_14 {
|
|||||||
if (existsWithSlug) {
|
if (existsWithSlug) {
|
||||||
throw new BadRequestException("User already has an event type with this slug.");
|
throw new BadRequestException("User already has an event type with this slug.");
|
||||||
}
|
}
|
||||||
|
await this.checkUserOwnsSchedule(userId, body.scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
|
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
|
||||||
@@ -94,6 +97,7 @@ export class EventTypesService_2024_06_14 {
|
|||||||
const profileId = user.movedToProfile?.id || null;
|
const profileId = user.movedToProfile?.id || null;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
organization: { isOrgAdmin },
|
organization: { isOrgAdmin },
|
||||||
profile: { id: profileId },
|
profile: { id: profileId },
|
||||||
@@ -202,7 +206,7 @@ export class EventTypesService_2024_06_14 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
|
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 eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||||
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
|
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
|
||||||
await updateEventType({
|
await updateEventType({
|
||||||
@@ -224,12 +228,13 @@ export class EventTypesService_2024_06_14 {
|
|||||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
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);
|
const existingEventType = await this.getUserEventType(userId, eventTypeId);
|
||||||
if (!existingEventType) {
|
if (!existingEventType) {
|
||||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
}
|
}
|
||||||
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
|
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
|
||||||
|
await this.checkUserOwnsSchedule(userId, scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
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}`);
|
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 {
|
import {
|
||||||
transformApiEventTypeBookingFields,
|
transformApiEventTypeBookingFields,
|
||||||
transformApiEventTypeLocations,
|
transformApiEventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.13";
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -29,12 +29,14 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
|
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
|
||||||
const { locations, bookingFields, ...rest } = inputEventType;
|
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
|
||||||
|
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...rest,
|
...rest,
|
||||||
|
length: lengthInMinutes,
|
||||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||||
|
schedule: scheduleId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
|
|||||||
+4
-8
@@ -9,7 +9,7 @@ import {
|
|||||||
parseRecurringEvent,
|
parseRecurringEvent,
|
||||||
TransformedLocationsSchema,
|
TransformedLocationsSchema,
|
||||||
BookingFieldsSchema,
|
BookingFieldsSchema,
|
||||||
} from "@calcom/platform-libraries-0.0.13";
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
|
|
||||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||||
type DatabaseEventType = EventType & EventTypeRelations;
|
type DatabaseEventType = EventType & EventTypeRelations;
|
||||||
@@ -41,7 +41,7 @@ type Input = Pick<
|
|||||||
| "recurringEvent"
|
| "recurringEvent"
|
||||||
| "metadata"
|
| "metadata"
|
||||||
| "users"
|
| "users"
|
||||||
| "schedule"
|
| "scheduleId"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -68,13 +68,13 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
successRedirectUrl,
|
successRedirectUrl,
|
||||||
seatsShowAvailabilityCount,
|
seatsShowAvailabilityCount,
|
||||||
isInstantEvent,
|
isInstantEvent,
|
||||||
|
scheduleId,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
|
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
|
||||||
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||||
const schedule = await this.getSchedule(databaseEventType);
|
|
||||||
const users = this.transformUsers(databaseEventType.users);
|
const users = this.transformUsers(databaseEventType.users);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,7 +104,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
seatsShowAvailabilityCount,
|
seatsShowAvailabilityCount,
|
||||||
isInstantEvent,
|
isInstantEvent,
|
||||||
users,
|
users,
|
||||||
schedule,
|
scheduleId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +128,6 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
return EventTypeMetaDataSchema.parse(metadata);
|
return EventTypeMetaDataSchema.parse(metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSchedule(databaseEventType: Input) {
|
|
||||||
return databaseEventType.schedule || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
transformUsers(users: User[]) {
|
transformUsers(users: User[]) {
|
||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
|
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
|
||||||
|
|||||||
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
class CalendarsServiceMock {
|
|
||||||
async getCalendars() {
|
|
||||||
return {
|
|
||||||
connectedCalendars: [
|
|
||||||
{
|
|
||||||
integration: {
|
|
||||||
type: "google_calendar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Platform Gcal Endpoints", () => {
|
describe("Platform Gcal Endpoints", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
})
|
})
|
||||||
.overrideProvider(CalendarsService)
|
|
||||||
.useClass(CalendarsServiceMock)
|
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
accessTokenSecret = tokens.accessToken;
|
accessTokenSecret = tokens.accessToken;
|
||||||
refreshTokenSecret = tokens.refreshToken;
|
refreshTokenSecret = tokens.refreshToken;
|
||||||
await app.init();
|
await app.init();
|
||||||
|
jest
|
||||||
|
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||||
|
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/oauth/auth-url`)
|
.get(`/v2/gcal/oauth/auth-url`)
|
||||||
.set("Authorization", `Bearer invalid_access_token`)
|
.set("Authorization", `Bearer invalid_access_token`)
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/oauth/auth-url`)
|
.get(`/v2/gcal/oauth/auth-url`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
expect(data.authUrl).toBeDefined();
|
expect(data.authUrl).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
|
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
|
it(`/GET/gcal/oauth/save: without access token`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
|
it(`/GET/gcal/oauth/save: without origin`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
|
it(`/GET/gcal/check with access token but without origin`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/check`)
|
.get(`/v2/gcal/check`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check without access token`, async () => {
|
it(`/GET/gcal/check without access token`, async () => {
|
||||||
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
|
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/check`)
|
.get(`/v2/gcal/check`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
|
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
|
||||||
gcalCredentials = await credentialsRepositoryFixture.create(
|
gcalCredentials = await credentialsRepositoryFixture.create(
|
||||||
"google_calendar",
|
"google_calendar",
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { GcalController } from "@/ee/gcal/gcal.controller";
|
import { GcalController } from "@/ee/gcal/gcal.controller";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
@@ -21,6 +22,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
GCalService,
|
GCalService,
|
||||||
CalendarsService,
|
CalendarsService,
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
|
CalendarsRepository,
|
||||||
],
|
],
|
||||||
controllers: [GcalController],
|
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 () => {
|
it("should not update user associated with access token given invalid timezone", async () => {
|
||||||
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
|
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { userSchemaResponse } from "@calcom/platform-types";
|
|||||||
export class MeController {
|
export class MeController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly schedulesRepository: SchedulesService_2024_04_15
|
private readonly schedulesService: SchedulesService_2024_04_15
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@@ -45,7 +45,7 @@ export class MeController {
|
|||||||
): Promise<UpdateMeOutput> {
|
): Promise<UpdateMeOutput> {
|
||||||
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
|
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
|
||||||
if (bodySchedule.timeZone && user.defaultScheduleId) {
|
if (bodySchedule.timeZone && user.defaultScheduleId) {
|
||||||
await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
|
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
|
||||||
timeZone: bodySchedule.timeZone,
|
timeZone: bodySchedule.timeZone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ export class MeOutput {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
timeZone!: string;
|
timeZone!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
organizationId!: number | null;
|
||||||
}
|
}
|
||||||
+31
@@ -66,6 +66,37 @@ describe("Schedules Endpoints", () => {
|
|||||||
expect(user).toBeDefined();
|
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 () => {
|
it("should create a default schedule", async () => {
|
||||||
const scheduleName = "schedule-name";
|
const scheduleName = "schedule-name";
|
||||||
const scheduleTimeZone = "Europe/Rome";
|
const scheduleTimeZone = "Europe/Rome";
|
||||||
|
|||||||
+11
-1
@@ -19,8 +19,18 @@ export class CreateAvailabilityInput_2024_04_15 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transformStringToDate(value: string, key: string): Date {
|
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");
|
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 timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
|
||||||
const parts = timePart.split(":");
|
const parts = timePart.split(":");
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export class SchedulesRepository_2024_06_11 {
|
|||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getScheduleByIdAndUserId(scheduleId: number, userId: number) {
|
||||||
|
const schedule = await this.dbRead.prisma.schedule.findUnique({
|
||||||
|
where: {
|
||||||
|
id: scheduleId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
async updateSchedule(
|
async updateSchedule(
|
||||||
userId: number,
|
userId: number,
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import {
|
import {
|
||||||
transformApiScheduleOverrides,
|
transformApiScheduleOverrides,
|
||||||
transformApiScheduleAvailability,
|
transformApiScheduleAvailability,
|
||||||
} from "@calcom/platform-libraries-0.0.4";
|
} from "@calcom/platform-libraries-0.0.23";
|
||||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||||
import { ScheduleOverrideInput_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 { Request } from "express";
|
||||||
|
|
||||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
|
import { TRPCError } from "@calcom/platform-libraries-0.0.23";
|
||||||
import { Response } from "@calcom/platform-types";
|
import { Response } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Catch(TRPCError)
|
@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",
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
|||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
|
ProfilesModule,
|
||||||
],
|
],
|
||||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
exports: [NextAuthGuard, ApiAuthGuard],
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { PlatformPlanType } from "@/modules/billing/types";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
|
||||||
@@ -0,0 +1,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,123 @@
|
|||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
describe("PlatformPlanGuard", () => {
|
||||||
|
let guard: PlatformPlanGuard;
|
||||||
|
let reflector: Reflector;
|
||||||
|
let organizationsRepository: OrganizationsRepository;
|
||||||
|
let redisService: RedisService;
|
||||||
|
|
||||||
|
const mockContext = createMockExecutionContext({
|
||||||
|
params: { teamId: "1", orgId: "1" },
|
||||||
|
user: { id: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
reflector = new Reflector();
|
||||||
|
organizationsRepository = createMock<OrganizationsRepository>();
|
||||||
|
redisService = createMock<RedisService>({
|
||||||
|
redis: {
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
set: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(guard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: "sub_123",
|
||||||
|
plan: "SCALE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the organization does not exist", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if the organization is not platform", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: false,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the organization has no subscription", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: null,
|
||||||
|
plan: "STARTER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the user has a lower plan than required", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: "sub_123",
|
||||||
|
plan: "STARTER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if the result is cached in Redis", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the result is cached in Redis", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||||
|
return createMock<ExecutionContext>({
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
params: context.params,
|
||||||
|
user: context.user,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { PlatformPlanType } from "@/modules/billing/types";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformPlanGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const teamId = request.params.teamId as string;
|
||||||
|
const orgId = request.params.orgId as string;
|
||||||
|
const user = request.user as GetUserReturnType;
|
||||||
|
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||||
|
|
||||||
|
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||||
|
teamId ?? "none"
|
||||||
|
}:guard:platformbilling:${minimumPlan}`;
|
||||||
|
|
||||||
|
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||||
|
|
||||||
|
if (cachedAccess) {
|
||||||
|
return cachedAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canAccess = false;
|
||||||
|
|
||||||
|
if (user && orgId) {
|
||||||
|
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||||
|
const isPlatform = team?.isPlatform;
|
||||||
|
const hasSubscription = team?.platformBilling?.subscriptionId;
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
canAccess = false;
|
||||||
|
} else if (!isPlatform) {
|
||||||
|
canAccess = true;
|
||||||
|
} else if (!hasSubscription) {
|
||||||
|
canAccess = false;
|
||||||
|
} else {
|
||||||
|
canAccess = hasMinimumPlan({
|
||||||
|
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
||||||
|
minimumPlan: minimumPlan,
|
||||||
|
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
|
return canAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasMinimumPlanProp = {
|
||||||
|
currentPlan: PlatformPlanType;
|
||||||
|
minimumPlan: PlatformPlanType;
|
||||||
|
plans: PlatformPlanType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||||
|
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
|
||||||
|
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
|
||||||
|
|
||||||
|
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPlanIndex >= minimumPlanIndex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
type CachedData = {
|
||||||
|
org?: Team;
|
||||||
|
canAccess?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
let canAccess = false;
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||||
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
throw new ForbiddenException("No organization id found in request params.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||||
|
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||||
|
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||||
|
request.organization = cachedOrg;
|
||||||
|
return cachedCanAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||||
|
|
||||||
|
if (org?.isOrganization && !org?.isPlatform) {
|
||||||
|
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
|
||||||
|
Number(organizationId)
|
||||||
|
);
|
||||||
|
if (!adminAPIAccessIsEnabledInOrg) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canAccess = true;
|
||||||
|
org &&
|
||||||
|
(await this.redisService.redis.set(
|
||||||
|
REDIS_CACHE_KEY,
|
||||||
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
|
"EX",
|
||||||
|
300
|
||||||
|
));
|
||||||
|
return canAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
type CachedData = {
|
||||||
|
org?: Team;
|
||||||
|
canAccess?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IsOrgGuard implements CanActivate {
|
export class IsOrgGuard implements CanActivate {
|
||||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
constructor(
|
||||||
|
private organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
let canAccess = false;
|
||||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
@@ -16,13 +26,33 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||||
|
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
|
|
||||||
if (org && org.isOrganization) {
|
if (cachedData) {
|
||||||
request.organization = org;
|
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||||
return true;
|
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||||
|
request.organization = cachedOrg;
|
||||||
|
return cachedCanAccess;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||||
|
|
||||||
|
if (org?.isOrganization) {
|
||||||
|
request.organization = org;
|
||||||
|
canAccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
await this.redisService.redis.set(
|
||||||
|
REDIS_CACHE_KEY,
|
||||||
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
|
"EX",
|
||||||
|
300
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canAccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,20 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
reflector = new Reflector();
|
reflector = new Reflector();
|
||||||
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
|
guard = new PermissionsGuard(
|
||||||
|
reflector,
|
||||||
|
createMock<TokensRepository>(),
|
||||||
|
createMock<ConfigService>({
|
||||||
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "api.apiKeyPrefix":
|
||||||
|
return "cal_";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
|
|||||||
@@ -101,23 +101,23 @@ export class RolesGuard implements CanActivate {
|
|||||||
// if the user is admin or owner of org, allow request because org > team
|
// if the user is admin or owner of org, allow request because org > team
|
||||||
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
|
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
|
||||||
canAccess = true;
|
canAccess = true;
|
||||||
}
|
} else {
|
||||||
|
if (!teamMembership) {
|
||||||
|
this.logger.log(
|
||||||
|
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!teamMembership) {
|
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
|
||||||
this.logger.log(
|
canAccess = hasMinimumRole({
|
||||||
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
checkRole: `TEAM_${teamMembership.role}`,
|
||||||
);
|
minimumRole: allowedRole,
|
||||||
throw new ForbiddenException(
|
roles: TEAM_ROLES,
|
||||||
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
|
|
||||||
canAccess = hasMinimumRole({
|
|
||||||
checkRole: `TEAM_${teamMembership.role}`,
|
|
||||||
minimumRole: allowedRole,
|
|
||||||
roles: TEAM_ROLES,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if allowed role is a ORG ROLE, check org membersip role
|
// if allowed role is a ORG ROLE, check org membersip role
|
||||||
@@ -129,7 +129,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 3600);
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
return canAccess;
|
return canAccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-37
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
|||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||||
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { MockedRedisService } from "test/mocks/mock-redis-service";
|
import { MockedRedisService } from "test/mocks/mock-redis-service";
|
||||||
|
|
||||||
|
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||||
|
|
||||||
describe("ApiAuthStrategy", () => {
|
describe("ApiAuthStrategy", () => {
|
||||||
@@ -33,19 +37,26 @@ describe("ApiAuthStrategy", () => {
|
|||||||
let oAuthClient: PlatformOAuthClient;
|
let oAuthClient: PlatformOAuthClient;
|
||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
const validApiKeyEmail = "api-key-user-email@example.com";
|
const validApiKeyEmail = "api-key-user-email@example.com";
|
||||||
const validAccessTokenEmail = "access-token-user-email@example.com";
|
const validAccessTokenEmail = "access-token-user-email@example.com";
|
||||||
|
const validOAuthEmail = "oauth-user@example.com";
|
||||||
|
|
||||||
let validApiKeyUser: User;
|
let validApiKeyUser: User;
|
||||||
let validAccessTokenUser: User;
|
let validAccessTokenUser: User;
|
||||||
|
let validOAuthUser: User;
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
ignoreEnvFile: true,
|
ignoreEnvFile: true,
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
|
ProfilesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||||
|
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||||
validApiKeyUser = await userRepositoryFixture.create({
|
validApiKeyUser = await userRepositoryFixture.create({
|
||||||
email: validApiKeyEmail,
|
email: validApiKeyEmail,
|
||||||
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
|
|||||||
validAccessTokenUser = await userRepositoryFixture.create({
|
validAccessTokenUser = await userRepositoryFixture.create({
|
||||||
email: validAccessTokenEmail,
|
email: validAccessTokenEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
validOAuthUser = await userRepositoryFixture.create({
|
||||||
|
email: validOAuthEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await profilesRepositoryFixture.create({
|
||||||
|
uid: "asd-asd",
|
||||||
|
username: validOAuthEmail,
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
name: "name",
|
name: "name",
|
||||||
@@ -89,30 +113,14 @@ describe("ApiAuthStrategy", () => {
|
|||||||
|
|
||||||
describe("authenticate with strategy", () => {
|
describe("authenticate with strategy", () => {
|
||||||
it("should return user associated with valid access token", async () => {
|
it("should return user associated with valid access token", async () => {
|
||||||
console.log("HERERERER");
|
|
||||||
const { accessToken } = await tokensRepositoryFixture.createTokens(
|
const { accessToken } = await tokensRepositoryFixture.createTokens(
|
||||||
validAccessTokenUser.id,
|
validAccessTokenUser.id,
|
||||||
oAuthClient.id
|
oAuthClient.id
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("Access token", accessToken);
|
const user = await strategy.accessTokenStrategy(accessToken);
|
||||||
|
expect(user).toBeDefined();
|
||||||
const context: ExecutionContext = {
|
expect(user?.id).toEqual(validAccessTokenUser.id);
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => ({
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
get: (key: string) =>
|
|
||||||
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
} as ExecutionContext;
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const user = await strategy.authenticate(request);
|
|
||||||
|
|
||||||
await expect(user.id).toEqual(validAccessTokenUser.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return user associated with valid api key", async () => {
|
it("should return user associated with valid api key", async () => {
|
||||||
@@ -120,22 +128,15 @@ describe("ApiAuthStrategy", () => {
|
|||||||
now.setDate(now.getDate() + 1);
|
now.setDate(now.getDate() + 1);
|
||||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
|
||||||
|
|
||||||
const context: ExecutionContext = {
|
const user = await strategy.apiKeyStrategy(keyString);
|
||||||
switchToHttp: () => ({
|
expect(user).toBeDefined();
|
||||||
getRequest: () => ({
|
expect(user?.id).toEqual(validApiKeyUser.id);
|
||||||
headers: {
|
});
|
||||||
authorization: `Bearer cal_test_${keyString}`,
|
|
||||||
},
|
|
||||||
get: (key: string) =>
|
|
||||||
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
} as ExecutionContext;
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const user = await strategy.authenticate(request);
|
it("should return user associated with valid OAuth client", async () => {
|
||||||
|
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
|
||||||
expect(user.id).toEqual(validApiKeyUser.id);
|
expect(user).toBeDefined();
|
||||||
|
expect(user.id).toEqual(validOAuthUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw 401 if api key is invalid", async () => {
|
it("should throw 401 if api key is invalid", async () => {
|
||||||
@@ -161,7 +162,55 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw 401 if Authorization header does not contain auth token", async () => {
|
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
headers: {
|
||||||
|
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
|
||||||
|
[X_CAL_SECRET_KEY]: `secret`,
|
||||||
|
},
|
||||||
|
get: (key: string) =>
|
||||||
|
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.authenticate(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
expect(error.getStatus()).toEqual(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw 401 if OAuth secret is invalid", async () => {
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
headers: {
|
||||||
|
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
|
||||||
|
[X_CAL_SECRET_KEY]: `gibberish`,
|
||||||
|
},
|
||||||
|
get: (key: string) =>
|
||||||
|
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.authenticate(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
expect(error.getStatus()).toEqual(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
|
||||||
const context: ExecutionContext = {
|
const context: ExecutionContext = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getRequest: () => ({
|
getRequest: () => ({
|
||||||
@@ -180,4 +229,13 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await userRepositoryFixture.delete(validApiKeyUser.id);
|
||||||
|
await userRepositoryFixture.delete(validAccessTokenUser.id);
|
||||||
|
await userRepositoryFixture.delete(validOAuthUser.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
module.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
|||||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||||
@@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
private readonly oauthFlowService: OAuthFlowService,
|
private readonly oauthFlowService: OAuthFlowService,
|
||||||
private readonly tokensRepository: TokensRepository,
|
private readonly tokensRepository: TokensRepository,
|
||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeyRepository
|
private readonly apiKeyRepository: ApiKeyRepository,
|
||||||
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
|
private readonly profilesRepository: ProfilesRepository
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(request: Request) {
|
async authenticate(request: Request) {
|
||||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
try {
|
||||||
if (!authString) {
|
const { params } = request;
|
||||||
return this.error(new UnauthorizedException("No Authorization header provided"));
|
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
|
||||||
|
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
|
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||||
|
|
||||||
|
if (oAuthClientId && oAuthClientSecret) {
|
||||||
|
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bearerToken) {
|
||||||
|
const requestOrigin = request.get("Origin");
|
||||||
|
return await this.authenticateBearerToken(bearerToken, requestOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return this.error(err);
|
||||||
|
}
|
||||||
|
return this.error(
|
||||||
|
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
|
||||||
|
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
|
||||||
|
return this.success(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
|
||||||
|
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestOrigin = request.get("Origin");
|
if (client.secret !== oAuthClientSecret) {
|
||||||
|
throw new UnauthorizedException("Invalid client secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||||
|
|
||||||
|
if (!platformCreatorId) {
|
||||||
|
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
|
||||||
try {
|
try {
|
||||||
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||||
? await this.apiKeyStrategy(authString)
|
? await this.apiKeyStrategy(authString)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
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 { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { BullModule } from "@nestjs/bull";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
|
imports: [
|
||||||
providers: [BillingConfigService, BillingService, BillingRepository],
|
PrismaModule,
|
||||||
|
StripeModule,
|
||||||
|
MembershipsModule,
|
||||||
|
OrganizationsModule,
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: "billing",
|
||||||
|
limiter: {
|
||||||
|
max: 1,
|
||||||
|
duration: 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
|
||||||
exports: [BillingService, BillingRepository],
|
exports: [BillingService, BillingRepository],
|
||||||
controllers: [BillingController],
|
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()
|
@Injectable()
|
||||||
export class BillingConfigService {
|
export class BillingConfigService {
|
||||||
private readonly config: Map<PlatformPlan, string>;
|
private readonly config: Map<
|
||||||
|
PlatformPlan,
|
||||||
|
{
|
||||||
|
base: string;
|
||||||
|
overage: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
constructor() {
|
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)));
|
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
|
||||||
for (const key of planKeys) {
|
for (const key of planKeys) {
|
||||||
this.config.set(
|
this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], {
|
||||||
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
|
base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "",
|
||||||
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);
|
return this.config.get(plan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { AppConfig } from "@/config/type";
|
import { AppConfig } from "@/config/type";
|
||||||
|
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { PlatformPlan } from "@/modules/billing/types";
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
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 { ConfigService } from "@nestjs/config";
|
||||||
|
import { Queue } from "bull";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService implements OnModuleDestroy {
|
||||||
private logger = new Logger("BillingService");
|
private logger = new Logger("BillingService");
|
||||||
private readonly webAppUrl: string;
|
private readonly webAppUrl: string;
|
||||||
|
|
||||||
@@ -19,9 +22,10 @@ export class BillingService {
|
|||||||
public readonly stripeService: StripeService,
|
public readonly stripeService: StripeService,
|
||||||
private readonly billingRepository: BillingRepository,
|
private readonly billingRepository: BillingRepository,
|
||||||
private readonly configService: ConfigService<AppConfig>,
|
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) {
|
async getBillingData(teamId: number) {
|
||||||
@@ -58,7 +62,10 @@ export class BillingService {
|
|||||||
customer: customerId,
|
customer: customerId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: this.billingConfigService.get(plan),
|
price: this.billingConfigService.get(plan)?.overage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
price: this.billingConfigService.get(plan)?.base,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -99,43 +106,56 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async increaseUsageForTeam(teamId: number) {
|
/**
|
||||||
try {
|
*
|
||||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
* Adds a job to the queue to increment usage of a stripe subscription.
|
||||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
* we delay the job until the booking starts.
|
||||||
this.logger.error("Team did not have stripe subscription associated to it", {
|
* the delay ensure we can adapt to cancel / reschedule.
|
||||||
teamId,
|
*/
|
||||||
});
|
async increaseUsageByUserId(
|
||||||
return void 0;
|
userId: number,
|
||||||
}
|
booking: {
|
||||||
|
uid: string;
|
||||||
|
startTime: Date;
|
||||||
|
fromReschedule?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { uid, startTime, fromReschedule } = booking;
|
||||||
|
|
||||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
const delay = startTime.getTime() - Date.now();
|
||||||
billingSubscription.subscriptionId
|
if (fromReschedule) {
|
||||||
);
|
// cancel the usage increment job for the booking that is being rescheduled
|
||||||
const item = stripeSubscription.items.data[0];
|
await this.cancelUsageByBookingUid(fromReschedule);
|
||||||
// legacy plans are licensed, we cannot create usage records against them
|
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
|
||||||
if (item.price?.recurring?.usage_type === "licensed") {
|
}
|
||||||
return void 0;
|
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,
|
* Cancels the usage increment job for a booking when it is cancelled.
|
||||||
timestamp: "now",
|
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||||
});
|
*/
|
||||||
} catch (error) {
|
async cancelUsageByBookingUid(bookingUid: string) {
|
||||||
// don't fail the request, log it.
|
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||||
this.logger.error("Failed to increase usage for team", {
|
if (job) {
|
||||||
teamId: teamId,
|
await job.remove();
|
||||||
error,
|
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async increaseUsageByClientId(clientId: string) {
|
async onModuleDestroy() {
|
||||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
try {
|
||||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
await this.billingQueue.close();
|
||||||
|
} catch (err) {
|
||||||
return this.increaseUsageForTeam(team?.id);
|
this.logger.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,3 +4,5 @@ export enum PlatformPlan {
|
|||||||
SCALE = "SCALE",
|
SCALE = "SCALE",
|
||||||
ENTERPRISE = "ENTERPRISE",
|
ENTERPRISE = "ENTERPRISE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||||
@@ -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.23";
|
||||||
|
|
||||||
|
@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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,17 @@ import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule, UsersModule],
|
imports: [
|
||||||
|
OAuthClientModule,
|
||||||
|
BillingModule,
|
||||||
|
PlatformEndpointsModule,
|
||||||
|
TimezoneModule,
|
||||||
|
UsersModule,
|
||||||
|
WebhooksModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class EndpointsModule implements NestModule {
|
export class EndpointsModule implements NestModule {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||||
|
import {
|
||||||
|
EventTypeWebhookOutputResponseDto,
|
||||||
|
EventTypeWebhooksOutputResponseDto,
|
||||||
|
} from "@/modules/webhooks/outputs/event-type-webhook.output";
|
||||||
|
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { EventType, Webhook } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("EventTypes WebhooksController (e2e)", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
const userEmail = "event-types-webhook-controller-e2e@api.com";
|
||||||
|
let user: UserWithProfile;
|
||||||
|
let otherUser: UserWithProfile;
|
||||||
|
let eventType: EventType;
|
||||||
|
let eventType2: EventType;
|
||||||
|
let otherEventType: EventType;
|
||||||
|
|
||||||
|
let eventTypeRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let webhookRepositoryFixture: WebhookRepositoryFixture;
|
||||||
|
|
||||||
|
let webhook: EventTypeWebhookOutputResponseDto["data"];
|
||||||
|
let webhook2: Webhook;
|
||||||
|
let otherWebhook: Webhook;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
})
|
||||||
|
).compile();
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
|
||||||
|
eventTypeRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
username: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
otherUser = await userRepositoryFixture.create({
|
||||||
|
email: "other-user-webhook-controller@api.com",
|
||||||
|
username: "other-user-webhook-controller@api.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
eventType = await eventTypeRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "Event Type 1",
|
||||||
|
slug: "webhook-event-type-1",
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
eventType2 = await eventTypeRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "Event Type 2",
|
||||||
|
slug: "webhook-event-type-2",
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
otherEventType = await eventTypeRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "Other Event Type ",
|
||||||
|
slug: "other-webhook-event-type",
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
otherUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
otherWebhook = await webhookRepositoryFixture.create({
|
||||||
|
id: "2mdfnn24",
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
});
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
userRepositoryFixture.deleteByEmail(otherUser.email);
|
||||||
|
webhookRepositoryFixture.delete(otherWebhook.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/webhooks (POST)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/event-types/${eventType.id}/webhooks`)
|
||||||
|
.send({
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
} satisfies CreateWebhookInputDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (res) => {
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
},
|
||||||
|
} satisfies EventTypeWebhookOutputResponseDto);
|
||||||
|
webhook = res.body.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/webhooks (POST)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||||
|
.send({
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
} satisfies CreateWebhookInputDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (res) => {
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
eventTypeId: eventType2.id,
|
||||||
|
},
|
||||||
|
} satisfies EventTypeWebhookOutputResponseDto);
|
||||||
|
webhook2 = res.body.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/event-types/${otherEventType.id}/webhooks`)
|
||||||
|
.send({
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: true,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
} satisfies CreateWebhookInputDto)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||||
|
.send({
|
||||||
|
active: false,
|
||||||
|
} satisfies UpdateWebhookInputDto)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body.data.active).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||||
|
.send({
|
||||||
|
active: false,
|
||||||
|
} satisfies UpdateWebhookInputDto)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: false,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
},
|
||||||
|
} satisfies EventTypeWebhookOutputResponseDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
|
||||||
|
return request(app.getHttpServer()).get(`/v2/event-types/${eventType.id}/webhooks/90284`).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to the eventType", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/event-types/${eventType.id}/webhooks/${otherWebhook.id}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/webhooks (GET)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/event-types/${eventType.id}/webhooks`)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
|
||||||
|
responseBody.data.forEach((webhook) => {
|
||||||
|
expect(webhook.eventTypeId).toBe(eventType.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks (GET)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
|
||||||
|
responseBody.data.forEach((webhook) => {
|
||||||
|
expect(webhook.eventTypeId).toBe(eventType2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
id: expect.any(String),
|
||||||
|
subscriberUrl: "https://example.com",
|
||||||
|
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
|
||||||
|
active: false,
|
||||||
|
payloadTemplate: "string",
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
},
|
||||||
|
} satisfies EventTypeWebhookOutputResponseDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks (DELETE)", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/v2/event-types/${eventType2.id}/webhooks`)
|
||||||
|
.expect(200)
|
||||||
|
.then((res) => {
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
status: "success",
|
||||||
|
data: "1 webhooks deleted",
|
||||||
|
} satisfies DeleteManyWebhooksOutputResponseDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/v2/event-types/${eventType.id}/webhooks/1234453`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
|
||||||
|
import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard";
|
||||||
|
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
|
||||||
|
import {
|
||||||
|
EventTypeWebhookOutputResponseDto,
|
||||||
|
EventTypeWebhookOutputDto,
|
||||||
|
EventTypeWebhooksOutputResponseDto,
|
||||||
|
} from "@/modules/webhooks/outputs/event-type-webhook.output";
|
||||||
|
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
|
||||||
|
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
|
||||||
|
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
|
||||||
|
import { EventTypeWebhooksService } from "@/modules/webhooks/services/event-type-webhooks.service";
|
||||||
|
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Delete,
|
||||||
|
Patch,
|
||||||
|
ParseIntPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Webhook } from "@prisma/client";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/event-types/:eventTypeId/webhooks",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard)
|
||||||
|
@ApiTags("Users' EventTypes Webhooks")
|
||||||
|
export class EventTypeWebhooksController {
|
||||||
|
constructor(
|
||||||
|
private readonly webhooksService: WebhooksService,
|
||||||
|
private readonly eventTypeWebhooksService: EventTypeWebhooksService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@ApiOperation({ summary: "Create a webhook for an event-type" })
|
||||||
|
async createEventTypeWebhook(
|
||||||
|
@Body() body: CreateWebhookInputDto,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||||
|
): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
|
const webhook = await this.eventTypeWebhooksService.createEventTypeWebhook(
|
||||||
|
eventTypeId,
|
||||||
|
new WebhookInputPipe().transform(body)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("/:webhookId")
|
||||||
|
@ApiOperation({ summary: "Update a webhook of an event-type" })
|
||||||
|
async updateEventTypeWebhook(
|
||||||
|
@Body() body: UpdateWebhookInputDto,
|
||||||
|
@Param("webhookId") webhookId: string
|
||||||
|
): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
|
const webhook = await this.webhooksService.updateWebhook(
|
||||||
|
webhookId,
|
||||||
|
new PartialWebhookInputPipe().transform(body)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/:webhookId")
|
||||||
|
@ApiOperation({ summary: "Get a webhook of an event-type" })
|
||||||
|
async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/")
|
||||||
|
@ApiOperation({ summary: "Get all webhooks of an event-type" })
|
||||||
|
async getEventTypeWebhooks(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Query() pagination: SkipTakePagination
|
||||||
|
): Promise<EventTypeWebhooksOutputResponseDto> {
|
||||||
|
const webhooks = await this.eventTypeWebhooksService.getEventTypeWebhooksPaginated(
|
||||||
|
eventTypeId,
|
||||||
|
pagination.skip ?? 0,
|
||||||
|
pagination.take ?? 250
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: webhooks.map((webhook) =>
|
||||||
|
plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("/:webhookId")
|
||||||
|
@ApiOperation({ summary: "Delete a webhook of an event-type" })
|
||||||
|
async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
|
await this.webhooksService.deleteWebhook(webhook.id);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("/")
|
||||||
|
@ApiOperation({ summary: "Delete all webhooks of an event-type" })
|
||||||
|
async deleteAllEventTypeWebhooks(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||||
|
): Promise<DeleteManyWebhooksOutputResponseDto> {
|
||||||
|
const data = await this.eventTypeWebhooksService.deleteAllEventTypeWebhooks(eventTypeId);
|
||||||
|
return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { MembershipRole } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MembershipsRepository {
|
export class MembershipsRepository {
|
||||||
constructor(private readonly dbRead: PrismaReadService) {}
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
@@ -47,4 +49,17 @@ export class MembershipsRepository {
|
|||||||
|
|
||||||
return !!adminMembership;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 656 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user