Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f1019908 | ||
|
|
863fc695ec | ||
|
|
d931fb1f9c | ||
|
|
fd054dfb8d | ||
|
|
699e08b2ee | ||
|
|
e1228ca03c | ||
|
|
9ad69c20cf | ||
|
|
9a5efd39ce | ||
|
|
497b0804d3 | ||
|
|
8b50f419e5 | ||
|
|
9208827406 | ||
|
|
8891381d89 | ||
|
|
ab39f0c8b6 | ||
|
|
cd2382e091 | ||
|
|
154c54a2aa | ||
|
|
e949a4c529 | ||
|
|
64b321ac9c | ||
|
|
e90e060739 | ||
|
|
123074f4ba | ||
|
|
8115c86e37 | ||
|
|
92a48b466c | ||
|
|
7a4298dc00 | ||
|
|
3d379c65e8 | ||
|
|
b1d509ba30 | ||
|
|
6360e73901 | ||
|
|
7e44e686e8 | ||
|
|
0b914bef79 | ||
|
|
a48d190af3 | ||
|
|
c21f0caecf | ||
|
|
1f42a152b0 | ||
|
|
b8f0cc6fa1 | ||
|
|
c6f84e6c4b | ||
|
|
9c3f4ec055 | ||
|
|
f0cfbede7e | ||
|
|
e2a825a9a0 | ||
|
|
963a2a0b76 | ||
|
|
4037367f8c | ||
|
|
0b3b268eed | ||
|
|
2acb5559dc | ||
|
|
be1c6eb65c | ||
|
|
feefa26c90 | ||
|
|
2c25f88100 | ||
|
|
2be9fc8bc0 | ||
|
|
5927b42560 | ||
|
|
5726d18027 | ||
|
|
3312d9cfc6 | ||
|
|
c140b01548 | ||
|
|
c2085c6be9 | ||
|
|
b8b9d04897 | ||
|
|
4497c4ce0b | ||
|
|
cd06141816 | ||
|
|
013646bf2c | ||
|
|
12bbbe3617 | ||
|
|
2c7c9fd716 | ||
|
|
11fc2a738a | ||
|
|
44ab25b4a0 | ||
|
|
4a2c0cb164 | ||
|
|
b1d9e2b84c | ||
|
|
47b9e8a8ac | ||
|
|
42265021fe | ||
|
|
0b0fee3985 | ||
|
|
5718cc5e17 | ||
|
|
361889ded7 | ||
|
|
2260ddcebc | ||
|
|
80c3b91077 | ||
|
|
a11cf130bf | ||
|
|
f9d6ea2a00 | ||
|
|
50f6aa5f58 | ||
|
|
132746af74 | ||
|
|
395381ddcc | ||
|
|
ef88effd9f | ||
|
|
673a2be7c7 | ||
|
|
b1cd3e940e | ||
|
|
711bec8d71 | ||
|
|
7529e331a3 | ||
|
|
cc147d0797 | ||
|
|
c8e8800647 | ||
|
|
99caae8665 | ||
|
|
1f08bc5a4f | ||
|
|
5594e5c713 | ||
|
|
34afc7b674 | ||
|
|
513a58c91c | ||
|
|
375b9190a3 | ||
|
|
83d7bf322a | ||
|
|
06429e27fb | ||
|
|
90bcd711a9 | ||
|
|
8d61b452b1 | ||
|
|
03d0b98155 | ||
|
|
de503add83 | ||
|
|
1a4cf5f91d | ||
|
|
ed53645783 | ||
|
|
c7b1fbdf67 | ||
|
|
6d7f7108e5 | ||
|
|
d9aab9088d | ||
|
|
c80daf43b8 | ||
|
|
2bdf65ce8c | ||
|
|
343e2ac9d7 | ||
|
|
670b82fd23 | ||
|
|
e6f81d09c8 | ||
|
|
8f91ffe692 | ||
|
|
f5dbdae4a9 | ||
|
|
e99f6048d6 | ||
|
|
654a204878 | ||
|
|
7928933852 | ||
|
|
c4cdba1015 | ||
|
|
5ed36a7fe1 | ||
|
|
e77a2f6963 | ||
|
|
a10d3599e2 | ||
|
|
d99f171597 | ||
|
|
da55306a5a | ||
|
|
66378160df | ||
|
|
8aee8d9d3b | ||
|
|
79e33a4d26 | ||
|
|
b5fa43f086 | ||
|
|
0d757039f7 | ||
|
|
44f6f2e320 | ||
|
|
d8eb1df61d | ||
|
|
ef7cac6c50 | ||
|
|
21dd44733f | ||
|
|
b0bfec7247 | ||
|
|
ba5b9e951a | ||
|
|
5e3865b645 | ||
|
|
a02240b579 | ||
|
|
376caa412d | ||
|
|
8722745185 | ||
|
+12 |
a4a3eb5ea9 | ||
|
|
b560475d89 | ||
|
|
4872daef60 | ||
|
|
dfa92f5cb6 | ||
|
|
babb1a3bf7 | ||
|
|
7b5a2726ab | ||
|
|
7d4653296e | ||
|
|
644fb38f87 | ||
|
|
743b155a88 | ||
|
|
664a92a1d0 | ||
|
|
8ae71f6160 | ||
|
|
ab1eab9c54 | ||
|
|
a64b7752c9 | ||
|
|
be728e9c01 | ||
|
|
2146c04444 | ||
|
|
0b94473c7f | ||
|
|
2a1dc619ee | ||
|
|
48a2fc2f67 | ||
|
|
4553455098 | ||
|
|
b4f1b5aebe | ||
|
|
a99e285d76 | ||
|
|
bdea436f8b | ||
|
|
182d0fa228 | ||
|
|
0d7f560c43 | ||
|
|
1377f1a214 | ||
|
|
e8dfa50119 | ||
|
|
706e8e4c1f | ||
|
|
a95729f4a1 | ||
|
|
4b6a389212 | ||
|
|
9d3be8de03 | ||
|
|
b283f1f8cd | ||
|
|
5ff52e1239 | ||
|
|
2b1cb9f878 | ||
|
|
74a46e8ba3 | ||
|
|
5b5f7c1c48 | ||
|
|
9375efffe3 | ||
|
|
da59c9de12 | ||
|
|
2a734faf45 | ||
|
|
934cefecce | ||
|
|
c88811aeee | ||
|
|
f46c3c65fb | ||
|
|
1d203dc566 | ||
|
|
26a1c749f7 | ||
|
|
ad347cb59f | ||
|
|
ac300ff670 | ||
|
|
c834b927b6 | ||
|
|
34ba27407b | ||
|
|
8431cd2ef8 | ||
|
|
cd09a56491 | ||
|
|
5c05a422a8 | ||
|
|
e805c0f00a | ||
|
|
2fa7f7931d | ||
|
|
20729b3a4e | ||
|
|
4d9777af8b | ||
|
|
1534588b34 | ||
|
|
7183ec7f1b | ||
|
|
d836497d56 | ||
|
|
38fa0fae1d | ||
|
|
2872b263fd | ||
|
|
92e53adc8e | ||
|
|
72cc4ab13e | ||
|
|
4e82ffe2ad | ||
|
|
90f6c7e239 | ||
|
|
ffa58d1cdb | ||
|
|
f834e93652 | ||
|
|
ae7fae7da4 | ||
|
|
b6c6e41603 | ||
|
|
e4dc2d0877 | ||
|
|
e43690f190 | ||
|
|
5bbccad41d | ||
|
|
bc53037f37 | ||
|
|
b4a1a8e7bf | ||
|
|
ac43d90d53 | ||
|
|
2b24b4472d | ||
|
|
715cbc1c38 | ||
|
|
d670976a69 | ||
|
|
17ff01af75 | ||
|
|
77af36e2af | ||
|
|
d52b2a23bd | ||
|
|
54e79d3c08 | ||
|
|
498b55efdd | ||
|
|
1e9a4c58a3 | ||
|
|
35a5ed5431 | ||
|
|
4921cd7ecb | ||
|
|
04a0e4642e | ||
|
|
f6ba77ccfe | ||
|
|
9a40838fa4 | ||
|
|
f94494b91e | ||
|
|
2e2a6c73b1 | ||
|
|
6de454b071 | ||
|
|
7936709644 | ||
|
|
d7383b2c80 | ||
|
|
9e980dc0ab | ||
|
|
b879aeef09 | ||
|
|
0eabe7f28b | ||
|
|
3371602e0f | ||
|
|
f642a6ebeb | ||
|
|
bc34e67191 | ||
|
|
2fb1408dd1 | ||
|
|
16ca4dd39c | ||
|
|
6dd8812a19 | ||
|
|
1a60afad7a | ||
|
|
76a2773688 | ||
|
|
8e76d7bd10 | ||
|
|
9749ba1cd0 | ||
|
|
f52497f181 | ||
|
|
c1c4b1278b | ||
|
|
4a914c2dfb | ||
|
|
62d579d03d | ||
|
|
74bed31edc | ||
|
|
97f213f260 | ||
|
|
b84cdc0d45 | ||
|
|
8cb5a7080b | ||
|
|
11f0d48d0b | ||
|
|
c4a1382982 | ||
|
|
e0922c5ee0 | ||
|
|
f8dc7a115a | ||
|
|
a60aa1bf6c | ||
|
|
bc203d7439 | ||
|
|
d82847035f | ||
|
|
6d133c26f8 | ||
|
|
3ae53a4b6e | ||
|
|
63339d8c01 | ||
|
|
44410c87f0 | ||
|
|
c016241004 | ||
|
|
019ef6de2a | ||
|
|
a2b064b269 | ||
|
|
abb4fd307f | ||
|
|
a9ebb0d160 | ||
|
|
1058c0e171 | ||
|
|
021e5f3833 | ||
|
|
5b5a42e72d | ||
|
|
2b43d9a716 | ||
|
|
3a5325f2a8 | ||
|
|
5ad73607f0 | ||
|
|
5067f0dd16 | ||
|
|
a53e697baa | ||
|
|
e7415fe6e2 | ||
|
|
562309d24b | ||
|
|
3900c55204 | ||
|
|
b3be9a93e0 | ||
|
|
d27ccd6f44 | ||
|
|
f86a5db843 | ||
|
|
4cbf9ec9ab | ||
|
|
aa3c91ebd6 | ||
|
|
071c899ea6 | ||
|
|
32b2e83564 | ||
|
|
7ff6d6559a | ||
|
|
2572a5cb93 | ||
|
|
45e0f87e45 | ||
|
|
6e875fccbe | ||
|
|
689e17bcf6 | ||
|
|
584f9c6e93 | ||
|
|
70348c6f64 | ||
|
|
747591fded | ||
|
|
9bfd933699 | ||
|
|
a775e42e85 | ||
|
|
e3e2bc804a | ||
|
|
b84099aec0 | ||
|
|
073d34f893 | ||
|
|
d72900d164 | ||
|
|
e3b478f5b1 | ||
|
|
a5191d1e5a | ||
|
|
ea21cd5826 | ||
|
|
e78c8738fb | ||
|
|
47314bae83 | ||
|
|
fd8afbc418 | ||
|
|
6e7012757f | ||
|
|
933eb0d057 | ||
|
|
fcb79888f3 | ||
|
|
111e2f30c6 | ||
|
|
f2b3e620b4 | ||
|
|
d9133cb395 |
+9
-1
@@ -168,13 +168,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_TEAM_PRODUCT_ID=
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET_APPS=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
# ***********************************************************************************************************
|
||||
@@ -333,7 +333,9 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
||||
AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
@@ -343,12 +345,14 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
||||
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
|
||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_BOOKING_ENABLED=0
|
||||
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
|
||||
APP_ROUTER_WORKFLOWS_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
||||
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||
APP_ROUTER_APPS_ENABLED=0
|
||||
APP_ROUTER_VIDEO_ENABLED=0
|
||||
APP_ROUTER_TEAM_ENABLED=0
|
||||
APP_ROUTER_TEAMS_ENABLED=0
|
||||
APP_ROUTER_AVAILABILITY_ENABLED=0
|
||||
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
|
||||
@@ -392,3 +396,7 @@ VAPID_PRIVATE_KEY=
|
||||
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL=
|
||||
|
||||
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
|
||||
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
|
||||
NEXT_PUBLIC_LOGGER_LEVEL=
|
||||
|
||||
@@ -49,7 +49,9 @@ assignees: ""
|
||||
(Share it here.)
|
||||
|
||||
---
|
||||
|
||||
##### House rules
|
||||
|
||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
## Mandatory Tasks (DO NOT REMOVE)
|
||||
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# This is just to test this file
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- name: Cache Docs build
|
||||
uses: buildjet/cache@v4
|
||||
id: cache-docs-build
|
||||
env:
|
||||
cache-name: docs-build
|
||||
key-1: ${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('docs/**.*', '!**/node_modules') }}
|
||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||
key-4: ${{ github.sha }}
|
||||
with:
|
||||
path: |
|
||||
**/docs/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- name: Run build
|
||||
working-directory: docs
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
|
||||
echo "Cache hit for Docs build. Skipping build."
|
||||
else
|
||||
npm install -g mintlify
|
||||
mintlify dev &
|
||||
sleep 5 # Let it run for 5 seconds
|
||||
kill $!
|
||||
fi
|
||||
shell: bash
|
||||
@@ -15,7 +15,7 @@ env:
|
||||
IS_E2E: true
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
NODE_OPTIONS: --max-old-space-size=29000
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- "!(**.md|.github/CODEOWNERS)"
|
||||
- "!(**.md|.github/CODEOWNERS|docs/**|apps/web/public/static/locales/**/common.json)"
|
||||
- name: Get Latest Commit SHA
|
||||
id: get_sha
|
||||
run: |
|
||||
@@ -139,6 +139,13 @@ jobs:
|
||||
uses: ./.github/workflows/atoms-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-docs:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/docs-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes, check-label, deps]
|
||||
@@ -224,6 +231,8 @@ jobs:
|
||||
build,
|
||||
build-api-v1,
|
||||
build-api-v2,
|
||||
build-atoms,
|
||||
build-docs,
|
||||
e2e,
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
|
||||
@@ -39,7 +39,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Web App
|
||||
name: Build Web App
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -7,14 +7,14 @@ jobs:
|
||||
test:
|
||||
name: Unit
|
||||
timeout-minutes: 20
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- run: yarn test
|
||||
- run: yarn test -- --no-isolate
|
||||
# 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 --no-isolate
|
||||
- name: Run API v2 tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+12
-9
@@ -177,24 +177,25 @@ Do not commit your `yarn.lock` unless you've made changes to the `package.json`.
|
||||
|
||||
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||
|
||||
```bash
|
||||
git checkout HEAD~1 yarn.lock
|
||||
git commit -m "Revert yarn.lock changes"
|
||||
```
|
||||
```bash
|
||||
git checkout HEAD~1 yarn.lock
|
||||
git commit -m "Revert yarn.lock changes"
|
||||
```
|
||||
|
||||
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
|
||||
If you've pushed the commit with the `yarn.lock`:
|
||||
|
||||
1. Correct the commit locally using the above method.
|
||||
2. Carefully force push:
|
||||
1. Correct the commit locally using the above method.
|
||||
2. Carefully force push:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name> --force
|
||||
```
|
||||
```bash
|
||||
git push origin <your-branch-name> --force
|
||||
```
|
||||
|
||||
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||
|
||||
1. **Checkout a Previous Version**:
|
||||
|
||||
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||
|
||||
```bash
|
||||
@@ -208,6 +209,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
|
||||
```
|
||||
|
||||
2. **Commit the Reverted Version**:
|
||||
|
||||
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||
|
||||
```bash
|
||||
@@ -215,6 +217,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
|
||||
```
|
||||
|
||||
3. **Proceed with Caution**:
|
||||
|
||||
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -173,12 +173,30 @@ yarn dx
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**.
|
||||
Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
||||
Where {level} can be one of the following:
|
||||
|
||||
`0` for silly \
|
||||
`1` for trace \
|
||||
`2` for debug \
|
||||
`3` for info \
|
||||
`4` for warn \
|
||||
`5` for error \
|
||||
`6` for fatal
|
||||
|
||||
When you set `NEXT_PUBLIC_LOGGER_LEVEL={level}` in your .env file, it enables logging at that level and higher. Here's how it works:
|
||||
|
||||
The logger will include all logs that are at the specified level or higher. For example: \
|
||||
|
||||
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=2`, it will log from level 2 (debug) upwards, meaning levels 2 (debug), 3 (info), 4 (warn), 5 (error), and (fatal) will be logged. \
|
||||
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=3`, it will log from level 3 (info) upwards, meaning levels 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged, but level 2 (debug) and level 1 (trace) will be ignored. \
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
echo 'NEXT_PUBLIC_LOGGER_LEVEL=3' >> .env
|
||||
```
|
||||
|
||||
for Logger level to be set at info, for example.
|
||||
|
||||
#### Gitpod Setup
|
||||
|
||||
1. Click the button below to open this project in Gitpod.
|
||||
@@ -399,25 +417,25 @@ Cal.com, Inc. is a commercial open source company, which means some parts of thi
|
||||
> [!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 | ❌ | ✅ |
|
||||
| | 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.
|
||||
@@ -513,17 +531,18 @@ following
|
||||
|
||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||
2. On the upper right, click "Develop" => "Build App".
|
||||
3. On "OAuth", select "Create".
|
||||
3. Select "General App" , click "Create".
|
||||
4. Name your App.
|
||||
5. Choose "User-managed app" as the app type.
|
||||
6. De-select the option to publish the app on the Zoom App Marketplace.
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
5. Choose "User-managed app" for "Select how the app is managed".
|
||||
6. De-select the option to publish the app on the Zoom App Marketplace, if asked.
|
||||
7. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
8. Set the "OAuth Redirect URL" under "OAuth Information" as `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
9. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
10. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left,
|
||||
1. click the category "Meeting" and check the scope `meeting:write:meeting`.
|
||||
2. click the category "User" and check the scope `user:read:settings`.
|
||||
11. Click "Done".
|
||||
12. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
### Obtaining Daily API Credentials
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import { _AttendeeModel as Attendee } from "@calcom/prisma/zod";
|
||||
|
||||
import { timeZone } from "~/lib/validations/shared/timeZone";
|
||||
@@ -14,7 +15,7 @@ export const schemaAttendeeBaseBodyParams = Attendee.pick({
|
||||
const schemaAttendeeCreateParams = z
|
||||
.object({
|
||||
bookingId: z.number().int(),
|
||||
email: z.string().email(),
|
||||
email: emailSchema,
|
||||
name: z.string(),
|
||||
timeZone: timeZone,
|
||||
})
|
||||
@@ -23,7 +24,7 @@ const schemaAttendeeCreateParams = z
|
||||
const schemaAttendeeEditParams = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
email: emailSchema.optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { _AttendeeModel, _BookingModel as Booking, _PaymentModel, _UserModel } from "@calcom/prisma/zod";
|
||||
import {
|
||||
_AttendeeModel,
|
||||
_BookingModel as Booking,
|
||||
_EventTypeModel,
|
||||
_PaymentModel,
|
||||
_TeamModel,
|
||||
_UserModel,
|
||||
} from "@calcom/prisma/zod";
|
||||
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryUserId } from "./shared/queryUserId";
|
||||
@@ -46,10 +53,27 @@ export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams
|
||||
.merge(schemaBookingEditParams)
|
||||
.omit({ uid: true });
|
||||
|
||||
const teamSchema = _TeamModel.pick({
|
||||
name: true,
|
||||
slug: true,
|
||||
});
|
||||
|
||||
export const schemaBookingReadPublic = Booking.extend({
|
||||
eventType: _EventTypeModel
|
||||
.pick({
|
||||
title: true,
|
||||
slug: true,
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
team: teamSchema.nullish(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
attendees: z
|
||||
.array(
|
||||
_AttendeeModel.pick({
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
@@ -87,6 +111,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
timeZone: true,
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
payment: true,
|
||||
metadata: true,
|
||||
status: true,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryAttendeeEmail = baseApiParams.extend({
|
||||
attendeeEmail: z.string().email(),
|
||||
attendeeEmail: emailSchema,
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({
|
||||
attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(),
|
||||
attendeeEmail: z.union([emailSchema, z.array(emailSchema)]).optional(),
|
||||
});
|
||||
|
||||
export const withValidQueryAttendeeEmail = withValidation({
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const expandEnum = z.enum(["team"]);
|
||||
|
||||
export const schemaQuerySingleOrMultipleExpand = z
|
||||
.union([
|
||||
expandEnum, // Allow a single value from the enum
|
||||
z.array(expandEnum).refine((arr) => new Set(arr).size === arr.length, {
|
||||
message: "Array values must be unique",
|
||||
}), // Allow an array of enum values, with uniqueness constraint
|
||||
])
|
||||
.optional();
|
||||
@@ -1,16 +1,18 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||
email: z.string().email(),
|
||||
email: emailSchema,
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||
email: z.union([emailSchema, z.array(emailSchema)]),
|
||||
});
|
||||
|
||||
export const withValidQueryUserEmail = withValidation({
|
||||
|
||||
@@ -10,6 +10,8 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
|
||||
isPlatform: true,
|
||||
smsLockState: true,
|
||||
smsLockReviewedByAdmin: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { _UserModel as User } from "@calcom/prisma/zod";
|
||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||
@@ -92,7 +93,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
||||
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
|
||||
// for example making weekStart only accept weekdays as input
|
||||
const schemaUserEditParams = z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
email: emailSchema.toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
@@ -115,7 +116,7 @@ const schemaUserEditParams = z.object({
|
||||
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
||||
|
||||
const schemaUserCreateParams = z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
email: emailSchema.toLowerCase(),
|
||||
username: usernameSchema,
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.4",
|
||||
"next": "^13.5.6",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
*/
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
|
||||
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.parse({
|
||||
...req.query,
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
/**
|
||||
@@ -89,9 +90,21 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
|
||||
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
|
||||
const expand = Array.isArray(queryFilterForExpand)
|
||||
? queryFilterForExpand
|
||||
: queryFilterForExpand
|
||||
? [queryFilterForExpand]
|
||||
: [];
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { attendees: true, user: true, payment: true },
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
payment: true,
|
||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||
},
|
||||
});
|
||||
return { booking: schemaBookingReadPublic.parse(booking) };
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTr
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
|
||||
@@ -32,8 +32,6 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
|
||||
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
|
||||
/**
|
||||
@@ -216,10 +217,18 @@ export async function handler(req: NextApiRequest) {
|
||||
args.take = take;
|
||||
args.skip = skip;
|
||||
}
|
||||
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
|
||||
const expand = Array.isArray(queryFilterForExpand)
|
||||
? queryFilterForExpand
|
||||
: queryFilterForExpand
|
||||
? [queryFilterForExpand]
|
||||
: [];
|
||||
|
||||
args.include = {
|
||||
attendees: true,
|
||||
user: true,
|
||||
payment: true,
|
||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||
};
|
||||
|
||||
const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query);
|
||||
|
||||
@@ -27,12 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
isColdStart = false;
|
||||
}
|
||||
|
||||
const { usernameList, ...rest } = req.query;
|
||||
const { usernameList, isTeamEvent, ...rest } = req.query;
|
||||
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
|
||||
let slugs = usernameList;
|
||||
if (!Array.isArray(usernameList)) {
|
||||
slugs = usernameList ? [usernameList] : undefined;
|
||||
}
|
||||
const input = getScheduleSchema.parse({ usernameList: slugs, ...rest });
|
||||
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
||||
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
||||
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||
const slotsInProvidedTimeZone = timeZoneSupported
|
||||
|
||||
@@ -123,9 +123,11 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
// TODO: Perhaps there is a better fix for this?
|
||||
const cloneData: typeof data & {
|
||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
|
||||
} = {
|
||||
...data,
|
||||
smsLockReviewedByAdmin: false,
|
||||
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
|
||||
@@ -123,9 +123,11 @@ async function postHandler(req: NextApiRequest) {
|
||||
// TODO: Perhaps there is a better fix for this?
|
||||
const cloneData: typeof data & {
|
||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
|
||||
} = {
|
||||
...data,
|
||||
smsLockReviewedByAdmin: false,
|
||||
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ describe("GET /api/bookings", async () => {
|
||||
|
||||
const responseData = await handler(req);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
console.log(booking);
|
||||
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
|
||||
});
|
||||
});
|
||||
@@ -188,4 +187,27 @@ describe("GET /api/bookings", async () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expand feature to add relational data in return payload", () => {
|
||||
it("Returns only team data when expand=team is set", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
expand: "team",
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
|
||||
const responseData = await handler(req);
|
||||
console.log("bookings=>", responseData.bookings);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,4 +26,5 @@ WEB_APP_URL=http://localhost:3000/
|
||||
CALCOM_LICENSE_KEY=
|
||||
API_KEY_PREFIX=cal_
|
||||
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||
IS_E2E=false
|
||||
IS_E2E=false
|
||||
DOCS_URL=
|
||||
|
||||
@@ -44,8 +44,8 @@ Copy `.env.example` to `.env` and fill values.
|
||||
|
||||
## Add license Key to deployments table in DB
|
||||
|
||||
id, logo theme licenseKey agreedLicenseAt
|
||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
||||
id, logo theme licenseKey agreedLicenseAt
|
||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
||||
|
||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"]
|
||||
"reporters": ["default", "jest-summarizing-reporter"],
|
||||
"workerIdleMemoryLimit": "512MB",
|
||||
"maxWorkers": 2
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
@@ -28,13 +28,14 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.34",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.46",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
@@ -43,7 +44,7 @@
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/throttler": "6.2.1",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
@@ -57,10 +58,10 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"luxon": "^3.4.4",
|
||||
"nest-winston": "^1.9.4",
|
||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"qs-stringify": "^1.2.1",
|
||||
"querystring": "^0.2.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
@Controller()
|
||||
@DocsTags("Health - development only")
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
export class AppController {
|
||||
@Get("health")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
|
||||
+373
-14
@@ -1,26 +1,385 @@
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
describe("AppController", () => {
|
||||
describe("Rate limiting", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
|
||||
const userEmail = "app-rate-limits-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
let organization: Team;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer()).get("/health").expect("OK");
|
||||
});
|
||||
let apiKeyString: string;
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
let rateLimit: RateLimit;
|
||||
let apiKeyStringWithRateLimit: string;
|
||||
|
||||
let apiKeyStringWithMultipleLimits: string;
|
||||
let firstRateLimitWithMultipleLimits: RateLimit;
|
||||
let secondRateLimitWithMultipleLimits: RateLimit;
|
||||
|
||||
const mockDefaultLimit = 5;
|
||||
const mockDefaultTtl = 2500;
|
||||
const mockDefaultBlockDuration = 5000;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
}).compile();
|
||||
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
|
||||
jest
|
||||
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
|
||||
.mockReturnValue(mockDefaultBlockDuration);
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
|
||||
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
|
||||
user.id,
|
||||
null
|
||||
);
|
||||
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
|
||||
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
|
||||
|
||||
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
|
||||
await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
|
||||
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"short",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
1000,
|
||||
2,
|
||||
2000
|
||||
);
|
||||
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"long",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
2000,
|
||||
3,
|
||||
4000
|
||||
);
|
||||
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: userEmail,
|
||||
user: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 1023,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it(
|
||||
"api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with custom rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = rateLimit.limit;
|
||||
const blockDuration = rateLimit.blockDuration;
|
||||
const name = rateLimit.name;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
|
||||
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with multiple rate limits - should enforce both short and long rate limits",
|
||||
async () => {
|
||||
const shortLimit = firstRateLimitWithMultipleLimits.limit;
|
||||
const longLimit = secondRateLimitWithMultipleLimits.limit;
|
||||
const shortName = firstRateLimitWithMultipleLimits.name;
|
||||
const longName = secondRateLimitWithMultipleLimits.name;
|
||||
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
|
||||
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
|
||||
|
||||
let requestsMade = 0;
|
||||
// note(Lauris): exhaust short limit to have remaining 0 for it
|
||||
for (let i = 1; i <= shortLimit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
|
||||
for (let i = requestsMade; i < longLimit; i++) {
|
||||
const responseAfterShortLimit = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): both have remaining 0 so now exceed both
|
||||
const blockedResponseLong = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
|
||||
firstRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
|
||||
secondRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
// note(Lauris): wait for short limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, shortBlock));
|
||||
const responseAfterShortLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// note(Lauris): wait for long limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, longBlock));
|
||||
const responseAfterLongLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - 1).toString()
|
||||
);
|
||||
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
},
|
||||
30 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"non api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userEmail);
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import appConfig from "@/config/app";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||
@@ -11,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@@ -36,11 +38,15 @@ import { AppController } from "./app.controller";
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
|
||||
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
|
||||
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
|
||||
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
|
||||
throttlers: [
|
||||
{
|
||||
name: "long",
|
||||
ttl: seconds(60), // Time to live for the long period in seconds
|
||||
limit: 120, // Maximum number of requests within the long ttl
|
||||
name: "dummy",
|
||||
ttl: seconds(60),
|
||||
limit: 120,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
@@ -53,13 +59,20 @@ import { AppController } from "./app.controller";
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: ThrottlerStorageRedisService,
|
||||
useFactory: (redisService: RedisService) => {
|
||||
return new ThrottlerStorageRedisService(redisService.redis);
|
||||
},
|
||||
inject: [RedisService],
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
useClass: CustomThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -77,6 +90,8 @@ export class AppModule implements NestModule {
|
||||
.forRoutes("*")
|
||||
.apply(AppLoggerMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RedirectsMiddleware)
|
||||
.forRoutes("/")
|
||||
.apply(RewriterMiddleware)
|
||||
.forRoutes("/");
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
|
||||
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -12,6 +12,6 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
|
||||
controllers: [BookingsController],
|
||||
controllers: [BookingsController_2024_04_15],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
export class BookingsModule_2024_04_15 {}
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
@@ -24,7 +24,7 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
describe("Bookings Endpoints 2024-04-15", () => {
|
||||
describe("User Authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("Bookings Endpoints", () => {
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
@@ -166,7 +166,7 @@ describe("Bookings Endpoints", () => {
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
@@ -209,7 +209,7 @@ describe("Bookings Endpoints", () => {
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
@@ -266,7 +266,7 @@ describe("Bookings Endpoints", () => {
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body: CreateBookingInput = {
|
||||
const body: CreateBookingInput_2024_04_15 = {
|
||||
rescheduleUid: createdBooking.uid,
|
||||
start: newBookingStart,
|
||||
end: newBookingEnd,
|
||||
@@ -305,7 +305,7 @@ describe("Bookings Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingsOutput = response.body;
|
||||
const responseBody: GetBookingsOutput_2024_04_15 = response.body;
|
||||
|
||||
expect(responseBody.data.bookings.length).toEqual(2);
|
||||
const fetchedBooking = responseBody.data.bookings.find(
|
||||
@@ -329,7 +329,7 @@ describe("Bookings Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingOutput = response.body;
|
||||
const responseBody: GetBookingOutput_2024_04_15 = response.body;
|
||||
const bookingInfo = responseBody.data;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
+34
-25
@@ -1,11 +1,11 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
|
||||
import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input";
|
||||
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
@@ -40,10 +40,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||
import {
|
||||
handleNewRecurringBooking,
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
handleMarkNoShow,
|
||||
getAllUserBookings,
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
getBookingForReschedule,
|
||||
ErrorCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import {
|
||||
GetBookingsInput_2024_04_15,
|
||||
CancelBookingInput_2024_04_15,
|
||||
Status_2024_04_15,
|
||||
} from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@@ -80,11 +84,11 @@ const DEFAULT_PLATFORM_PARAMS = {
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14],
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
export class BookingsController {
|
||||
@DocsExcludeController(true)
|
||||
export class BookingsController_2024_04_15 {
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
|
||||
constructor(
|
||||
@@ -99,16 +103,16 @@ export class BookingsController {
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
|
||||
@ApiQuery({ name: "filters[status]", enum: Status_2024_04_15, required: true })
|
||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||
@ApiQuery({ name: "cursor", type: "number", required: false })
|
||||
async getBookings(
|
||||
@GetUser() user: User,
|
||||
@Query() queryParams: GetBookingsInput
|
||||
): Promise<GetBookingsOutput> {
|
||||
@Query() queryParams: GetBookingsInput_2024_04_15
|
||||
): Promise<GetBookingsOutput_2024_04_15> {
|
||||
const { filters, cursor, limit } = queryParams;
|
||||
const bookings = await getAllUserBookings({
|
||||
bookingListingByStatus: filters.status,
|
||||
bookingListingByStatus: [filters.status],
|
||||
skip: cursor ?? 0,
|
||||
take: limit ?? 10,
|
||||
filters,
|
||||
@@ -125,7 +129,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid")
|
||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput> {
|
||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_04_15> {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
|
||||
if (!bookingInfo) {
|
||||
@@ -155,7 +159,7 @@ export class BookingsController {
|
||||
@Post("/")
|
||||
async createBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() body: CreateBookingInput,
|
||||
@Body() body: CreateBookingInput_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
@@ -186,7 +190,7 @@ export class BookingsController {
|
||||
async cancelBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Param("bookingId") bookingId: string,
|
||||
@Body() _: CancelBookingInput,
|
||||
@Body() _: CancelBookingInput_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
@@ -219,9 +223,9 @@ export class BookingsController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async markNoShow(
|
||||
@GetUser("id") userId: number,
|
||||
@Body() body: MarkNoShowInput,
|
||||
@Body() body: MarkNoShowInput_2024_04_15,
|
||||
@Param("bookingUid") bookingUid: string
|
||||
): Promise<MarkNoShowOutput> {
|
||||
): Promise<MarkNoShowOutput_2024_04_15> {
|
||||
try {
|
||||
const markNoShowResponse = await handleMarkNoShow({
|
||||
bookingUid: bookingUid,
|
||||
@@ -240,7 +244,7 @@ export class BookingsController {
|
||||
@Post("/recurring")
|
||||
async createRecurringBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateRecurringBookingInput[],
|
||||
@Body() _: CreateRecurringBookingInput_2024_04_15[],
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<BookingResponse[]>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
@@ -278,7 +282,7 @@ export class BookingsController {
|
||||
@Post("/instant")
|
||||
async createInstantBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateBookingInput,
|
||||
@Body() _: CreateBookingInput_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
@@ -369,7 +373,12 @@ export class BookingsController {
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
Object.assign(req, {
|
||||
userId,
|
||||
...oAuthParams,
|
||||
platformBookingLocation,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
});
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ class Response {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingInput {
|
||||
export class CreateBookingInput_2024_04_15 {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
end?: string;
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noEmail?: boolean;
|
||||
+1
-1
@@ -9,7 +9,7 @@ class Attendee {
|
||||
noShow!: boolean;
|
||||
}
|
||||
|
||||
export class MarkNoShowInput {
|
||||
export class MarkNoShowInput_2024_04_15 {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noShowHost?: boolean;
|
||||
+5
-5
@@ -88,7 +88,7 @@ class EventType {
|
||||
timeZone!: string | null;
|
||||
}
|
||||
|
||||
class GetBookingData {
|
||||
class GetBookingData_2024_04_15 {
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@@ -159,15 +159,15 @@ class GetBookingData {
|
||||
eventType!: EventType | null;
|
||||
}
|
||||
|
||||
export class GetBookingOutput {
|
||||
export class GetBookingOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetBookingData,
|
||||
type: GetBookingData_2024_04_15,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingData)
|
||||
data!: GetBookingData;
|
||||
@Type(() => GetBookingData_2024_04_15)
|
||||
data!: GetBookingData_2024_04_15;
|
||||
}
|
||||
+5
-5
@@ -202,7 +202,7 @@ class GetBookingsDataEntry {
|
||||
rescheduled?: any;
|
||||
}
|
||||
|
||||
class GetBookingsData {
|
||||
class GetBookingsData_2024_04_15 {
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingsDataEntry)
|
||||
@IsArray()
|
||||
@@ -215,15 +215,15 @@ class GetBookingsData {
|
||||
nextCursor!: number | null;
|
||||
}
|
||||
|
||||
export class GetBookingsOutput {
|
||||
export class GetBookingsOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetBookingsData,
|
||||
type: GetBookingsData_2024_04_15,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingsData)
|
||||
data!: GetBookingsData;
|
||||
@Type(() => GetBookingsData_2024_04_15)
|
||||
data!: GetBookingsData_2024_04_15;
|
||||
}
|
||||
+5
-5
@@ -12,7 +12,7 @@ class Attendee {
|
||||
noShow!: boolean;
|
||||
}
|
||||
|
||||
class HandleMarkNoShowData {
|
||||
class HandleMarkNoShowData_2024_04_15 {
|
||||
@IsString()
|
||||
message!: string;
|
||||
|
||||
@@ -27,15 +27,15 @@ class HandleMarkNoShowData {
|
||||
attendees?: Attendee[];
|
||||
}
|
||||
|
||||
export class MarkNoShowOutput {
|
||||
export class MarkNoShowOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: HandleMarkNoShowData,
|
||||
type: HandleMarkNoShowData_2024_04_15,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => HandleMarkNoShowData)
|
||||
data!: HandleMarkNoShowData;
|
||||
@Type(() => HandleMarkNoShowData_2024_04_15)
|
||||
data!: HandleMarkNoShowData_2024_04_15;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
OAuthFlowService,
|
||||
OAuthClientRepository,
|
||||
BookingsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
ApiKeyRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
})
|
||||
export class BookingsModule_2024_08_13 {}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getById(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdsWithAttendeesAndUserAndEvent(ids: number[]) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: ids,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUid(bookingUid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdWithAttendeesAndUserAndEvent(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: uid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByFromReschedule(fromReschedule: string) {
|
||||
return this.dbRead.prisma.booking.findFirst({
|
||||
where: {
|
||||
fromReschedule,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("With api key", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let apiKeyString: string;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = keyString;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{ title: "peer coding", slug: "peer-coding", length: 60 },
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create bookings", () => {
|
||||
it("should create a booking with api key", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Key",
|
||||
email: "mr_key@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Logger,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiTags as DocsTags,
|
||||
ApiHeader,
|
||||
getSchemaPath,
|
||||
ApiBody,
|
||||
ApiExtraModels,
|
||||
} from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
CreateBookingInput,
|
||||
GetBookingsInput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
CancelBookingInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings",
|
||||
version: VERSION_2024_08_13_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-08-13\``,
|
||||
required: true,
|
||||
})
|
||||
export class BookingsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({
|
||||
summary: "Create a booking",
|
||||
description: `
|
||||
POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:
|
||||
If eventTypeId in the request body is id of a regular event, then regular booking is created.
|
||||
|
||||
If it is an id of a recurring event type, then recurring booking is created.
|
||||
|
||||
Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.
|
||||
|
||||
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
|
||||
|
||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(CreateBookingInput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(CreateInstantBookingInput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(CreateRecurringBookingInput_2024_08_13) },
|
||||
],
|
||||
},
|
||||
description:
|
||||
"Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13",
|
||||
})
|
||||
@ApiExtraModels(
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13
|
||||
)
|
||||
async createBooking(
|
||||
@Body(new CreateBookingInputPipe())
|
||||
body: CreateBookingInput,
|
||||
@Req() request: Request
|
||||
): Promise<CreateBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.createBooking(request, body);
|
||||
|
||||
if (Array.isArray(booking)) {
|
||||
await this.bookingsService.billBookings(booking);
|
||||
} else {
|
||||
await this.bookingsService.billBooking(booking);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get a booking",
|
||||
description: `\`:bookingUid\` can be
|
||||
|
||||
1. uid of a normal booking
|
||||
|
||||
2. uid of one of the recurring booking recurrences
|
||||
|
||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`,
|
||||
})
|
||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.getBooking(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiOperation({ summary: "Get all bookings" })
|
||||
async getBookings(
|
||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||
@GetUser() user: User
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reschedule")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Reschedule a booking",
|
||||
description:
|
||||
"Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.",
|
||||
})
|
||||
async rescheduleBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: RescheduleBookingInput_2024_08_13,
|
||||
@Req() request: Request
|
||||
): Promise<RescheduleBookingOutput_2024_08_13> {
|
||||
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
|
||||
await this.bookingsService.billRescheduledBooking(newBooking, bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: newBooking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/cancel")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Cancel a booking" })
|
||||
async cancelBooking(
|
||||
@Req() request: Request,
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: CancelBookingInput_2024_08_13
|
||||
): Promise<CancelBookingOutput_2024_08_13> {
|
||||
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: cancelledBooking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/mark-absent")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Mark a booking absence" })
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser("id") ownerId: number
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Team bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const teamUserEmail = "orgUser1team1@api.com";
|
||||
const teamUserEmail2 = "orgUser2team1@api.com";
|
||||
let teamUser: User;
|
||||
let teamUser2: User;
|
||||
|
||||
let team1EventTypeId: number;
|
||||
let team2EventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: "team 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
team2 = await teamRepositoryFixture.create({
|
||||
name: "team 2",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
teamUser = await userRepositoryFixture.create({
|
||||
email: teamUserEmail,
|
||||
locale: "it",
|
||||
name: "orgUser1team1",
|
||||
});
|
||||
|
||||
teamUser2 = await userRepositoryFixture.create({
|
||||
email: teamUserEmail2,
|
||||
locale: "es",
|
||||
name: "orgUser2team1",
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser.id}`,
|
||||
username: teamUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser2.id}`,
|
||||
username: teamUserEmail2,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teamUser.id } },
|
||||
team: { connect: { id: team1.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teamUser.id } },
|
||||
team: { connect: { id: team2.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teamUser2.id } },
|
||||
team: { connect: { id: team2.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
team1EventTypeId = team1EventType.id;
|
||||
|
||||
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team2.id },
|
||||
},
|
||||
title: "Collective Event Type 2",
|
||||
slug: "collective-event-type-2",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
team2EventTypeId = team2EventType.id;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team1EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team2EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("create team bookings", () => {
|
||||
it("should create a team 1 booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: team1EventTypeId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team 2 booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: team2EventTypeId,
|
||||
attendee: {
|
||||
name: "bob",
|
||||
email: "bob@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team2EventTypeId);
|
||||
expect(data.attendees.length).toEqual(2);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.attendees[1]).toEqual({
|
||||
name: teamUser2.name,
|
||||
timeZone: teamUser2.timeZone,
|
||||
language: teamUser2.locale,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get team bookings", () => {
|
||||
it("should should get bookings by teamId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?teamId=${team1.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get bookings by teamId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?teamId=${team2.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get bookings by teamIds", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?teamIds=${team1.id},${team2.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
|
||||
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+1336
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BookingUidGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const bookingUid = request.params.bookingUid;
|
||||
|
||||
if (!bookingUid) {
|
||||
throw new BadRequestException("Booking UID missing in the request path");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class CancelBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects",
|
||||
})
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class CreateBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
|
||||
})
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class MarkAbsentBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
export class RescheduleBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Object)
|
||||
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import {
|
||||
handleNewBooking,
|
||||
handleNewRecurringBooking,
|
||||
getAllUserBookings,
|
||||
handleInstantMeeting,
|
||||
handleCancelBooking,
|
||||
handleMarkNoShow,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
GetBookingsInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CancelBookingInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type CreatedBooking = {
|
||||
hosts: { id: number }[];
|
||||
uid: string;
|
||||
start: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsService");
|
||||
constructor(
|
||||
private readonly inputService: InputBookingsService_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly billingService: BillingService
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
try {
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body);
|
||||
}
|
||||
|
||||
if (await this.isRecurring(body)) {
|
||||
return await this.createRecurringBooking(request, body);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
booking.bookingId
|
||||
);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.bookingId} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async isRecurring(body: CreateBookingInput) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
return !!eventType?.recurringEvent;
|
||||
}
|
||||
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
const uid = bookings[0].recurringEventId;
|
||||
if (!uid) {
|
||||
throw new Error("Recurring booking was not created");
|
||||
}
|
||||
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||
return this.outputService.getOutputRecurringBookings(recurringBooking);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
|
||||
if (!booking.id) {
|
||||
throw new Error("Booking was not created");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async getBooking(uid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid);
|
||||
|
||||
if (booking) {
|
||||
const isRecurring = !!booking.recurringEventId;
|
||||
if (isRecurring) {
|
||||
return this.outputService.getOutputRecurringBooking(booking);
|
||||
}
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||
if (!recurringBooking.length) {
|
||||
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputRecurringBookings(recurringBooking);
|
||||
}
|
||||
|
||||
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
// note(Lauris): we substract -1 because getAllUSerBookings child function adds +1 for some reason
|
||||
take: queryParams.take ? queryParams.take - 1 : 100,
|
||||
filters: this.inputService.transformGetBookingsFilters(queryParams),
|
||||
ctx: {
|
||||
user,
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
},
|
||||
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||
});
|
||||
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
|
||||
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
|
||||
const ids = fetchedBookings.bookings.map((booking) => booking.id);
|
||||
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids);
|
||||
|
||||
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
|
||||
const orderedBookings = ids.map((id) => bookingMap.get(id));
|
||||
|
||||
const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = [];
|
||||
for (const booking of orderedBookings) {
|
||||
if (!booking) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formatted = {
|
||||
...booking,
|
||||
eventType: booking.eventType,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
startTime: new Date(booking.startTime),
|
||||
endTime: new Date(booking.endTime),
|
||||
absentHost: !!booking.noShowHost,
|
||||
};
|
||||
|
||||
const isRecurring = !!formatted.recurringEventId;
|
||||
if (isRecurring) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
|
||||
} else {
|
||||
formattedBookings.push(this.outputService.getOutputBooking(formatted));
|
||||
}
|
||||
}
|
||||
|
||||
return formattedBookings;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) {
|
||||
try {
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
request,
|
||||
bookingUid,
|
||||
body
|
||||
);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
if (!booking.id) {
|
||||
throw new Error("Booking was not created");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
|
||||
if (databaseBooking.recurringEventId) {
|
||||
return this.outputService.getOutputRecurringBooking(databaseBooking);
|
||||
}
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
await handleCancelBooking(bookingRequest);
|
||||
return this.getBooking(bookingUid);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const isRecurring = !!booking.recurringEventId;
|
||||
if (isRecurring) {
|
||||
return this.outputService.getOutputRecurringBooking(booking);
|
||||
}
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
|
||||
async billBookings(bookings: CreatedBooking[]) {
|
||||
for (const booking of bookings) {
|
||||
await this.billBooking(booking);
|
||||
}
|
||||
}
|
||||
|
||||
async billBooking(booking: CreatedBooking) {
|
||||
const hostId = booking.hosts[0].id;
|
||||
if (!hostId) {
|
||||
this.logger.error(`Booking with uid=${booking.uid} has no host`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingService.increaseUsageByUserId(hostId, {
|
||||
uid: booking.uid,
|
||||
startTime: new Date(booking.start),
|
||||
});
|
||||
}
|
||||
|
||||
async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) {
|
||||
const hostId = newBooking.hosts[0].id;
|
||||
if (!hostId) {
|
||||
this.logger.error(`Booking with uid=${newBooking.uid} has no host`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.billingService.increaseUsageByUserId(hostId, {
|
||||
uid: newBooking.uid,
|
||||
startTime: new Date(newBooking.start),
|
||||
fromReschedule: oldBookingUid,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { NextApiRequest } from "next/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateInstantBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
GetBookingsInput_2024_08_13,
|
||||
MarkAbsentBookingInput_2024_08_13,
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||
|
||||
const DEFAULT_PLATFORM_PARAMS = {
|
||||
platformClientId: "",
|
||||
platformCancelUrl: "",
|
||||
platformRescheduleUrl: "",
|
||||
platformBookingUrl: "",
|
||||
arePlatformEmailsEnabled: false,
|
||||
platformBookingLocation: undefined,
|
||||
};
|
||||
|
||||
type OAuthRequestParams = {
|
||||
platformClientId: string;
|
||||
platformRescheduleUrl: string;
|
||||
platformCancelUrl: string;
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
};
|
||||
|
||||
export enum Frequency {
|
||||
"YEARLY",
|
||||
"MONTHLY",
|
||||
"WEEKLY",
|
||||
"DAILY",
|
||||
"HOURLY",
|
||||
"MINUTELY",
|
||||
"SECONDLY",
|
||||
}
|
||||
|
||||
const recurringEventSchema = z.object({
|
||||
dtstart: z.string().optional(),
|
||||
interval: z.number().int(),
|
||||
count: z.number().int(),
|
||||
freq: z.nativeEnum(Frequency),
|
||||
until: z.string().optional(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class InputBookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("InputBookingsService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeyRepository
|
||||
) {}
|
||||
|
||||
async createBookingRequest(
|
||||
request: Request,
|
||||
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputCreateBooking(body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = request.body.location || request.body.meetingUrl;
|
||||
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||
|
||||
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
|
||||
inputBooking.attendee.timeZone
|
||||
);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
eventTypeId: inputBooking.eventTypeId,
|
||||
timeZone: inputBooking.attendee.timeZone,
|
||||
language: inputBooking.attendee.language || "en",
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: inputBooking.metadata || {},
|
||||
metadata: {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: inputBooking.guests,
|
||||
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||
responses: inputBooking.bookingFieldsResponses
|
||||
? {
|
||||
...inputBooking.bookingFieldsResponses,
|
||||
name: inputBooking.attendee.name,
|
||||
email: inputBooking.attendee.email,
|
||||
}
|
||||
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
|
||||
};
|
||||
}
|
||||
|
||||
async createRecurringBookingRequest(
|
||||
request: Request,
|
||||
body: CreateRecurringBookingInput_2024_08_13
|
||||
): Promise<BookingRequest> {
|
||||
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
|
||||
const bodyTransformed = await this.transformInputCreateRecurringBooking(body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = request.body.location || request.body.meetingUrl;
|
||||
Object.assign(newRequest, {
|
||||
userId,
|
||||
...oAuthParams,
|
||||
platformBookingLocation: location,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
});
|
||||
|
||||
newRequest.body = bodyTransformed.map((event) => ({
|
||||
...event,
|
||||
}));
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputCreateRecurringBooking(inputBooking: CreateRecurringBookingInput_2024_08_13) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
if (!eventType.recurringEvent) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||
}
|
||||
|
||||
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
||||
const repeatsEvery = occurrence.interval;
|
||||
|
||||
if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) {
|
||||
throw new BadRequestException(
|
||||
"Provided recurrence count is higher than the event type's recurring event count."
|
||||
);
|
||||
}
|
||||
const repeatsTimes = inputBooking.recurrenceCount || occurrence.count;
|
||||
// note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly
|
||||
const timeBetween = occurrence.freq;
|
||||
|
||||
const events = [];
|
||||
const recurringEventId = uuidv4();
|
||||
|
||||
let startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
|
||||
inputBooking.attendee.timeZone
|
||||
);
|
||||
|
||||
for (let i = 0; i < repeatsTimes; i++) {
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
events.push({
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
eventTypeId: inputBooking.eventTypeId,
|
||||
recurringEventId,
|
||||
timeZone: inputBooking.attendee.timeZone,
|
||||
language: inputBooking.attendee.language || "en",
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: inputBooking.metadata || {},
|
||||
metadata: {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: inputBooking.guests,
|
||||
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||
responses: inputBooking.bookingFieldsResponses
|
||||
? {
|
||||
...inputBooking.bookingFieldsResponses,
|
||||
name: inputBooking.attendee.name,
|
||||
email: inputBooking.attendee.email,
|
||||
}
|
||||
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
|
||||
schedulingType: eventType.schedulingType,
|
||||
});
|
||||
|
||||
switch (timeBetween) {
|
||||
case 0: // Yearly
|
||||
startTime = startTime.plus({ years: repeatsEvery });
|
||||
break;
|
||||
case 1: // Monthly
|
||||
startTime = startTime.plus({ months: repeatsEvery });
|
||||
break;
|
||||
case 2: // Weekly
|
||||
startTime = startTime.plus({ weeks: repeatsEvery });
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported timeBetween value");
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
async createRescheduleBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: RescheduleBookingInput_2024_08_13
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = await this.getRescheduleBookingLocation(bookingUid);
|
||||
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||
|
||||
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
if (!booking.eventTypeId) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
|
||||
}
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const bookingResponses = bookingResponsesSchema.parse(booking.responses);
|
||||
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
|
||||
|
||||
if (!attendee) {
|
||||
throw new NotFoundException(
|
||||
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
eventTypeId: eventType.id,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: booking.metadata || {},
|
||||
metadata: {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: bookingResponses.guests,
|
||||
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
|
||||
rescheduleUid: bookingUid,
|
||||
};
|
||||
}
|
||||
|
||||
async getRescheduleBookingLocation(rescheduleBookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUid(rescheduleBookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${rescheduleBookingUid} not found`);
|
||||
}
|
||||
return booking.location;
|
||||
}
|
||||
|
||||
private async createBookingRequestOwnerId(req: Request): Promise<number | undefined> {
|
||||
try {
|
||||
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
// Access Token
|
||||
return this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async createBookingRequestOAuthClientParams(clientId: string) {
|
||||
const params = DEFAULT_PLATFORM_PARAMS;
|
||||
try {
|
||||
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
|
||||
if (client) {
|
||||
params.platformClientId = clientId;
|
||||
params.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
|
||||
params.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
|
||||
params.platformBookingUrl = client.bookingRedirectUri ?? "";
|
||||
params.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
|
||||
}
|
||||
return params;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) {
|
||||
return {
|
||||
attendeeEmail: queryParams.attendeeEmail,
|
||||
attendeeName: queryParams.attendeeName,
|
||||
afterStartDate: queryParams.afterStart,
|
||||
beforeEndDate: queryParams.beforeEnd,
|
||||
teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined),
|
||||
eventTypeIds:
|
||||
queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) {
|
||||
if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sortStart: queryParams.sortStart,
|
||||
sortEnd: queryParams.sortEnd,
|
||||
sortCreated: queryParams.sortCreated,
|
||||
};
|
||||
}
|
||||
|
||||
async createCancelBookingRequest(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: CancelBookingInput_2024_08_13
|
||||
): Promise<BookingRequest> {
|
||||
const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body);
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
Object.assign(newRequest, { userId, ...oAuthParams });
|
||||
|
||||
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
|
||||
bookingUid
|
||||
);
|
||||
|
||||
if (recurringBooking.length) {
|
||||
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
|
||||
allRemainingBookings = true;
|
||||
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
|
||||
uid = recurringBooking[0].uid;
|
||||
}
|
||||
|
||||
return {
|
||||
uid,
|
||||
cancellationReason: inputBooking.cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
}
|
||||
|
||||
transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) {
|
||||
return {
|
||||
noShowHost: inputBooking.host,
|
||||
attendees: inputBooking.attendees?.map((attendee) => ({
|
||||
email: attendee.email,
|
||||
noShow: attendee.absent,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
rescheduledReason: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type DatabaseBooking = Booking & {
|
||||
eventType: {
|
||||
id: number;
|
||||
slug: string;
|
||||
} | null;
|
||||
attendees: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
locale: string | null;
|
||||
noShow: boolean | null;
|
||||
}[];
|
||||
} & { user: { id: number; name: string | null; email: string } | null };
|
||||
|
||||
@Injectable()
|
||||
export class OutputBookingsService_2024_08_13 {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
|
||||
getOutputBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
})),
|
||||
guests: bookingResponses.guests,
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of databaseBookings) {
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
|
||||
transformed.push(this.getOutputRecurringBooking(databaseBooking));
|
||||
}
|
||||
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
getOutputRecurringBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
})),
|
||||
guests: bookingResponses.guests,
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
recurringBookingUid: databaseBooking.recurringEventId,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
bookingFieldsResponses: databaseBooking.responses,
|
||||
};
|
||||
|
||||
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
@@ -12,6 +13,16 @@ export interface CredentialSyncCalendarApp {
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface ICSFeedCalendarApp {
|
||||
save(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
urls: string[],
|
||||
readonly?: boolean
|
||||
): Promise<CreateIcsFeedOutputResponseDto>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface OAuthCalendarApp extends CalendarApp {
|
||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.contro
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -20,6 +21,7 @@ import { Module } from "@nestjs/common";
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
AppleCalendarService,
|
||||
IcsFeedService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
@@ -18,6 +19,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||
import { IcsCalendarServiceMock } from "test/mocks/ics-calendar-service-mock";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
GOOGLE_CALENDAR_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
|
||||
import { IcsFeedCalendarService } from "@calcom/platform-libraries";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
@@ -45,6 +49,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
let googleCalendarCredentials: Credential;
|
||||
let accessTokenSecret: string;
|
||||
let refreshTokenSecret: string;
|
||||
let icsCalendarCredentials: CreateIcsFeedOutput;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@@ -192,6 +197,52 @@ describe("Platform Calendars Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => {
|
||||
const body = {
|
||||
urls: ["https://cal.com/ics/feed.ics", "https://not-an-ics-feed.com"],
|
||||
readOnly: false,
|
||||
};
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => {
|
||||
const body = {
|
||||
urls: ["https://cal.com/ics/feed.ics"],
|
||||
readOnly: false,
|
||||
};
|
||||
jest
|
||||
.spyOn(IcsFeedCalendarService.prototype, "listCalendars")
|
||||
.mockImplementation(IcsCalendarServiceMock.prototype.listCalendars);
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateIcsFeedOutputResponseDto = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userId).toBeDefined();
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
icsCalendarCredentials = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${ICS_CALENDAR}/check with access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${ICS_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.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,
|
||||
@@ -217,6 +268,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||
await credentialsRepositoryFixture.delete(icsCalendarCredentials.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CreateIcsFeedInputDto } from "@/ee/calendars/input/create-ics.input";
|
||||
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -31,7 +34,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
APPLE_CALENDAR,
|
||||
CREDENTIAL_CALENDARS,
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@@ -58,11 +62,31 @@ export class CalendarsController {
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
private readonly appleCalendarService: AppleCalendarService,
|
||||
private readonly icsFeedService: IcsFeedService,
|
||||
private readonly calendarsRepository: CalendarsRepository
|
||||
) {}
|
||||
|
||||
@Post("/ics-feed/save")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Save an ICS feed" })
|
||||
async createIcsFeed(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser("email") userEmail: string,
|
||||
@Body() body: CreateIcsFeedInputDto
|
||||
): Promise<CreateIcsFeedOutputResponseDto> {
|
||||
return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly);
|
||||
}
|
||||
|
||||
@Get("/ics-feed/check")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Check an ICS feed" })
|
||||
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
|
||||
return await this.icsFeedService.check(userId);
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Get("/busy-times")
|
||||
@ApiOperation({ summary: "Get busy times" })
|
||||
async getBusyTimes(
|
||||
@Query() queryParams: CalendarBusyTimesInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -91,6 +115,7 @@ export class CalendarsController {
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Get all calendars" })
|
||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||
const calendars = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
@@ -103,6 +128,7 @@ export class CalendarsController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get connect URL" })
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@@ -125,6 +151,7 @@ export class CalendarsController {
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save a calendar" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@@ -154,6 +181,7 @@ export class CalendarsController {
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/credentials")
|
||||
@ApiOperation({ summary: "Sync credentials" })
|
||||
async syncCredentials(
|
||||
@GetUser() user: User,
|
||||
@Param("calendar") calendar: string,
|
||||
@@ -167,7 +195,7 @@ export class CalendarsController {
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
CREDENTIAL_CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +204,7 @@ export class CalendarsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection" })
|
||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
@@ -195,6 +224,7 @@ export class CalendarsController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Disconnect a calendar" })
|
||||
async deleteCalendarCredentials(
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import { IsNotEmpty, IsArray } from "class-validator";
|
||||
|
||||
// Custom constraint to validate ICS URLs
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
|
||||
validate(url: unknown) {
|
||||
if (typeof url !== "string") return false;
|
||||
|
||||
// Check if it's a valid URL and ends with .ics
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return (
|
||||
(urlObject.protocol === "http:" || urlObject.protocol === "https:") &&
|
||||
urlObject.pathname.endsWith(".ics")
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "The URL must be a valid ICS URL (ending with .ics)";
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateIcsFeedInputDto {
|
||||
@ApiProperty({
|
||||
example: ["https://cal.com/ics/feed.ics", "http://cal.com/ics/feed.ics"],
|
||||
description: "An array of ICS URLs",
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
example: "https://cal.com/ics/feed.ics",
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsNotEmpty({ each: true })
|
||||
@Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array
|
||||
urls!: string[];
|
||||
|
||||
@IsBoolean()
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: "Whether to allowing writing to the calendar or not",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
readOnly?: boolean = true;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 CreateIcsFeedOutput {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({ example: 1234567890, description: "The id of the calendar credential" })
|
||||
readonly id!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({ example: "ics-feed_calendar", description: "The type of the calendar" })
|
||||
readonly type!: string;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: 1234567890,
|
||||
description: "The user id of the user that created the calendar",
|
||||
type: "integer",
|
||||
})
|
||||
readonly userId!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: 1234567890,
|
||||
nullable: true,
|
||||
description: "The team id of the user that created the calendar",
|
||||
type: "integer",
|
||||
})
|
||||
readonly teamId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({ example: "ics-feed", description: "The slug of the calendar" })
|
||||
readonly appId!: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty({ example: false, description: "Whether the calendar credentials are valid or not" })
|
||||
readonly invalid!: boolean | null;
|
||||
}
|
||||
|
||||
export class CreateIcsFeedOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateIcsFeedOutput)
|
||||
data!: CreateIcsFeedOutput;
|
||||
}
|
||||
@@ -198,7 +198,7 @@ class DestinationCalendar {
|
||||
integrationTitle?: string;
|
||||
}
|
||||
|
||||
class ConnectedCalendarsData {
|
||||
export class ConnectedCalendarsData {
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
connectedCalendars!: ConnectedCalendar[];
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CredentialsRepository,
|
||||
CredentialsWithUserEmail,
|
||||
} from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
@@ -34,9 +32,7 @@ export class CalendarsService {
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, ICS_CALENDAR_TYPE, ICS_CALENDAR } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, IcsFeedCalendarService } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
private logger = new Logger("IcsFeedService");
|
||||
|
||||
async save(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
urls: string[],
|
||||
readonly = true
|
||||
): Promise<CreateIcsFeedOutputResponseDto> {
|
||||
const data = {
|
||||
type: ICS_CALENDAR_TYPE,
|
||||
ICS_CALENDAR,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ urls, skipWriting: readonly }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const dav = new IcsFeedCalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: userEmail },
|
||||
});
|
||||
|
||||
const listedCals = await dav.listCalendars();
|
||||
|
||||
if (listedCals.length !== urls.length) {
|
||||
throw new BadRequestException(
|
||||
`Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialRepository.upsertAppCredential(
|
||||
ICS_CALENDAR_TYPE,
|
||||
data.key,
|
||||
userId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
userId: credential.userId,
|
||||
teamId: credential.teamId,
|
||||
appId: credential.appId,
|
||||
invalid: credential.invalid,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.error("Could not add ICS feeds", e);
|
||||
throw new BadRequestException("Could not add ICS feeds, try using private ics feed.");
|
||||
}
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
|
||||
|
||||
if (!icsFeedCredentials) {
|
||||
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
|
||||
}
|
||||
|
||||
if (icsFeedCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid Ics Feed credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const icsCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
if (!icsCalendar) {
|
||||
throw new UnauthorizedException("Ics Feed not connected.");
|
||||
}
|
||||
if (icsCalendar.error?.message) {
|
||||
throw new UnauthorizedException(icsCalendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -36,7 +36,7 @@ import {
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
@@ -47,7 +47,7 @@ import { PrismaClient } from "@calcom/prisma";
|
||||
version: [VERSION_2024_04_15, VERSION_2024_06_11],
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
@DocsExcludeController(true)
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
|
||||
+11
-1
@@ -15,6 +15,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
systemBeforeFieldEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@@ -127,8 +128,17 @@ export class EventTypesService_2024_04_15 {
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
await updateEventType({
|
||||
input: { id: eventTypeId, ...body },
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
export const DEFAULT_EVENT_TYPES = {
|
||||
thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" },
|
||||
thirtyMinutesVideo: {
|
||||
length: 30,
|
||||
slug: "thirty-minutes-video",
|
||||
title: "30 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" },
|
||||
sixtyMinutesVideo: {
|
||||
length: 60,
|
||||
slug: "sixty-minutes-video",
|
||||
title: "60 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
import { IntegrationLocation } from "@calcom/lib";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type EventTypeWithLocation = BaseEventType & {
|
||||
locations: IntegrationLocation[];
|
||||
};
|
||||
|
||||
const thirtyMinutes: BaseEventType = {
|
||||
length: 30,
|
||||
slug: "thirty-minutes",
|
||||
title: "30 Minutes",
|
||||
};
|
||||
|
||||
const thirtyMinutesVideo: EventTypeWithLocation = {
|
||||
length: 30,
|
||||
slug: "thirty-minutes-video",
|
||||
title: "30 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
};
|
||||
|
||||
const sixtyMinutes: BaseEventType = {
|
||||
length: 60,
|
||||
slug: "sixty-minutes",
|
||||
title: "60 Minutes",
|
||||
};
|
||||
|
||||
const sixtyMinutesVideo: EventTypeWithLocation = {
|
||||
length: 60,
|
||||
slug: "sixty-minutes-video",
|
||||
title: "60 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
};
|
||||
|
||||
export const DEFAULT_EVENT_TYPES = {
|
||||
thirtyMinutes,
|
||||
thirtyMinutesVideo,
|
||||
sixtyMinutes,
|
||||
sixtyMinutesVideo,
|
||||
};
|
||||
|
||||
+449
-36
@@ -20,7 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import { BookingWindowPeriodInputTypeEnum_2024_06_14, FrequencyInput } from "@calcom/platform-enums";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
@@ -230,11 +236,37 @@ describe("Event types Endpoints", () => {
|
||||
value: 30,
|
||||
rolling: true,
|
||||
},
|
||||
bookerLayouts: {
|
||||
enabledLayouts: [
|
||||
BookerLayoutsInputEnum_2024_06_14.column,
|
||||
BookerLayoutsInputEnum_2024_06_14.month,
|
||||
BookerLayoutsInputEnum_2024_06_14.week,
|
||||
],
|
||||
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
|
||||
},
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.TIME,
|
||||
noticeThreshold: {
|
||||
count: 60,
|
||||
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||
},
|
||||
blockUnconfirmedBookingsInBooker: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
disabled: false,
|
||||
},
|
||||
requiresBookerEmailVerification: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
lightThemeHex: "#fafafa",
|
||||
},
|
||||
customName: `{Event type title} between {Organiser} and {Scheduler}`,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -250,7 +282,6 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||
expect(createdEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
|
||||
@@ -258,11 +289,346 @@ describe("Event types Endpoints", () => {
|
||||
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
|
||||
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
|
||||
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
|
||||
expect(createdEventType.bookerLayouts).toEqual(body.bookerLayouts);
|
||||
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
expect(createdEventType.recurrence).toEqual(body.recurrence);
|
||||
expect(createdEventType.customName).toEqual(body.customName);
|
||||
expect(createdEventType.requiresBookerEmailVerification).toEqual(
|
||||
body.requiresBookerEmailVerification
|
||||
);
|
||||
|
||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(createdEventType.color).toEqual(body.color);
|
||||
|
||||
const responseBookingFields = body.bookingFields || [];
|
||||
const expectedBookingFields = [
|
||||
{ isDefault: true, required: true, slug: "name", type: "name" },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
...responseBookingFields.map((field) => ({ isDefault: false, ...field })),
|
||||
];
|
||||
|
||||
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/even-types by username`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.[0];
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
});
|
||||
|
||||
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 2",
|
||||
slug: "coding-class-2",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
{
|
||||
type: "phone",
|
||||
phone: "+37120993151",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
scheduleId: firstSchedule.id,
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when trying to enable seats for an event type with multiple locations", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 3",
|
||||
slug: "coding-class-3",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
{
|
||||
type: "phone",
|
||||
phone: "+37120993151",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
scheduleId: firstSchedule.id,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
expect(createdEventType).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
lengthInMinutes: body.lengthInMinutes,
|
||||
locations: body.locations,
|
||||
scheduleId: firstSchedule.id,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when creating an event type with seats enabled and confirmationPolicy enabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 4",
|
||||
slug: "coding-class-4",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
scheduleId: firstSchedule.id,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when trying to enable seats for an event type with confirmationPolicy enabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 5",
|
||||
slug: "coding-class-5",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
scheduleId: firstSchedule.id,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
console.log("createdEventType: ", createdEventType);
|
||||
|
||||
expect(createdEventType).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
lengthInMinutes: body.lengthInMinutes,
|
||||
confirmationPolicy: body.confirmationPolicy,
|
||||
scheduleId: firstSchedule.id,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when trying to set multiple locations for an event type with seats enabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 6",
|
||||
slug: "coding-class-6",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
scheduleId: firstSchedule.id,
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
expect(createdEventType).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
lengthInMinutes: body.lengthInMinutes,
|
||||
seats: body.seats,
|
||||
scheduleId: firstSchedule.id,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
{
|
||||
type: "phone",
|
||||
phone: "+37120993151",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when creating an event type with confirmationPolicy enabled and seats enabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 7",
|
||||
slug: "coding-class-7",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
scheduleId: firstSchedule.id,
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return an error when trying to enable confirmationPolicy for an event type with seats enabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 8",
|
||||
slug: "coding-class-8",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
showAttendeeInfo: true,
|
||||
showAvailabilityCount: true,
|
||||
},
|
||||
scheduleId: firstSchedule.id,
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const createdEventType = createResponse.body.data;
|
||||
|
||||
expect(createdEventType).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
lengthInMinutes: body.lengthInMinutes,
|
||||
seats: body.seats,
|
||||
scheduleId: firstSchedule.id,
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should update event type", async () => {
|
||||
const newTitle = "Coding class in Italian!";
|
||||
|
||||
@@ -284,11 +650,33 @@ describe("Event types Endpoints", () => {
|
||||
value: 40,
|
||||
rolling: false,
|
||||
},
|
||||
bookerLayouts: {
|
||||
enabledLayouts: [
|
||||
BookerLayoutsInputEnum_2024_06_14.column,
|
||||
BookerLayoutsInputEnum_2024_06_14.month,
|
||||
BookerLayoutsInputEnum_2024_06_14.week,
|
||||
],
|
||||
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
|
||||
},
|
||||
confirmationPolicy: {
|
||||
type: ConfirmationPolicyEnum.ALWAYS,
|
||||
blockUnconfirmedBookingsInBooker: false,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.monthly,
|
||||
interval: 4,
|
||||
occurrences: 10,
|
||||
disabled: false,
|
||||
},
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
lightThemeHex: "#fafafa",
|
||||
},
|
||||
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -314,7 +702,19 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
|
||||
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
|
||||
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
|
||||
expect(updatedEventType.bookerLayouts).toEqual(body.bookerLayouts);
|
||||
expect(updatedEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||
expect(updatedEventType.recurrence).toEqual(body.recurrence);
|
||||
expect(updatedEventType.customName).toEqual(body.customName);
|
||||
expect(updatedEventType.requiresBookerEmailVerification).toEqual(
|
||||
body.requiresBookerEmailVerification
|
||||
);
|
||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(updatedEventType.color).toEqual(body.color);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
@@ -323,7 +723,15 @@ describe("Event types Endpoints", () => {
|
||||
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
|
||||
eventType.offsetStart = updatedEventType.offsetStart;
|
||||
eventType.bookingWindow = updatedEventType.bookingWindow;
|
||||
eventType.bookerLayouts = updatedEventType.bookerLayouts;
|
||||
eventType.confirmationPolicy = updatedEventType.confirmationPolicy;
|
||||
eventType.recurrence = updatedEventType.recurrence;
|
||||
eventType.customName = updatedEventType.customName;
|
||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,38 +772,19 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
});
|
||||
|
||||
it(`/GET/even-types by username`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.[0];
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||
@@ -421,7 +810,19 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
});
|
||||
|
||||
it(`/GET/:id not existing`, async () => {
|
||||
@@ -476,6 +877,16 @@ describe("Event types Endpoints", () => {
|
||||
let legacyEventTypeId1: number;
|
||||
let legacyEventTypeId2: number;
|
||||
|
||||
const expectedReturnSystemFields = [
|
||||
{ isDefault: true, required: true, slug: "name", type: "name" },
|
||||
{ isDefault: true, required: true, slug: "email", type: "email" },
|
||||
{ isDefault: true, type: "radioInput", slug: "location", required: false },
|
||||
{ isDefault: true, required: true, slug: "title", type: "text" },
|
||||
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
|
||||
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
|
||||
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -545,7 +956,7 @@ describe("Event types Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return empty bookingFields if system fields are the only one in database", async () => {
|
||||
it("should return system bookingFields stored in database", async () => {
|
||||
const legacyEventTypeInput = {
|
||||
title: "legacy event type",
|
||||
description: "legacy event type description",
|
||||
@@ -638,11 +1049,11 @@ describe("Event types Endpoints", () => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
expect(fetchedEventType.bookingFields).toEqual([]);
|
||||
expect(fetchedEventType.bookingFields).toEqual(expectedReturnSystemFields);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return user created bookingFields among system fields in the database", async () => {
|
||||
it("should return user created bookingFields with system fields", async () => {
|
||||
const userDefinedBookingField = {
|
||||
name: "team",
|
||||
type: "textarea",
|
||||
@@ -755,7 +1166,9 @@ describe("Event types Endpoints", () => {
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(fetchedEventType.bookingFields).toEqual([
|
||||
...expectedReturnSystemFields,
|
||||
{
|
||||
isDefault: false,
|
||||
type: userDefinedBookingField.type,
|
||||
slug: userDefinedBookingField.name,
|
||||
label: userDefinedBookingField.label,
|
||||
|
||||
+63
-11
@@ -3,7 +3,9 @@ import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
|
||||
import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output";
|
||||
import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output";
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
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 { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -23,14 +25,15 @@ import {
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -38,28 +41,56 @@ import {
|
||||
version: VERSION_2024_06_14_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
@DocsTags("Event Types")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-06-14\``,
|
||||
required: true,
|
||||
})
|
||||
export class EventTypesController_2024_06_14 {
|
||||
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Create an event type" })
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.createUserEventType(user, body);
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
|
||||
user.id,
|
||||
body
|
||||
);
|
||||
|
||||
const eventType = await this.eventTypesService.createUserEventType(user, transformedBody);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Get an event type" })
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -72,11 +103,12 @@ export class EventTypesController_2024_06_14 {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all event types" })
|
||||
async getEventTypes(
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||
): Promise<GetEventTypesOutput_2024_06_14> {
|
||||
@@ -84,30 +116,50 @@ export class EventTypesController_2024_06_14 {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventTypes),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an event type" })
|
||||
async updateEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
|
||||
body,
|
||||
user.id,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, transformedBody, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: this.eventTypeResponseTransformPipe.transform(eventType),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ summary: "Delete an event type" })
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
@@ -22,6 +27,11 @@ import { Module } from "@nestjs/common";
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
EventTypeResponseTransformPipe,
|
||||
CalendarsService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [EventTypesController_2024_06_14],
|
||||
exports: [
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type InputEventTransformed = Omit<
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
| "lengthInMinutes"
|
||||
| "locations"
|
||||
| "bookingFields"
|
||||
| "bookingLimitsCount"
|
||||
| "onlyShowFirstAvailableSlot"
|
||||
| "bookingLimitsDuration"
|
||||
| "offsetStart"
|
||||
| "periodType"
|
||||
| "periodDays"
|
||||
| "periodCountCalendarDays"
|
||||
| "periodStartDate"
|
||||
| "periodEndDate"
|
||||
| "recurrence"
|
||||
> & {
|
||||
length: number;
|
||||
slug: string;
|
||||
locations?: ReturnType<typeof transformApiEventTypeLocations>;
|
||||
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
|
||||
};
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
@@ -42,7 +13,10 @@ export class EventTypesRepository_2024_06_14 {
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async createUserEventType(userId: number, body: InputEventTransformed) {
|
||||
async createUserEventType(
|
||||
userId: number,
|
||||
body: Omit<InputEventTransformed_2024_06_14, "destinationCalendar">
|
||||
) {
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
data: {
|
||||
...body,
|
||||
@@ -57,7 +31,19 @@ export class EventTypesRepository_2024_06_14 {
|
||||
async getEventTypeWithSeats(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
select: { users: { select: { id: true } }, seatsPerTimeSlot: true },
|
||||
select: {
|
||||
users: { select: { id: true } },
|
||||
seatsPerTimeSlot: true,
|
||||
locations: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeWithMetaData(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
select: { metadata: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +53,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
id: eventTypeId,
|
||||
userId,
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,29 +62,21 @@ export class EventTypesRepository_2024_06_14 {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(
|
||||
user: UserWithProfile,
|
||||
isUserOrganizationAdmin: boolean,
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { users: true, schedule: true },
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeByIdWithOwnerAndTeam(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { owner: true, team: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +88,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
slug: slug,
|
||||
},
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Injectable, PipeTransform } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
import {
|
||||
DatabaseEventType,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
} from "../services/output-event-types.service";
|
||||
|
||||
type EventTypeResponse = DatabaseEventType & { ownerId: number };
|
||||
|
||||
@Injectable()
|
||||
export class EventTypeResponseTransformPipe implements PipeTransform {
|
||||
constructor(private readonly outputEventTypesService: OutputEventTypesService_2024_06_14) {}
|
||||
|
||||
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
|
||||
return plainToClass(
|
||||
EventTypeOutput_2024_06_14,
|
||||
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
|
||||
{ strategy: "exposeAll" }
|
||||
);
|
||||
}
|
||||
|
||||
// Implementing function overloading to ensure correct return types based on input type:
|
||||
transform(value: EventTypeResponse[]): EventTypeOutput_2024_06_14[];
|
||||
|
||||
transform(value: EventTypeResponse): EventTypeOutput_2024_06_14;
|
||||
|
||||
transform(
|
||||
value: EventTypeResponse | EventTypeResponse[]
|
||||
): EventTypeOutput_2024_06_14 | EventTypeOutput_2024_06_14[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.transformEventType(item));
|
||||
} else {
|
||||
return this.transformEventType(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-70
@@ -1,7 +1,5 @@
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
@@ -13,20 +11,13 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService,
|
||||
@@ -35,22 +26,13 @@ export class EventTypesService_2024_06_14 {
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11
|
||||
) {}
|
||||
|
||||
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
|
||||
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
const {
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
periodType = undefined,
|
||||
periodDays = undefined,
|
||||
periodCountCalendarDays = undefined,
|
||||
periodStartDate = undefined,
|
||||
periodEndDate = undefined,
|
||||
recurrence = undefined,
|
||||
...bodyTransformed
|
||||
} = this.inputEventTypesService.transformInputCreateEventType(body);
|
||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
input: bodyTransformed,
|
||||
input: rest,
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -62,15 +44,7 @@ export class EventTypesService_2024_06_14 {
|
||||
await updateEventType({
|
||||
input: {
|
||||
id: eventTypeCreated.id,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
periodType,
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
recurrence,
|
||||
...bodyTransformed,
|
||||
...body,
|
||||
},
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
@@ -86,10 +60,13 @@ export class EventTypesService_2024_06_14 {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`);
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
return {
|
||||
ownerId: user.id,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_06_14) {
|
||||
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
|
||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||
if (existsWithSlug) {
|
||||
throw new BadRequestException("User already has an event type with this slug.");
|
||||
@@ -109,7 +86,10 @@ export class EventTypesService_2024_06_14 {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
return {
|
||||
ownerId: user.id,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async getEventTypesByUsername(username: string) {
|
||||
@@ -117,7 +97,7 @@ export class EventTypesService_2024_06_14 {
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
return this.getUserEventTypes(user.id);
|
||||
return await this.getUserEventTypes(user.id);
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
@@ -147,38 +127,19 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(userId, eventType);
|
||||
return this.outputEventTypesService.getResponseEventType(userId, eventType);
|
||||
|
||||
return {
|
||||
ownerId: userId,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
|
||||
|
||||
const eventTypePromises = eventTypes.map(async (eventType) => {
|
||||
return await this.outputEventTypesService.getResponseEventType(userId, eventType);
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
});
|
||||
|
||||
return await Promise.all(eventTypePromises);
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
|
||||
user,
|
||||
isUserOrganizationAdmin,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
@@ -190,7 +151,7 @@ export class EventTypesService_2024_06_14 {
|
||||
return await getEventTypesPublic(user.id);
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14): Promise<EventTypeOutput_2024_06_14[]> {
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
|
||||
const { username, eventSlug, usernames } = queryParams;
|
||||
|
||||
if (username && eventSlug) {
|
||||
@@ -218,12 +179,12 @@ export class EventTypesService_2024_06_14 {
|
||||
usersFiltered.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(0, {
|
||||
return {
|
||||
ownerId: 0,
|
||||
...dynamicEvent,
|
||||
users: usersFiltered,
|
||||
isInstantEvent: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async createUserDefaultEventTypes(userId: number) {
|
||||
@@ -239,12 +200,12 @@ export class EventTypesService_2024_06_14 {
|
||||
return defaultEventTypes;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
|
||||
async updateEventType(eventTypeId: number, body: InputEventTransformed_2024_06_14, user: UserWithProfile) {
|
||||
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
|
||||
|
||||
await updateEventType({
|
||||
input: { id: eventTypeId, ...bodyTransformed },
|
||||
input: { id: eventTypeId, ...body },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -259,7 +220,10 @@ export class EventTypesService_2024_06_14 {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
return {
|
||||
ownerId: user.id,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
|
||||
|
||||
+296
-18
@@ -1,16 +1,97 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
transformApiEventTypeIntervalLimits,
|
||||
transformApiEventTypeFutureBookingLimits,
|
||||
transformApiEventTypeRecurrence,
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
transformIntervalLimitsApiToInternal,
|
||||
transformFutureBookingLimitsApiToInternal,
|
||||
transformRecurrenceApiToInternal,
|
||||
systemBeforeFieldName,
|
||||
systemBeforeFieldEmail,
|
||||
systemAfterFieldRescheduleReason,
|
||||
EventTypeMetaDataSchema,
|
||||
systemBeforeFieldLocation,
|
||||
transformBookerLayoutsApiToInternal,
|
||||
transformConfirmationPolicyApiToInternal,
|
||||
transformEventColorsApiToInternal,
|
||||
validateCustomEventName,
|
||||
transformSeatsApiToInternal,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
InputEventTransformed_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
import { OutputEventTypesService_2024_06_14 } from "./output-event-types.service";
|
||||
|
||||
interface ValidationContext {
|
||||
eventTypeId?: number;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
locations?: InputEventTransformed_2024_06_14["locations"];
|
||||
requiresConfirmation?: boolean;
|
||||
eventName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
userId: UserWithProfile["id"],
|
||||
inputEventType: CreateEventTypeInput_2024_06_14
|
||||
) {
|
||||
const transformedBody = this.transformInputCreateEventType(inputEventType);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
|
||||
locations: transformedBody.locations,
|
||||
requiresConfirmation: transformedBody.requiresConfirmation,
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
async transformAndValidateUpdateEventTypeInput(
|
||||
inputEventType: UpdateEventTypeInput_2024_06_14,
|
||||
userId: UserWithProfile["id"],
|
||||
eventTypeId: number
|
||||
) {
|
||||
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
eventTypeId: eventTypeId,
|
||||
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
|
||||
locations: transformedBody.locations,
|
||||
requiresConfirmation: transformedBody.requiresConfirmation,
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
@@ -26,74 +107,271 @@ export class InputEventTypesService_2024_06_14 {
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookerLayouts,
|
||||
confirmationPolicy,
|
||||
color,
|
||||
recurrence,
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const hasMultipleLocations = (locations || defaultLocations).length > 1;
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: this.transformInputLocations(locations || defaultLocations),
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingFields: this.transformInputBookingFields(bookingFields, hasMultipleLocations),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
},
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
eventTypeColor: this.transformInputEventTypeColor(color),
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
|
||||
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
||||
const {
|
||||
lengthInMinutes,
|
||||
locations,
|
||||
bookingFields,
|
||||
scheduleId,
|
||||
bookingLimitsCount,
|
||||
bookingLimitsDuration,
|
||||
bookingWindow,
|
||||
bookerLayouts,
|
||||
confirmationPolicy,
|
||||
color,
|
||||
recurrence,
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
const metadataTransformed = !!eventTypeDb?.metadata
|
||||
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
|
||||
: {};
|
||||
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
const hasMultipleLocations = !!(locations && locations?.length > 1);
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
schedule: scheduleId,
|
||||
bookingFields: bookingFields
|
||||
? this.transformInputBookingFields(bookingFields, hasMultipleLocations)
|
||||
: undefined,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
},
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
eventTypeColor: this.transformInputEventTypeColor(color),
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformApiEventTypeLocations(inputLocations);
|
||||
return transformLocationsApiToInternal(inputLocations);
|
||||
}
|
||||
|
||||
transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
|
||||
return transformApiEventTypeBookingFields(inputBookingFields);
|
||||
transformInputBookingFields(
|
||||
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
|
||||
hasMultipleLocations: boolean
|
||||
) {
|
||||
const defaultFieldsBefore = [systemBeforeFieldName, systemBeforeFieldEmail];
|
||||
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
|
||||
if (hasMultipleLocations) {
|
||||
defaultFieldsBefore.push(systemBeforeFieldLocation);
|
||||
}
|
||||
|
||||
const customFields = transformBookingFieldsApiToInternal(inputBookingFields);
|
||||
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
|
||||
|
||||
return [...defaultFieldsBefore, ...customFields, ...defaultFieldsAfter];
|
||||
}
|
||||
|
||||
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
|
||||
return transformApiEventTypeIntervalLimits(inputBookingFields);
|
||||
return transformIntervalLimitsApiToInternal(inputBookingFields);
|
||||
}
|
||||
|
||||
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
||||
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
|
||||
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
||||
return !!res ? res : {};
|
||||
}
|
||||
|
||||
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
|
||||
return transformBookerLayoutsApiToInternal(inputBookerLayouts);
|
||||
}
|
||||
|
||||
transformInputConfirmationPolicy(
|
||||
requiresConfirmation: CreateEventTypeInput_2024_06_14["confirmationPolicy"]
|
||||
) {
|
||||
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
|
||||
}
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
return transformApiEventTypeRecurrence(recurrence);
|
||||
if (!recurrence || recurrence.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transformRecurrenceApiToInternal(recurrence);
|
||||
}
|
||||
|
||||
transformInputEventTypeColor(color: CreateEventTypeInput_2024_06_14["color"]) {
|
||||
return transformEventColorsApiToInternal(color);
|
||||
}
|
||||
|
||||
transformInputSeatOptions(seats: CreateEventTypeInput_2024_06_14["seats"]) {
|
||||
return transformSeatsApiToInternal(seats);
|
||||
}
|
||||
async validateEventTypeInputs({
|
||||
eventTypeId,
|
||||
seatsPerTimeSlot,
|
||||
locations,
|
||||
requiresConfirmation,
|
||||
eventName,
|
||||
}: ValidationContext) {
|
||||
let seatsPerTimeSlotDb: number | null = null;
|
||||
let locationsDb: ReturnType<typeof this.transformInputLocations> = [];
|
||||
let requiresConfirmationDb = false;
|
||||
|
||||
if (eventTypeId != null) {
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithSeats(eventTypeId);
|
||||
seatsPerTimeSlotDb = eventTypeDb?.seatsPerTimeSlot ?? null;
|
||||
locationsDb = this.outputEventTypesService.transformLocations(eventTypeDb?.locations) ?? [];
|
||||
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
|
||||
}
|
||||
|
||||
const seatsPerTimeSlotFinal = seatsPerTimeSlot !== undefined ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||
const seatsEnabledFinal = seatsPerTimeSlotFinal != null && seatsPerTimeSlotFinal > 0;
|
||||
|
||||
const locationsFinal = locations !== undefined ? locations : locationsDb;
|
||||
const requiresConfirmationFinal =
|
||||
requiresConfirmation !== undefined ? requiresConfirmation : requiresConfirmationDb;
|
||||
|
||||
this.validateSeatsSingleLocationRule(seatsEnabledFinal, locationsFinal);
|
||||
this.validateSeatsRequiresConfirmationFalseRule(seatsEnabledFinal, requiresConfirmationFinal);
|
||||
this.validateMultipleLocationsSeatsDisabledRule(locationsFinal, seatsEnabledFinal);
|
||||
this.validateRequiresConfirmationSeatsDisabledRule(requiresConfirmationFinal, seatsEnabledFinal);
|
||||
|
||||
if (eventName) {
|
||||
await this.validateCustomEventNameInput(eventName);
|
||||
}
|
||||
}
|
||||
validateSeatsSingleLocationRule(
|
||||
seatsEnabled: boolean,
|
||||
locations: ReturnType<typeof this.transformInputLocations>
|
||||
) {
|
||||
if (seatsEnabled && locations.length > 1) {
|
||||
throw new BadRequestException(
|
||||
"Seats Validation failed: Seats are enabled but more than one location provided."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateSeatsRequiresConfirmationFalseRule(seatsEnabled: boolean, requiresConfirmation: boolean) {
|
||||
if (seatsEnabled && requiresConfirmation) {
|
||||
throw new BadRequestException(
|
||||
"Seats Validation failed: Seats are enabled but requiresConfirmation is true."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateMultipleLocationsSeatsDisabledRule(
|
||||
locations: ReturnType<typeof this.transformInputLocations>,
|
||||
seatsEnabled: boolean
|
||||
) {
|
||||
if (locations.length > 1 && seatsEnabled) {
|
||||
throw new BadRequestException("Locations Validation failed: Multiple locations but seats are enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
validateRequiresConfirmationSeatsDisabledRule(requiresConfirmation: boolean, seatsEnabled: boolean) {
|
||||
if (requiresConfirmation && seatsEnabled) {
|
||||
throw new BadRequestException(
|
||||
"RequiresConfirmation Validation failed: Seats are enabled but requiresConfirmation is true."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateCustomEventNameInput(value: string) {
|
||||
const validationResult = validateCustomEventName(value);
|
||||
if (validationResult !== true) {
|
||||
throw new BadRequestException(`Invalid event name variables: ${validationResult}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputDestinationCalendar(
|
||||
userId: number,
|
||||
destinationCalendar: DestinationCalendar_2024_06_14
|
||||
) {
|
||||
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
||||
|
||||
const matchedCalendar = allCals.find(
|
||||
(cal) =>
|
||||
cal.externalId === destinationCalendar.externalId &&
|
||||
cal.integration === destinationCalendar.integration
|
||||
);
|
||||
|
||||
if (!matchedCalendar) {
|
||||
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not exist");
|
||||
}
|
||||
|
||||
if (matchedCalendar.readOnly) {
|
||||
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not have write permission");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputUseDestinationCalendarEmail(userId: number) {
|
||||
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
||||
|
||||
const primaryCalendar = allCals.find((cal) => cal.primary);
|
||||
|
||||
if (!primaryCalendar) {
|
||||
throw new BadRequestException(
|
||||
"Validation failed: A primary connected calendar is required to set useDestinationCalendarEmail"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
+116
-26
@@ -1,25 +1,39 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule } from "@prisma/client";
|
||||
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
|
||||
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata,
|
||||
getResponseEventTypeLocations,
|
||||
getResponseEventTypeBookingFields,
|
||||
transformLocationsInternalToApi,
|
||||
transformBookingFieldsInternalToApi,
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
SystemField,
|
||||
UserField,
|
||||
CustomField,
|
||||
parseBookingLimit,
|
||||
getResponseEventTypeIntervalLimits,
|
||||
getResponseEventTypeFutureBookingLimits,
|
||||
getResponseEventTypeRecurrence,
|
||||
transformIntervalLimitsInternalToApi,
|
||||
transformFutureBookingLimitsInternalToApi,
|
||||
transformRecurrenceInternalToApi,
|
||||
transformBookerLayoutsInternalToApi,
|
||||
transformRequiresConfirmationInternalToApi,
|
||||
transformEventTypeColorsInternalToApi,
|
||||
parseEventTypeColor,
|
||||
transformSeatsInternalToApi,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
|
||||
import {
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
BookerLayoutsTransformedSchema,
|
||||
NoticeThresholdTransformedSchema,
|
||||
EventTypeOutput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
type EventTypeRelations = {
|
||||
users: User[];
|
||||
schedule: Schedule | null;
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
};
|
||||
export type DatabaseEventType = EventType & EventTypeRelations;
|
||||
|
||||
type Input = Pick<
|
||||
DatabaseEventType,
|
||||
@@ -58,11 +72,20 @@ type Input = Pick<
|
||||
| "periodCountCalendarDays"
|
||||
| "periodStartDate"
|
||||
| "periodEndDate"
|
||||
| "requiresBookerEmailVerification"
|
||||
| "hideCalendarNotes"
|
||||
| "eventTypeColor"
|
||||
| "seatsShowAttendees"
|
||||
| "requiresConfirmationWillBlockSlot"
|
||||
| "eventName"
|
||||
| "destinationCalendar"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class OutputEventTypesService_2024_06_14 {
|
||||
async getResponseEventType(ownerId: number, databaseEventType: Input) {
|
||||
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
|
||||
const {
|
||||
id,
|
||||
length,
|
||||
@@ -74,8 +97,6 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
beforeEventBuffer,
|
||||
afterEventBuffer,
|
||||
slug,
|
||||
schedulingType,
|
||||
requiresConfirmation,
|
||||
price,
|
||||
currency,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
@@ -87,17 +108,35 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
scheduleId,
|
||||
onlyShowFirstAvailableSlot,
|
||||
offsetStart,
|
||||
requiresBookerEmailVerification,
|
||||
hideCalendarNotes,
|
||||
seatsShowAttendees,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
const customName = databaseEventType?.eventName ?? undefined;
|
||||
const bookingFields = databaseEventType.bookingFields
|
||||
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
|
||||
: [];
|
||||
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
const users = this.transformUsers(databaseEventType.users);
|
||||
const users = this.transformUsers(databaseEventType.users || []);
|
||||
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
|
||||
const bookingLimitsDuration = this.transformIntervalLimits(databaseEventType.durationLimits);
|
||||
const color = this.transformEventTypeColor(databaseEventType.eventTypeColor);
|
||||
const bookerLayouts = this.transformBookerLayouts(
|
||||
metadata.bookerLayouts as unknown as BookerLayoutsTransformedSchema
|
||||
);
|
||||
const confirmationPolicy = this.transformRequiresConfirmation(
|
||||
!!databaseEventType.requiresConfirmation,
|
||||
!!databaseEventType.requiresConfirmationWillBlockSlot,
|
||||
metadata.requiresConfirmationThreshold as NoticeThresholdTransformedSchema
|
||||
);
|
||||
delete metadata["bookerLayouts"];
|
||||
delete metadata["requiresConfirmationThreshold"];
|
||||
const seats = this.transformSeats(seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount);
|
||||
const bookingWindow = this.transformBookingWindow({
|
||||
periodType: databaseEventType.periodType,
|
||||
periodDays: databaseEventType.periodDays,
|
||||
@@ -105,6 +144,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
periodStartDate: databaseEventType.periodStartDate,
|
||||
periodEndDate: databaseEventType.periodEndDate,
|
||||
} as TransformFutureBookingsLimitSchema_2024_06_14);
|
||||
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -121,16 +161,12 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
minimumBookingNotice,
|
||||
beforeEventBuffer,
|
||||
afterEventBuffer,
|
||||
schedulingType,
|
||||
metadata,
|
||||
requiresConfirmation,
|
||||
price,
|
||||
currency,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
seatsPerTimeSlot,
|
||||
forwardParamsSuccessRedirect,
|
||||
successRedirectUrl,
|
||||
seatsShowAvailabilityCount,
|
||||
isInstantEvent,
|
||||
users,
|
||||
scheduleId,
|
||||
@@ -139,24 +175,43 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
onlyShowFirstAvailableSlot,
|
||||
offsetStart,
|
||||
bookingWindow,
|
||||
bookerLayouts,
|
||||
confirmationPolicy,
|
||||
requiresBookerEmailVerification,
|
||||
hideCalendarNotes,
|
||||
color,
|
||||
seats,
|
||||
customName,
|
||||
destinationCalendar,
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
};
|
||||
}
|
||||
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
|
||||
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
|
||||
}
|
||||
|
||||
transformBookingFields(inputBookingFields: (SystemField | UserField)[] | null) {
|
||||
if (!inputBookingFields) return [];
|
||||
const userFields = inputBookingFields.filter((field) => field.editable === "user") as UserField[];
|
||||
return getResponseEventTypeBookingFields(userFields);
|
||||
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
|
||||
if (!destinationCalendar) return undefined;
|
||||
return {
|
||||
integration: destinationCalendar.integration,
|
||||
externalId: destinationCalendar.externalId,
|
||||
};
|
||||
}
|
||||
|
||||
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
return transformBookingFieldsInternalToApi(bookingFields);
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
return getResponseEventTypeRecurrence(recurringEventParsed);
|
||||
if (!recurringEventParsed) return null;
|
||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||
}
|
||||
|
||||
transformMetadata(metadata: any) {
|
||||
@@ -182,10 +237,45 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
|
||||
transformIntervalLimits(bookingLimits: any) {
|
||||
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
||||
return getResponseEventTypeIntervalLimits(bookingLimitsParsed);
|
||||
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
||||
}
|
||||
|
||||
transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) {
|
||||
return getResponseEventTypeFutureBookingLimits(bookingLimits);
|
||||
return transformFutureBookingLimitsInternalToApi(bookingLimits);
|
||||
}
|
||||
|
||||
transformBookerLayouts(bookerLayouts: BookerLayoutsTransformedSchema) {
|
||||
if (!bookerLayouts) return undefined;
|
||||
return transformBookerLayoutsInternalToApi(bookerLayouts);
|
||||
}
|
||||
|
||||
transformRequiresConfirmation(
|
||||
requiresConfirmation: boolean,
|
||||
requiresConfirmationWillBlockSlot: boolean,
|
||||
requiresConfirmationThreshold?: NoticeThresholdTransformedSchema
|
||||
) {
|
||||
return transformRequiresConfirmationInternalToApi(
|
||||
requiresConfirmation,
|
||||
requiresConfirmationWillBlockSlot,
|
||||
requiresConfirmationThreshold
|
||||
);
|
||||
}
|
||||
|
||||
transformEventTypeColor(eventTypeColor: any) {
|
||||
if (!eventTypeColor) return undefined;
|
||||
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
|
||||
return transformEventTypeColorsInternalToApi(parsedeventTypeColor);
|
||||
}
|
||||
|
||||
transformSeats(
|
||||
seatsPerTimeSlot: number | null,
|
||||
seatsShowAttendees: boolean | null,
|
||||
seatsShowAvailabilityCount: boolean | null
|
||||
) {
|
||||
return transformSeatsInternalToApi({
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees: !!seatsShowAttendees,
|
||||
seatsShowAvailabilityCount: !!seatsShowAvailabilityCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
@@ -44,7 +44,7 @@ const CALENDAR_SCOPES = [
|
||||
path: "/v2/gcal",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Google Calendar")
|
||||
@DocsTags("Platform / Google Calendar")
|
||||
export class GcalController {
|
||||
private readonly logger = new Logger("Platform Gcal Provider");
|
||||
|
||||
@@ -62,6 +62,7 @@ export class GcalController {
|
||||
@Get("/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Get auth URL" })
|
||||
async redirect(
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Req() req: Request
|
||||
@@ -81,6 +82,7 @@ export class GcalController {
|
||||
@Get("/oauth/save")
|
||||
@Redirect(undefined, 301)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Connect a calendar" })
|
||||
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
|
||||
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
|
||||
url.searchParams.append("code", code);
|
||||
@@ -92,6 +94,7 @@ export class GcalController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection status" })
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-us
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
@@ -30,6 +30,7 @@ export class MeController {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([PROFILE_READ])
|
||||
@ApiOperation({ summary: "Get my profile" })
|
||||
async getMe(@GetUser() user: UserWithProfile): Promise<GetMeOutput> {
|
||||
const organization = this.usersService.getUserMainProfile(user)?.organization;
|
||||
const me = userSchemaResponse.parse(
|
||||
@@ -52,6 +53,7 @@ export class MeController {
|
||||
|
||||
@Patch("/")
|
||||
@Permissions([PROFILE_WRITE])
|
||||
@ApiOperation({ summary: "Update my profile" })
|
||||
async updateMe(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateManagedUserInput
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingsModule } from "@/ee/bookings/bookings.module";
|
||||
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
@@ -21,7 +22,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_04_15,
|
||||
EventTypesModule_2024_06_14,
|
||||
CalendarsModule,
|
||||
BookingsModule,
|
||||
BookingsModule_2024_04_15,
|
||||
BookingsModule_2024_08_13,
|
||||
SlotsModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -24,12 +24,13 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
path: "/v2/provider",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal provider")
|
||||
@DocsTags("Platform / Cal Provider")
|
||||
export class CalProviderController {
|
||||
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
|
||||
|
||||
@Get("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get a provider" })
|
||||
async verifyClientId(@Param("clientId") clientId: string): Promise<ProviderVerifyClientOutput> {
|
||||
if (!clientId) {
|
||||
throw new NotFoundException();
|
||||
@@ -51,6 +52,7 @@ export class CalProviderController {
|
||||
@Get("/:clientId/access-token")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Verify an access token" })
|
||||
async verifyAccessToken(
|
||||
@Param("clientId") clientId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
|
||||
+2
-9
@@ -23,8 +23,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -36,7 +35,7 @@ import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input"
|
||||
version: VERSION_2024_04_15_VALUE,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
@DocsExcludeController(true)
|
||||
export class SchedulesController_2024_04_15 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
|
||||
|
||||
@@ -57,11 +56,6 @@ export class SchedulesController_2024_04_15 {
|
||||
|
||||
@Get("/default")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_04_15,
|
||||
})
|
||||
async getDefaultSchedule(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
@@ -78,7 +72,6 @@ export class SchedulesController_2024_04_15 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
+41
-4
@@ -17,8 +17,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -38,11 +37,40 @@ import {
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-06-11\``,
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
export class SchedulesController_2024_06_11 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({
|
||||
summary: "Create a schedule",
|
||||
description: `
|
||||
Create a schedule for the authenticated user.
|
||||
|
||||
The point of creating schedules is for event types to be available at specific times.
|
||||
|
||||
The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
|
||||
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the \`AvailabilitySettings\` atom.
|
||||
2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying
|
||||
\`"isDefault": true\` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.
|
||||
|
||||
The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.
|
||||
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||
|
||||
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
|
||||
`,
|
||||
})
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_06_11
|
||||
@@ -59,9 +87,12 @@ export class SchedulesController_2024_06_11 {
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_06_11,
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get default schedule",
|
||||
description: "Get the default schedule of the authenticated user.",
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
|
||||
@@ -73,7 +104,7 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
@ApiOperation({ summary: "Get a schedule" })
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
@@ -88,6 +119,10 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({
|
||||
summary: "Get all schedules",
|
||||
description: "Get all schedules of the authenticated user.",
|
||||
})
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
|
||||
@@ -99,6 +134,7 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Patch("/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({ summary: "Update a schedule" })
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
|
||||
@@ -119,6 +155,7 @@ export class SchedulesController_2024_06_11 {
|
||||
@Delete("/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({ summary: "Delete a schedule" })
|
||||
async deleteSchedule(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
@@ -19,6 +19,13 @@ export type Environment = {
|
||||
CALCOM_LICENSE_KEY: string;
|
||||
GET_LICENSE_KEY_URL: string;
|
||||
API_KEY_PREFIX: string;
|
||||
DOCS_URL: string;
|
||||
RATE_LIMIT_DEFAULT_TTL_MS: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_API_KEY: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT: number;
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -5,13 +5,16 @@ import {
|
||||
VERSION_2024_04_15,
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_06_14,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
|
||||
export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unknown as VersionValue;
|
||||
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
|
||||
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
|
||||
export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue;
|
||||
|
||||
export { VERSION_2024_04_15 };
|
||||
export { VERSION_2024_06_11 };
|
||||
export { VERSION_2024_06_14 };
|
||||
export { VERSION_2024_08_13 };
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import {
|
||||
ThrottlerGuard,
|
||||
ThrottlerException,
|
||||
ThrottlerRequest,
|
||||
ThrottlerModuleOptions,
|
||||
seconds,
|
||||
} from "@nestjs/throttler";
|
||||
import { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
|
||||
const rateLimitSchema = z.object({
|
||||
name: z.string(),
|
||||
limit: z.number(),
|
||||
ttl: z.number(),
|
||||
blockDuration: z.number(),
|
||||
});
|
||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||
|
||||
const sixtySecondsMs = 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
private logger = new Logger("CustomThrottlerGuard");
|
||||
|
||||
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", sixtySecondsMs));
|
||||
|
||||
private defaultLimitApiKey = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_API_KEY", 120));
|
||||
private defaultLimitOAuthClient = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT", 500));
|
||||
private defaultLimitAccessToken = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN", 500));
|
||||
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
|
||||
|
||||
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", sixtySecondsMs));
|
||||
|
||||
constructor(
|
||||
options: ThrottlerModuleOptions,
|
||||
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
|
||||
reflector: Reflector,
|
||||
private readonly dbRead: PrismaReadService
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||
const { context } = requestProps;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
|
||||
);
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.handleApiKeyRequest(tracker, response);
|
||||
} else {
|
||||
return this.handleNonApiKeyRequest(tracker, response);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||
|
||||
let allLimitsBlocked = true;
|
||||
for (const rateLimit of rateLimits) {
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (!isBlocked) {
|
||||
allLimitsBlocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allLimitsBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.log(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getDefaultRateLimit(tracker: string) {
|
||||
return {
|
||||
name: "default",
|
||||
limit: this.getDefaultLimit(tracker),
|
||||
ttl: this.getDefaultTtl(),
|
||||
blockDuration: this.getDefaultBlockDuration(),
|
||||
};
|
||||
}
|
||||
|
||||
getDefaultLimit(tracker: string) {
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.defaultLimitApiKey;
|
||||
} else if (tracker.startsWith("oauth_client_")) {
|
||||
return this.defaultLimitOAuthClient;
|
||||
} else if (tracker.startsWith("access_token_")) {
|
||||
return this.defaultLimitAccessToken;
|
||||
} else {
|
||||
return this.defaultLimit;
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultTtl() {
|
||||
return this.defaultTttl;
|
||||
}
|
||||
|
||||
getDefaultBlockDuration() {
|
||||
return this.defaultBlockDuration;
|
||||
}
|
||||
|
||||
private async getRateLimitsForApiKeyTracker(tracker: string) {
|
||||
const cacheKey = `rate_limit:${tracker}`;
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.log(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
const apiKey = tracker.replace("api_key_", "");
|
||||
let rateLimits: RateLimitType[];
|
||||
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
|
||||
where: { hashedKey: apiKey },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
throw new UnauthorizedException("Invalid API Key");
|
||||
}
|
||||
|
||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||
where: { apiKeyId: apiKeyRecord.id },
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.log(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.log(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
|
||||
return rateLimits;
|
||||
}
|
||||
|
||||
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
|
||||
const { name, limit, ttl, blockDuration } = rateLimit;
|
||||
|
||||
const key = `${tracker}:${limit}:${ttl}`;
|
||||
|
||||
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
|
||||
key,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
name
|
||||
);
|
||||
|
||||
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
|
||||
response.setHeader(
|
||||
`X-RateLimit-Remaining-${nameFirstUpper}`,
|
||||
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (authorizationHeader) {
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||
: `access_token_${authorizationHeader}`;
|
||||
}
|
||||
|
||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (oauthClientId) {
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return `ip_${request.ip}`;
|
||||
}
|
||||
|
||||
this.logger.log(`no tracker found: ${request.url}`);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
+73
-6
@@ -4,6 +4,12 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import {
|
||||
PathItemObject,
|
||||
PathsObject,
|
||||
OperationObject,
|
||||
TagObject,
|
||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
||||
import "dotenv/config";
|
||||
import * as fs from "fs";
|
||||
import { Server } from "http";
|
||||
@@ -13,6 +19,8 @@ import { bootstrap } from "./app";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loggerConfig } from "./lib/logger";
|
||||
|
||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
||||
|
||||
const run = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
@@ -35,13 +43,69 @@ const run = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
function customTagSort(a: string, b: string): number {
|
||||
const platformPrefix = "Platform";
|
||||
const orgsPrefix = "Orgs";
|
||||
|
||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function isOperationObject(obj: any): obj is OperationObject {
|
||||
return obj && typeof obj === "object" && "tags" in obj;
|
||||
}
|
||||
|
||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
||||
|
||||
Object.keys(paths).forEach((pathKey) => {
|
||||
const pathItem = paths[pathKey];
|
||||
|
||||
HttpMethods.forEach((method) => {
|
||||
const operation = pathItem[method];
|
||||
|
||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
||||
const firstTag = operation.tags[0];
|
||||
|
||||
if (!groupedPaths[firstTag]) {
|
||||
groupedPaths[firstTag] = {};
|
||||
}
|
||||
|
||||
groupedPaths[firstTag][pathKey] = pathItem;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
||||
const sortedPaths: PathsObject = {};
|
||||
|
||||
sortedTags.forEach((tag) => {
|
||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
||||
});
|
||||
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
const logger = new Logger("App");
|
||||
logger.log(`Generating Swagger documentation...\n`);
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com v2 API").build();
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const outputFile = "./swagger/documentation.json";
|
||||
|
||||
@@ -50,11 +114,14 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error: Error) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class RedirectsMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: () => void) {
|
||||
if (process.env.DOCS_URL && (req.url.startsWith("/v2/docs") || req.url.startsWith("/docs"))) {
|
||||
return res.redirect(process.env.DOCS_URL);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { App } from "@prisma/client";
|
||||
import { App, Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AppsRepository {
|
||||
@@ -10,4 +10,15 @@ export class AppsRepository {
|
||||
async getAppBySlug(slug: string): Promise<App | null> {
|
||||
return await this.dbRead.prisma.app.findUnique({ where: { slug } });
|
||||
}
|
||||
|
||||
async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
type: type,
|
||||
key: key,
|
||||
userId: userId,
|
||||
appId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
MembershipsRepository,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
})
|
||||
export class AtomsModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
these endpoints should not be recommended for use by third party and are excluded from docs
|
||||
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsController {
|
||||
constructor(private readonly eventTypesService: EventTypesAtomService) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateTeamEventType(eventTypeId, body, user, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
updateEventType,
|
||||
TUpdateEventTypeInputSchema,
|
||||
systemBeforeFieldEmail,
|
||||
getEventTypeById,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesAtomService {
|
||||
constructor(
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
||||
private readonly teamEventTypeService: OrganizationsEventTypesService
|
||||
) {}
|
||||
|
||||
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
if (eventType?.team?.id) {
|
||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||
} else {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
}
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
eventTypeId: number,
|
||||
body: TUpdateEventTypeInputSchema,
|
||||
user: UserWithProfile,
|
||||
teamId: number
|
||||
) {
|
||||
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
const eventType = await updateEventType({
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
|
||||
await this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
const eventType = await updateEventType({
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async checkCanUpdateTeamEventType(userId: number, eventTypeId: number, teamId: number, scheduleId: number) {
|
||||
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
|
||||
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
|
||||
}
|
||||
|
||||
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
|
||||
const membership = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
eventTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!membership?.team?.eventTypes?.some((item) => item.id === eventTypeId)) {
|
||||
throw new ForbiddenException(
|
||||
`Access denied. Either the team with ID=${teamId} does not own the event type with ID=${eventTypeId}, or your MEMBER role does not have permission to access this resource.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
createMock<OAuthClientRepository>()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
|
||||
it("should return false", async () => {
|
||||
const mockContext = createMockExecutionContext({});
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when oauth id is provided", () => {
|
||||
it("should return true for valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for multiple valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for empty Permissions decorator", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a missing permission", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { hasPermissions } from "@calcom/platform-utils";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private tokensRepository: TokensRepository,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (nextAuthToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authString) {
|
||||
if (!authString && !oAuthClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
|
||||
const oAuthClientPermissions = authString
|
||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
||||
|
||||
if (!oAuthClientPermissions) {
|
||||
return false;
|
||||
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
||||
}
|
||||
|
||||
async getOAuthClientPermissions(accessToken: string) {
|
||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
|
||||
async getOAuthClientPermissionsById(id: string) {
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class BillingRepository {
|
||||
billingStart: number,
|
||||
billingEnd: number,
|
||||
plan: PlatformPlan,
|
||||
subscription?: string
|
||||
subscriptionId?: string
|
||||
) {
|
||||
return this.dbWrite.prisma.platformBilling.update({
|
||||
where: {
|
||||
@@ -28,7 +28,7 @@ export class BillingRepository {
|
||||
data: {
|
||||
billingCycleStart: billingStart,
|
||||
billingCycleEnd: billingEnd,
|
||||
subscriptionId: subscription,
|
||||
subscriptionId,
|
||||
plan: plan.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,9 +7,8 @@ import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subsc
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import { Stripe } from "stripe";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@@ -40,6 +38,7 @@ export class BillingController {
|
||||
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly configService: ConfigService<AppConfig>
|
||||
) {
|
||||
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
|
||||
@@ -69,25 +68,31 @@ export class BillingController {
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { status } = await this.billingService.getBillingData(teamId);
|
||||
|
||||
if (status === "valid") {
|
||||
throw new BadRequestException("This team is already subscribed to a plan.");
|
||||
}
|
||||
|
||||
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
const customerId = await this.billingService.createTeamBilling(teamId);
|
||||
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:teamId/upgrade")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async upgradeTeamBillingInStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,33 +108,7 @@ export class BillingController {
|
||||
this.stripeWhSecret
|
||||
);
|
||||
|
||||
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription.metadata?.teamId) {
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(subscription.metadata.teamId);
|
||||
const plan = subscription.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
await this.billingService.setSubscriptionForTeam(
|
||||
teamId,
|
||||
subscription,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
await this.billingService.createOrUpdateStripeSubscription(event);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
|
||||
@@ -6,7 +6,13 @@ import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleDestroy,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Queue } from "bull";
|
||||
import { DateTime } from "luxon";
|
||||
@@ -41,14 +47,11 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
async createTeamBilling(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let brandNewBilling = false;
|
||||
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
if (!teamWithBilling?.platformBilling) {
|
||||
brandNewBilling = true;
|
||||
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
|
||||
|
||||
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
|
||||
@@ -57,43 +60,65 @@ export class BillingService implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
},
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/`,
|
||||
mode: "subscription",
|
||||
return customerId;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
},
|
||||
],
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/`,
|
||||
mode: "subscription",
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
currency: "usd",
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
}
|
||||
|
||||
return { action: "none" };
|
||||
return url;
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
|
||||
mode: "setup",
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
currency: "usd",
|
||||
});
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
@@ -102,10 +127,94 @@ export class BillingService implements OnModuleDestroy {
|
||||
billingCycleStart,
|
||||
billingCycleEnd,
|
||||
plan,
|
||||
subscription.id
|
||||
subscriptionId
|
||||
);
|
||||
}
|
||||
|
||||
async createOrUpdateStripeSubscription(event: Stripe.Event) {
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const subscription = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (!subscription.metadata?.teamId) {
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(subscription.metadata.teamId);
|
||||
const plan = subscription.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
if (subscription.mode === "subscription") {
|
||||
await this.setSubscriptionForTeam(
|
||||
teamId,
|
||||
subscription.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.mode === "setup") {
|
||||
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||
throw new NotFoundException("Team plan not found");
|
||||
}
|
||||
|
||||
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
teamWithBilling?.platformBilling?.subscriptionId
|
||||
);
|
||||
const currentLicensedItem = existingUserSubscription.items.data.find(
|
||||
(item) => item.price?.recurring?.usage_type === "licensed"
|
||||
);
|
||||
const currentOverageItem = existingUserSubscription.items.data.find(
|
||||
(item) => item.price?.recurring?.usage_type === "metered"
|
||||
);
|
||||
|
||||
if (!currentLicensedItem) {
|
||||
throw new NotFoundException("There is no licensed item present in the subscription");
|
||||
}
|
||||
|
||||
if (!currentOverageItem) {
|
||||
throw new NotFoundException("There is no overage item present in the subscription");
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: currentLicensedItem.id,
|
||||
price: this.billingConfigService.get(plan)?.base,
|
||||
},
|
||||
{
|
||||
id: currentOverageItem.id,
|
||||
price: this.billingConfigService.get(plan)?.overage,
|
||||
clear_usage: false,
|
||||
},
|
||||
],
|
||||
billing_cycle_anchor: "now",
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
|
||||
await this.setSubscriptionForTeam(
|
||||
teamId,
|
||||
teamWithBilling?.platformBilling?.subscriptionId,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* Adds a job to the queue to increment usage of a stripe subscription.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
],
|
||||
exports: [],
|
||||
controllers: [ConferencingController],
|
||||
})
|
||||
export class ConferencingModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user